---
# === IDENTITÉ ===
module: M3
nom: "Répertoire prestataires"
ecran: repertoire-prestataires
owner_spec: Matthieu
backup_spec: Tristan
version: V0.2
date_redaction: 2026-06-11
# Historique : V0.2 (2026-06-11) — Spec back initiale, miroir M2 (fournisseurs).
# Alignement refonte-contact (pas de contact inline sur le formulaire principal).
# Différences M3 : pas d'onglet Information ; site sur le formulaire principal (provider_site) ;
# adresse simple (pas de type/bennes/triage) ; nouveau pôle Technique.
# === LIENS ===
spec_front: ./spec-front.md
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-42090&p=f&m=dev"
# === LIEN LESSTIME ===
lesstime_taskgroup_id: 29 # M3 — Répertoire prestataires (projet STARSEED #6)
lesstime_project_id: 6
statut_global: en_dev
# === DÉPENDANCES AMONT ===
depend_de:
- M2-suppliers # pattern jumeau Supplier* répliqué en Provider* ; référentiels compta partagés
- M1-clients # référentiels comptables (TvaMode/PaymentDelay/PaymentType/Bank) + filtre ?typeCode= (créé au M2)
- M0-categories # Category + CategoryType (étendu par seed M3 : type PRESTATAIRE)
- Sites # SitesModule + 3 sites seedés (86 / 17 / 82) déjà en place
- Core # User, Role, Permission, Audit, JWT déjà en place
- Shared # TimestampableBlamableTrait + Subscriber (ERP-52)
---
# Spec back — Module 3 : Répertoire prestataires
## 1. Contexte
Cette spec **complète et précise** la [spec front V0.2](./spec-front.md) (`M3-reportoire-prestataires.docx` du 04/06/2026) avec tout ce qui touche au back : décisions d'archi, modèle de données, migration, API REST, RBAC, règles de gestion, tests, hors-périmètre.
**Module cible** : **nouveau module `Technique`** (`src/Module/Technique/`). Le prestataire est le **jumeau du fournisseur** (`Provider` / `ProviderContact` / `ProviderAddress` / `ProviderRib`), construit sur le pattern éprouvé M1/M2. Voir § 2.1 pour la justification du module séparé et la consommation des référentiels comptables.
**Dépendances déjà en place sur `develop`** (héritées M1/M2) :
- `Commercial` → référentiels comptables `TvaMode` / `PaymentDelay` / `PaymentType` / `Bank` (entités lecture seule, déjà seedées — **partagées sans duplication**, consommées en relation ORM).
- `Catalog` (M0) → `Category` + `CategoryType` + **filtre `?typeCode=` opérationnel** (créé au M2). Le M3 ajoute le type `PRESTATAIRE`.
- `Sites` → 3 sites Châtellerault (86) / Saint-Jean (17) / Pommevic (82).
- `Shared` → `TimestampableBlamableTrait` + `Subscriber` (ERP-52).
- `Core` → User, Role, Permission, Audit, JWT.
> **RETEX obligatoire** : lire [`../_RETEX-M1-pour-M2.md`](../_RETEX-M1-pour-M2.md) AVANT de coder. ~80 % des frictions M1 venaient du **contrat de sérialisation** (groupes / sous-ressources / embed), pas du métier. La section § 4.0 applique ce RETEX au M3.
## 2. Décisions d'archi
### 2.1 Module — Nouveau module `Technique`, entités jumelles de `Supplier`
> **⚠️ Décision à confirmer (Matthieu, 11/06/2026)** : le docx place le répertoire prestataires dans un **Module « Technique »**, confirmé comme **pôle distinct du Commercial**. On crée donc un **nouveau module back `Technique`** :
> - `src/Module/Technique/TechniqueModule.php` : `ID = 'technique'`, `LABEL = 'Technique'`, `REQUIRED = false`, méthode `permissions()` (cf. § 5.1).
> - Activation : ajouter `TechniqueModule::class` dans `config/modules.php`.
> - Front : layer Nuxt `frontend/modules/technique/` (auto-détecté) + nouvelle **section sidebar « Technique »** dans `config/sidebar.php`.
Le prestataire M3 **réplique à l'identique** le pattern `Supplier*` du M2 sous `Provider*` (tables dédiées, pas de table polymorphe partagée — clients / fournisseurs / prestataires divergent fonctionnellement, l'isolation prime).
**Référentiels comptables & Category — consommation cross-module (relation ORM partagée, PAS d'import de logique)** : `Provider` référence `TvaMode` / `PaymentDelay` / `PaymentType` / `Bank` (module Commercial) et `Category` / `Site` (modules Catalog / Sites) via des **relations ORM** (ManyToOne / ManyToMany), **exactement comme `Supplier` (Commercial) référence déjà `Site` (Sites) et `Category` (Catalog)**. Ce sont des **données de référence partagées**, pas de la logique inter-module : aucun service / repository d'un autre module n'est appelé. La règle ABSOLUE n°1 (« ne jamais importer d'un module à un autre — passer par Shared/Contract ») vise les **dépendances de logique métier** ; le projet a déjà acté (M1/M2) que la **référence ORM à une entité de référence partagée** est tolérée et documentée comme telle.
> **Décision Matthieu (11/06) : on fait « comme supplier »** — consommation ORM partagée des référentiels comptables (zéro refacto, zéro duplication). La remontée dans `Shared` (isolation stricte) reste une option future non retenue au M3 (tracé HP-M4-2).
### 2.2 IDs entier auto-increment Postgres natif
Cohérent avec M0/M1/M2 et l'ensemble Starseed. Pas d'UUID, pas de ULID. PK en `INT GENERATED BY DEFAULT AS IDENTITY` (style aligné M1/M2), horodatages en `TIMESTAMP(0) WITHOUT TIME ZONE` (le `TimestampableBlamableTrait` mappe `datetime_immutable`).
### 2.3 Référentiels comptables — réutilisation M1/M2 (zéro duplication)
Les 4 tables `tva_mode` / `payment_delay` / `payment_type` / `bank` (+ entités lecture seule et seeds) sont **celles du M1**. Le M3 ne crée **aucune** nouvelle table de référentiel comptable : `provider.tva_mode_id`, `provider.payment_delay_id`, `provider.payment_type_id`, `provider.bank_id` pointent vers les mêmes tables.
Endpoints : `GET /api/tva_modes`, `/api/payment_delays`, `/api/payment_types`, `/api/banks` existent déjà. **Évolution M3** : élargir leur `security` pour autoriser **aussi** les rôles prestataires (cf. § 4.7). Les codes pivots `VIREMENT` (RG-3.07) et `LCR` (RG-3.08) existent déjà dans `payment_types`.
### 2.4 Catégories — nouveau `CategoryType` `PRESTATAIRE`
Le multi-select « Catégorie » du prestataire (formulaire principal ET adresse) référence des `Category` rattachées à un **nouveau `CategoryType` de code `PRESTATAIRE`** (label « Prestataire »), seedé par le M3. On assume des **types distincts** (`CLIENT` / `FOURNISSEUR` / `PRESTATAIRE`) — chacun avec sa taxonomie.
> **Bonne nouvelle vs M2** : le **filtre `?typeCode=` a été implémenté au M2** sur `/api/categories` (module Catalog). Le M3 n'a donc **plus à le créer** : il suffit de **seeder le type `PRESTATAIRE`** + ses catégories (migration `ON CONFLICT` pour la prod + fixture idempotente pour survivre au purger en dev/test — cf. M2 § 3.2). **À vérifier sur le JSON réel** que `GET /api/categories?typeCode=PRESTATAIRE` filtre bien (DoD de la spec).
> **Forme réelle de `Category`** : expose `code` **et `name`** (PAS `label`) sous `category:read`, plus `categoryType{ id, code, label }`. Le **libellé affiché front = `category.name`**. Les M2M `provider_category` / `provider_address_category` ne contraignent que des `Category` de type `PRESTATAIRE` (RG-3.09).
### 2.5 Archive vs soft delete — deux mécanismes distincts (identique M1/M2)
| Mécanisme | Colonne | Visibilité défaut | Restauration | Utilisateur |
|---|---|---|---|---|
| **Archive** (fonctionnel) | `is_archived` (bool, default false) + `archived_at` | masqué | Oui (toggle UI) | **Admin seul** via `technique.providers.archive` |
| **Soft delete** (technique) | `deleted_at` (timestamptz nullable) | masqué | HP M4+ | Aucun rôle au M3 (HP) |
Conséquences (miroir M2) :
- `DELETE /api/providers/{id}` **non exposé** au M3 (404 si appelé).
- `GET /api/providers?includeArchived=true` permet de voir les archivés (permission `technique.providers.view`).
- PATCH `{ "isArchived": true }` archive ; PATCH `{ "isArchived": false }` restaure.
- L'unicité métier ignore les archivés ET les soft-deletés (cf. § 2.6).
### 2.6 Unicité partielle Postgres — nom de société
> **Décision à confirmer (alignée Q4 M1 / § 2.6 M2)** : l'unicité métier porte **uniquement sur le nom de prestataire** (`company_name`). Le SIREN et l'email principal ne sont **pas** uniques.
Index unique partiel (`WHERE is_archived = FALSE AND deleted_at IS NULL`) sur `LOWER(company_name)`. Doublon → `409 Conflict` géré par le `ProviderProcessor`.
### 2.7 Audit & traces temporelles
Pattern Starseed standard, miroir M1/M2 :
- `#[Auditable]` sur `Provider`, `ProviderContact`, `ProviderAddress`, `ProviderRib`.
- **Tous les champs auditables** (pas d'`#[AuditIgnore]`) — y compris `ProviderRib.iban` et `ProviderRib.bic` (audit admin-only côté Starseed → traçabilité comptable).
- Audit M2M automatique sur `provider.categories` et `provider.sites` (`{categories: {added:[...], removed:[...]}}`).
- **Libellés i18n** (règle ABSOLUE backend — `AuditableEntitiesHaveI18nLabelTest`) : ajouter `audit.entity.technique_provider`, `audit.entity.technique_providercontact`, `audit.entity.technique_provideraddress`, `audit.entity.technique_providerrib` dans `frontend/i18n/locales/fr.json` (clé = `strtolower(module)` + `_` + `strtolower(Entity)`).
### 2.8 Timestampable + Blamable
`Provider`, `ProviderContact`, `ProviderAddress`, `ProviderRib` implémentent `TimestampableInterface` + `BlamableInterface` et utilisent `TimestampableBlamableTrait`. Migration : 4 colonnes par table (`created_at`/`updated_at` NOT NULL, `created_by`/`updated_by` nullable `ON DELETE SET NULL`) + commentaires via le helper `addStandardTimestampableBlamableComments($schema, '
')`.
### 2.9 Permissions RBAC — granularité (5 permissions, identique M2)
| Permission | Admin | Bureau | Compta | Commerciale | Usine |
|---|---|---|---|---|---|
| `technique.providers.view` | ✅ | ✅ | ✅ | ✅ (sauf compta) | ✅ (cloisonné par site — § 2.13) |
| `technique.providers.manage` | ✅ | ✅ | ❌ | ✅ | ❌ |
| `technique.providers.accounting.view` | ✅ | ❌ | ✅ | ❌ | ❌ |
| `technique.providers.accounting.manage` | ✅ | ❌ | ✅ | ❌ | ❌ |
| `technique.providers.archive` | ✅ | ❌ | ❌ | ❌ | ❌ |
Notes (miroir M2) :
- **Compta édite uniquement l'onglet Comptabilité** (`accounting.manage`) d'un prestataire existant. Pas de création (pas de `manage` global).
- **Commerciale** : `view` + `manage` mais **pas** `accounting.view` → onglet Comptabilité masqué (front) et filtré (back) via le `ProviderReadGroupContextBuilder` (gating **par ajout** de groupe `provider:read:accounting`, jamais par retrait). Sans la permission, scalaires compta + `ribs` ne sont jamais sérialisés.
- **Bureau** : `view` + `manage` (tout sauf Comptabilité).
- **Usine** : `view` (lecture seule, pas de `manage`), **cloisonné par site** — voir § 2.13.
- **⚠️ Le « Tout » vs « son site uniquement » de la colonne Consultation du docx n'est PAS porté par le rôle** : c'est un **cloisonnement par site piloté par l'utilisateur** (décision Matthieu, 11/06). Tout user voit par défaut les prestataires de **son site courant** ; les profils qui doivent voir **tous les sites** (Admin, et selon besoin Bureau/Compta/Commerciale) l'obtiennent via la permission `sites.bypass_scope` (Admin l'a par bypass total). Mécanique complète en § 2.13.
### 2.10 Validation incrémentale par onglet (workflow front-driven, identique M2)
`Provider` créé en BDD **dès validation du formulaire principal** via `POST /api/providers`. Onglets suivants → **PATCH partiels** avec groupes de sérialisation dédiés :
- `provider:write:main` — formulaire principal (POST + PATCH) : `companyName`, `categories`, `sites`
- `provider:write:contacts` — onglet Contact (sous-ressource `provider_contact`)
- `provider:write:addresses` — onglet Adresse (sous-ressource `provider_address`)
- `provider:write:accounting` — onglet Comptabilité (security séparée)
- `provider:write:archive` — toggle archive (security `technique.providers.archive`)
**Pas de groupe `provider:write:information`** (pas d'onglet Information au M3). **Pas de state machine** côté back (pas de `status = draft|active`).
### 2.11 Normalisation serveur des entrées texte (identique M1/M2)
`ProviderFieldNormalizer` (miroir `SupplierFieldNormalizer`), service interne appelé par les Processors avant validation :
```php
final class ProviderFieldNormalizer
{
public function normalizeCompanyName(?string $v): ?string // mb_strtoupper(trim)
public function normalizePersonName(?string $v): ?string // mb_convert_case TITLE
public function normalizeEmail(?string $v): ?string // mb_strtolower(trim)
public function normalizePhone(?string $v): ?string // preg_replace('/\D+/', '')
}
```
Le formatage `XX XX XX XX XX` est fait à l'affichage front. Le back stocke `0612345678` (chiffres seuls).
### 2.12 Liste : embed catégories + sites + hydratation anti-N+1 (cohérence M1/M2)
La **liste** `GET /api/providers` **embarque** les `categories[]` (avec `code`/`name`) et les `sites[]` (avec `name`/`postalCode` — pas de `code`), comme M1/M2.
> **Différence M3 (importante)** : au M2, `sites[]` de la liste était l'**agrégat dédoublonné des adresses** (`Supplier::getSites()`). Au M3, le **prestataire porte directement des sites** (formulaire principal — RG-3.03, M2M `provider_site`). La colonne « Site » de la liste affiche donc **`provider.sites` (relation directe)**, pas un agrégat d'adresses. Plus simple et plus performant.
Anti-N+1 (le code fera foi) : le `DoctrineProviderRepository` ne fetch-joine PAS les to-many dans la requête de liste (filtres + tri seulement) ; `hydrateListCollections()` remplit `categories` puis `sites` (relation directe) via des requêtes `IN` bornées séparées sur les mêmes instances (identity map), pour éviter le produit cartésien sur les chemins non paginés (export, `?pagination=false`). Le contrat de sérialisation (groupes `category:read` / `site:read` dans le contexte) est posé **une seule fois** sur l'entité.
### 2.13 Cloisonnement par site — visibilité pilotée par l'utilisateur (DÉCISION M3)
> **Décision Matthieu (11/06/2026)** : la visibilité des prestataires est **cloisonnée par site, automatiquement, côté back, en fonction de l'utilisateur** — **pas du rôle**. Un user a un (ou des) site(s) (`user_site`, + un `currentSite` actif). Il ne voit que les prestataires **rattachés à son site**. Les profils qui doivent voir tous les sites passent par `sites.bypass_scope` (Admin l'a par bypass total). Le rôle « Usine » n'est qu'un cas particulier de cette règle générale.
**Réutilisation de l'infra Sites existante** (`docs/modules/site-aware.md`) : `CurrentSiteProvider` (site courant de l'user), permission `sites.bypass_scope` (voit tous les sites — Admin automatique), users ↔ sites via M2M `user_site`.
**⚠️ Pourquoi PAS `SiteAwareInterface` standard** : le pattern opt-in `SiteAwareInterface` + `SiteScopedQueryExtension` est **mono-site** (`site_id INT NOT NULL`, ManyToOne unique, filtre `x.site = :currentSite`). Or le prestataire est **multi-site** (M2M `provider_site`, ≥ 1 — RG-3.03). Le pattern standard ne s'applique donc pas tel quel. On câble un **filtre de cloisonnement custom multi-site** (cas explicitement renvoyé au module par `site-aware.md § 6.1 / § 6.2`), qui réutilise `CurrentSiteProvider` + `sites.bypass_scope` :
- **Filtre LISTE** (`ProviderProvider` ou query extension dédiée `ProviderSiteScopeExtension`) : si l'user **n'a pas** `sites.bypass_scope` ET que `CurrentSiteProvider::get()` retourne un site → ne renvoyer que les prestataires dont `provider.sites` **contient** le `currentSite` (jointure `provider_site` + `WHERE site = :currentSite`). Si l'user a `bypass_scope` (Admin, profils consolidation) → aucun filtre (tous sites). Si `currentSite = null` (mode dégradé / module Sites off) → aligné `site-aware.md § 5` (no-op lecture, à documenter).
- **Filtre DÉTAIL** (`Get`) : un user sans `bypass_scope` qui demande un prestataire **hors de son site courant** → **404** (cohérence : ne pas révéler l'existence d'une ligne hors périmètre).
- **Écriture (décision Matthieu, 11/06)** : un user **sans** `bypass_scope` ne peut attacher **que les sites dont il dispose** (ses `user_site`) — sur le formulaire principal (`provider.sites`, RG-3.03) **comme** sur chaque adresse (`provider_address.sites`, RG-3.05). Tout site hors de ses `user_site` dans le payload → **422** sur `sites`. Un user `bypass_scope` (Admin) peut attacher n'importe quel site. Garde porté par le `ProviderProcessor` (POST + PATCH + sous-ressource adresses).
- **Cohérence sous-ressources** (`/providers/{id}/...`) : le détail étant déjà gardé en 404 hors périmètre, les sous-ressources héritent du garde-fou parent (cf. `site-aware.md § 6.1`).
> **Conséquence RBAC** : la colonne « Consultation » du docx (« Tout » vs « son site uniquement ») se réalise **par `sites.bypass_scope`**, pas par le code de rôle. Décision d'attribution par défaut (à acter au ticket RBAC) : `bypass_scope` aux profils Admin (auto) + Bureau + Compta + Commerciale (ils voient « Tout » d'après le docx) ; **Usine ne l'a pas** → cloisonné à son site. Si MALIO préfère que Bureau/Commerciale soient aussi cloisonnés, il suffit de ne pas leur donner `bypass_scope` — **aucun code à changer** (c'est l'intérêt de piloter par user/permission et non par rôle).
> **Index** : `idx_provider_site_site` sur `provider_site(site_id)` (déjà prévu § 3.2) sert le filtre `WHERE site = :currentSite`.
## 3. Modèle de données
### 3.1 Diagramme
```
+----------------------+ +--------------------------+ +-----------------+
| provider |--n:m-->| provider_category |<--n:m--| category |
| | +--------------------------+ | type=PRESTATAIRE|
| id (PK) | +-----------------+
| company_name |--n:m-->| provider_site |<--n:m--| site (Sites) |
| is_archived | +--------------------------+ | (RG-3.03) |
| archived_at | +-----------------+
| deleted_at | +--------------------------+
| -- Comptabilité -- |--1:n-->| provider_contact |
| siren / account_num | +--------------------------+
| tva_mode_id | +-----------------+
| n_tva | +--------------------------+ | tva_mode (M1) |
| payment_delay_id |--1:n-->| provider_address | | payment_* (M1) |
| payment_type_id | +--------------------------+ | bank (M1) |
| bank_id (nullable) | | (PAS de address_type) +-----------------+
+----------------------+ +--n:m--> site
+--n:m--> provider_contact
+--------------------------+ +--n:m--> category (PRESTATAIRE)
| provider_rib |
+--------------------------+
label / bic / iban
```
**Particularités M3 (différences vs `supplier`)** :
- **PAS d'onglet Information** : aucun champ `description` / `competitors` / `founded_at` / `employees_count` / `revenue_amount` / `director_name` / `profit_amount` / `volume_forecast`. Le `provider` est minimal : nom + comptabilité.
- **`provider.sites` (M2M `provider_site`)** : sélecteur de site **sur le formulaire principal** (RG-3.03, ≥ 1). NOUVEAU vs supplier (qui n'avait des sites que sur l'adresse).
- **`provider_address` simplifiée** : **pas** de `address_type`, **pas** de `bennes`, **pas** de `triage_provider`. Champs : sites[], street, street_complement, postal_code, city, country, categories[], contacts[].
- Les référentiels comptables (`tva_mode`...) **ne sont pas recréés** — FK vers les tables M1.
### 3.2 Migration Doctrine — SQL Postgres
Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrations/VersionYYYYMMDDHHMMSS.php` (à dater par le dev).
> **Même justification qu'au M1/M2** : la migration crée un schéma avec **FK cross-module** (`user`, `category`, `site`, FK vers les référentiels comptables M1). Le namespace modulaire casserait l'ordre (`make db-reset`) — exception racine de la règle ABSOLUE n°11. Le seed du `CategoryType PRESTATAIRE` se fait **en deux endroits** (migration `ON CONFLICT` pour la prod + fixture idempotente en dev/test).
> **Rappel règle ABSOLUE n°12** : chaque colonne créée DOIT recevoir son `COMMENT ON COLUMN` (FR, ≤ 200 car., sémantique + contrainte/RG). Les 4 colonnes Timestampable/Blamable passent par le helper. Le SQL ci-dessous est *illustratif* (style aligné M1/M2 : `INT GENERATED BY DEFAULT AS IDENTITY`, `TIMESTAMP(0) WITHOUT TIME ZONE`).
```sql
-- =====================================================================
-- Seed taxonomie : nouveau type PRESTATAIRE (référentiels comptables = M1, non recréés)
-- =====================================================================
INSERT INTO category_type (code, label) VALUES ('PRESTATAIRE', 'Prestataire')
ON CONFLICT (code) DO NOTHING;
-- =====================================================================
-- Table principale `provider`
-- =====================================================================
CREATE TABLE provider (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
-- Formulaire principal
company_name VARCHAR(180) NOT NULL,
-- (PAS d'onglet Information — aucun champ description/competitors/founded/employees/...)
-- Onglet Comptabilité (FK référentiels M1 — partagés)
siren VARCHAR(20),
account_number VARCHAR(40),
tva_mode_id INT REFERENCES tva_mode(id) ON DELETE RESTRICT,
n_tva VARCHAR(40),
payment_delay_id INT REFERENCES payment_delay(id) ON DELETE RESTRICT,
payment_type_id INT REFERENCES payment_type(id) ON DELETE RESTRICT,
bank_id INT REFERENCES bank(id) ON DELETE RESTRICT,
-- Archive (exposé M3)
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
archived_at TIMESTAMP(0) WITHOUT TIME ZONE,
-- Soft delete (préparé, non exposé au M3)
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE,
-- Timestampable + Blamable
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
updated_by INT REFERENCES "user"(id) ON DELETE SET NULL
);
CREATE INDEX idx_provider_is_archived ON provider(is_archived);
CREATE INDEX idx_provider_deleted_at ON provider(deleted_at);
CREATE INDEX idx_provider_created_by ON provider(created_by);
CREATE INDEX idx_provider_updated_by ON provider(updated_by);
-- Unicité métier (partielle : ignore archives + soft-delete) — nom de société uniquement (cf. § 2.6)
CREATE UNIQUE INDEX uq_provider_company_name_active
ON provider (LOWER(company_name))
WHERE is_archived = FALSE AND deleted_at IS NULL;
-- =====================================================================
-- M2M provider ↔ category (catégories de type PRESTATAIRE — RG-3.09)
-- =====================================================================
CREATE TABLE provider_category (
provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE,
category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT,
PRIMARY KEY (provider_id, category_id)
);
CREATE INDEX idx_provider_category_category ON provider_category(category_id);
-- =====================================================================
-- M2M provider ↔ site (sélecteur de site du FORMULAIRE PRINCIPAL — RG-3.03)
-- =====================================================================
CREATE TABLE provider_site (
provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE,
site_id INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT,
PRIMARY KEY (provider_id, site_id)
);
CREATE INDEX idx_provider_site_site ON provider_site(site_id);
-- =====================================================================
-- Sous-collection : Contacts (1:n)
-- =====================================================================
CREATE TABLE provider_contact (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE,
first_name VARCHAR(120),
last_name VARCHAR(120),
job_title VARCHAR(120),
phone_primary VARCHAR(20),
phone_secondary VARCHAR(20),
email VARCHAR(180),
position INT NOT NULL DEFAULT 0,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
updated_by INT REFERENCES "user"(id) ON DELETE SET NULL,
-- RG-3.04 : au moins 1 champ rempli (garanti côté Processor ; le CHECK ci-dessous
-- couvre le cas « nom OU prénom » comme garde-fou minimal aligné M2)
CONSTRAINT chk_provider_contact_name
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL)
);
CREATE INDEX idx_provider_contact_provider ON provider_contact(provider_id);
-- =====================================================================
-- Sous-collection : Adresses (1:n) — PAS de address_type / bennes / triage
-- =====================================================================
CREATE TABLE provider_address (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE,
country VARCHAR(80) NOT NULL DEFAULT 'France',
postal_code VARCHAR(20) NOT NULL,
city VARCHAR(120) NOT NULL,
street VARCHAR(255) NOT NULL,
street_complement VARCHAR(255),
position INT NOT NULL DEFAULT 0,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
updated_by INT REFERENCES "user"(id) ON DELETE SET NULL
);
CREATE INDEX idx_provider_address_provider ON provider_address(provider_id);
-- M2M provider_address ↔ site (RG-3.05 : ≥ 1 site)
CREATE TABLE provider_address_site (
provider_address_id INT NOT NULL REFERENCES provider_address(id) ON DELETE CASCADE,
site_id INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT,
PRIMARY KEY (provider_address_id, site_id)
);
-- M2M provider_address ↔ provider_contact
CREATE TABLE provider_address_contact (
provider_address_id INT NOT NULL REFERENCES provider_address(id) ON DELETE CASCADE,
provider_contact_id INT NOT NULL REFERENCES provider_contact(id) ON DELETE CASCADE,
PRIMARY KEY (provider_address_id, provider_contact_id)
);
-- M2M provider_address ↔ category (catégorie d'adresse, type PRESTATAIRE — RG-3.09)
CREATE TABLE provider_address_category (
provider_address_id INT NOT NULL REFERENCES provider_address(id) ON DELETE CASCADE,
category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT,
PRIMARY KEY (provider_address_id, category_id)
);
-- =====================================================================
-- Sous-collection : RIB (1:n)
-- =====================================================================
CREATE TABLE provider_rib (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE,
label VARCHAR(120) NOT NULL,
bic VARCHAR(20) NOT NULL,
iban VARCHAR(34) NOT NULL,
position INT NOT NULL DEFAULT 0,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
updated_by INT REFERENCES "user"(id) ON DELETE SET NULL
);
CREATE INDEX idx_provider_rib_provider ON provider_rib(provider_id);
```
### 3.2.bis Commentaires SQL obligatoires (échantillon)
```php
$this->addSql("COMMENT ON TABLE provider IS 'Répertoire prestataires (M3 Technique) — entités archivables.'");
$this->addSql("COMMENT ON COLUMN provider.company_name IS 'Raison sociale du prestataire — stockée en MAJUSCULES. Unique parmi non-archivés/non-supprimés (RG-3.10).'");
$this->addSql("COMMENT ON COLUMN provider.payment_type_id IS 'Type de règlement — FK -> payment_type.id (référentiel partagé M1), ON DELETE RESTRICT. Pilote RG-3.07 (Banque) et RG-3.08 (RIB).'");
$this->addSql("COMMENT ON COLUMN provider.bank_id IS 'Banque — FK -> bank.id (M1). Obligatoire ssi payment_type=VIREMENT (RG-3.07), null sinon.'");
$this->addSql("COMMENT ON COLUMN provider.siren IS 'SIREN du prestataire (9 chiffres). Non unique (cf. RG-3.10). Saisi à l''onglet Comptabilité.'");
// provider_site (M2M) : commenter via COMMENT ON TABLE
$this->addSql("COMMENT ON TABLE provider_site IS 'Sites rattachés au prestataire (sélecteur du formulaire principal — RG-3.03, ≥ 1).'");
$this->addSql("COMMENT ON COLUMN provider_address.postal_code IS 'Code postal — déclenche l''autocomplétion ville via l''API BAN (RG-3.06).'");
// + COMMENT ON COLUMN sur TOUTES les autres colonnes métier (cf. règle n°12)
$this->addStandardTimestampableBlamableComments($schema, 'provider');
$this->addStandardTimestampableBlamableComments($schema, 'provider_contact');
$this->addStandardTimestampableBlamableComments($schema, 'provider_address');
$this->addStandardTimestampableBlamableComments($schema, 'provider_rib');
```
### 3.3 Entité `Provider` — squelette (extrait)
Miroir de `Supplier` (cf. [`../M2-suppliers/spec-back.md § 3.3`](../M2-suppliers/spec-back.md)), **amputé de l'onglet Information** et **augmenté de `sites` (relation directe)**.
```php
['provider:read', 'category:read', 'site:read', 'default:read']],
provider: ProviderProvider::class,
),
new Get(
security: "is_granted('technique.providers.view')",
// Détail embarque sous-collections (contacts, addresses, ribs) + relations imbriquées.
// provider:read:accounting AJOUTÉ dynamiquement par le ReadGroupContextBuilder si accounting.view.
normalizationContext: ['groups' => [
'provider:read', 'provider:item:read',
'category:read', 'site:read', 'default:read',
]],
provider: ProviderProvider::class,
),
new Post(
security: "is_granted('technique.providers.manage')",
normalizationContext: ['groups' => ['provider:read', 'default:read']],
denormalizationContext: ['groups' => ['provider:write:main']],
processor: ProviderProcessor::class,
),
new Patch(
// Security élargie : manage OU accounting.manage (Compta édite la compta sans manage global).
security: "is_granted('technique.providers.manage') or is_granted('technique.providers.accounting.manage')",
normalizationContext: ['groups' => ['provider:read', 'default:read']],
denormalizationContext: ['groups' => [
'provider:write:main', 'provider:write:accounting', 'provider:write:archive',
]],
provider: ProviderProvider::class,
processor: ProviderProcessor::class,
),
// Pas de Delete au M3 (HP M4). Archivage via PATCH { isArchived: true }.
],
)]
#[ORM\Entity(repositoryClass: DoctrineProviderRepository::class)]
#[ORM\Table(name: 'provider')]
#[Auditable]
class Provider implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
#[Groups(['provider:read'])]
private ?int $id = null;
#[ORM\Column(length: 180)]
#[Assert\NotBlank(message: 'Le nom du prestataire est obligatoire.', normalizer: 'trim')]
#[Assert\Length(min: 2, max: 180, normalizer: 'trim')]
#[Groups(['provider:read', 'provider:write:main'])]
private ?string $companyName = null;
/** @var Collection Catégories de type PRESTATAIRE (RG-3.09) */
#[ORM\ManyToMany(targetEntity: Category::class)]
#[ORM\JoinTable(name: 'provider_category')]
#[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
#[Groups(['provider:read', 'provider:write:main'])]
private Collection $categories;
/** @var Collection Sites du prestataire — sélecteur du formulaire principal (RG-3.03) */
#[ORM\ManyToMany(targetEntity: Site::class)]
#[ORM\JoinTable(name: 'provider_site')]
#[Assert\Count(min: 1, minMessage: 'Au moins un site est obligatoire.')]
#[Groups(['provider:read', 'provider:write:main'])]
private Collection $sites;
// === Onglet Comptabilité (lecture/écriture conditionnées par permission — cf. M2) ===
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $siren = null;
#[ORM\Column(length: 40, nullable: true)]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $accountNumber = null;
#[ORM\ManyToOne(targetEntity: TvaMode::class)]
#[ORM\JoinColumn(name: 'tva_mode_id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?TvaMode $tvaMode = null;
#[ORM\Column(length: 40, nullable: true)]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $nTva = null;
#[ORM\ManyToOne(targetEntity: PaymentDelay::class)]
#[ORM\JoinColumn(name: 'payment_delay_id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?PaymentDelay $paymentDelay = null;
#[ORM\ManyToOne(targetEntity: PaymentType::class)]
#[ORM\JoinColumn(name: 'payment_type_id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?PaymentType $paymentType = null;
#[ORM\ManyToOne(targetEntity: Bank::class)]
#[ORM\JoinColumn(name: 'bank_id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?Bank $bank = null;
// === Sous-collections — EMBARQUÉES dans le DÉTAIL (RETEX M1 §2) ===
/** @var Collection */
#[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[Groups(['provider:item:read'])]
private Collection $contacts;
/** @var Collection */
#[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[Groups(['provider:item:read'])]
private Collection $addresses;
/** @var Collection RIB embarqués dans le groupe COMPTA (gated par le Provider) */
#[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderRib::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[Groups(['provider:read:accounting'])]
private Collection $ribs;
// === Archive / Soft delete ===
#[ORM\Column(name: 'is_archived', options: ['default' => false])]
private bool $isArchived = false;
// ⚠ PIÈGE BOOLÉEN (bug #3 M1) : #[Groups] + #[SerializedName('isArchived')] SUR LE GETTER,
// sinon Symfony strip "is" → attribut "archived" → clé droppée. À tester sur JSON réel.
#[Groups(['provider:read', 'provider:write:archive'])]
#[SerializedName('isArchived')]
public function isArchived(): bool
{
return $this->isArchived;
}
// ... archivedAt, getters/setters, __construct (ArrayCollection) ...
}
```
### 3.4 Squelettes des autres entités
Même pattern que les jumelles `Supplier*` (`#[Auditable]`, `TimestampableBlamableTrait`, FK `provider_id`). **Chaque propriété affichée porte un read-group** (RETEX M1 §1 maillon (a)) :
**`ProviderContact`** — propriétés dans `['provider:item:read', 'provider:write:contacts']` :
`firstName`, `lastName`, `jobTitle`, `phonePrimary`, `phoneSecondary`, `email`, `id`. Embed sous `provider.contacts` au détail ; éditables via la sous-ressource. **Max 2 téléphones** (`phonePrimary` + `phoneSecondary`).
**`ProviderAddress`** — propriétés dans `['provider:item:read', 'provider:write:addresses']` :
`country`, `postalCode`, `city`, `street`, `streetComplement`, `id`. **PAS** de `addressType` / `bennes` / `triageProvider`. Relations imbriquées (maillon (c) — read-groups dans le contexte du `Get` racine) :
- M2M `sites` → `#[Groups(['provider:item:read'])]` ; `Site` expose `id`/`name`/`postalCode`/`city`/`color` en `site:read` (**pas de `code`**) (`Assert\Count(min:1)` — RG-3.05).
- M2M `contacts` → `#[Groups(['provider:item:read'])]` ; embarque des `ProviderContact`.
- M2M `categories` → `#[Groups(['provider:item:read'])]` ; `Category` (id/code/name, type PRESTATAIRE — RG-3.09).
**`ProviderRib`** — propriétés dans `['provider:read:accounting', 'provider:write:accounting']` :
`label`, `bic`, `iban`, `id`. Embed sous `provider.ribs` **uniquement** si l'user a `accounting.view`. Aucun `#[AuditIgnore]` sur `iban`/`bic`.
> ⚠ `Site` / `Category` / référentiels comptables appartiennent à d'autres modules — on consomme leurs read-groups (`site:read`, `category:read`, `provider:read:accounting` pour les réfs compta), **pas de logique inter-module** (§ 2.1).
## 4. API REST (API Platform)
### 4.0 Contrat de sérialisation (RETEX M1 — section critique)
> **Leçon M1/M2** : ~80 % des frictions venaient du contrat de sérialisation. Pour **chaque champ affiché** (liste OU détail), les **3 maillons** doivent être prouvés : (a) groupe sur la propriété, (b) groupe dans le `normalizationContext` de l'opération, (c) read-group de l'entité imbriquée présent dans le contexte parent.
**Contexte par opération** :
| Opération | `normalizationContext` (groupes) |
|---|---|
| `GetCollection` (liste) | `provider:read` + `category:read` + `site:read` + `default:read` |
| `Get` (détail) | `provider:read` + `provider:item:read` + `provider:read:accounting`¹ + `category:read` + `site:read` + `default:read` |
¹ `provider:read:accounting` retiré par le `ProviderProvider` / `ProviderReadGroupContextBuilder` si l'user n'a pas `technique.providers.accounting.view`.
**LISTE — champ datatable → maillons** :
| Champ affiché | Propriété (a) | Dans contexte liste (b) | Imbriqué (c) |
|---|---|---|---|
| Nom | `companyName` ∈ `provider:read` | ✅ | — |
| Catégories | `categories` ∈ `provider:read` (embed) | ✅ | `category:read` ✅ (code/**name**) |
| Site | `sites` ∈ `provider:read` (embed, relation **directe** — RG-3.03) | ✅ | `site:read` ✅ (**name**/postalCode, pas de code) |
| Dernière activité | `updatedAt` ∈ `provider:read` | ✅ | — |
**DÉTAIL — champ → maillons** :
| Bloc / champ | Propriété (a) | Dans contexte détail (b) | Imbriqué (c) |
|---|---|---|---|
| Scalaires principaux | `provider:read` | ✅ | — |
| `categories[]` (id/code/name) | `categories` ∈ `provider:read` | ✅ | `category:read` ✅ |
| `sites[]` (formulaire principal) | `sites` ∈ `provider:read` | ✅ | `site:read` ✅ |
| `contacts[]` (5 champs) | `contacts` ∈ `provider:item:read` | ✅ | propriétés `ProviderContact` ∈ `provider:item:read` ✅ |
| `addresses[]` (scalaires) | `addresses` ∈ `provider:item:read` | ✅ | propriétés `ProviderAddress` ∈ `provider:item:read` ✅ |
| `addresses[].sites[]` | `sites` ∈ `provider:item:read` | ✅ | `site:read` ✅ |
| `addresses[].categories[]` | `categories` ∈ `provider:item:read` | ✅ | `category:read` ✅ |
| `addresses[].contacts[]` | `contacts` ∈ `provider:item:read` | ✅ | propriétés `ProviderContact` ∈ `provider:item:read` ✅ |
| Scalaires Comptabilité | `provider:read:accounting` | ✅ (gated) | réfs (`tvaMode`…) id+label ∈ `provider:read:accounting` |
| `ribs[]` (label/bic/iban) | `ribs` ∈ `provider:read:accounting` | ✅ (gated) | — |
### 4.0.bis Réponses JSON de référence (DoD — à CAPTURER sur l'API réelle)
> **Definition of Done** (miroir ERP-92 du M2) : avant de démarrer les écrans front, **capturer les réponses RÉELLES** via un test PHPUnit (`ProviderSerializationContractTest`, prestataire complet seedé) et les coller ici. Toute donnée affichée par le front DOIT apparaître dans ce JSON. **Ne jamais déclarer un champ « embarqué » sans l'avoir vu dans un JSON réel** (règle anti-régression M2).
>
> **2 pièges hérités M1/M2 à re-tester sur le M3** :
> 1. Réfs comptables (`tvaMode`/`paymentDelay`/`paymentType`/`bank`) : doivent sortir en **objet `{id, code, label}`**, pas en IRI nu → vérifier que les entités partagées portent bien le groupe `provider:read:accounting` (sinon les annoter, comme le fix ERP-92 l'a fait pour `supplier:read:accounting`).
> 2. Gating compta par **omission de clé** : pour un user sans `accounting.view`, les clés `siren`/`tvaMode`/`ribs`/… sont **absentes** (pas `null`).
`GET /api/providers` (liste, ADMIN — un membre, forme attendue) :
```json
{
"@context": "/api/contexts/Provider",
"@id": "/api/providers",
"@type": "Collection",
"totalItems": 1,
"member": [
{
"@id": "/api/providers/1", "@type": "Provider", "id": 1,
"companyName": "MAINTENANCE PRO SAS",
"categories": [
{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE",
"categoryType": {"@id": "/api/category_types/3", "@type": "CategoryType", "id": 3, "code": "PRESTATAIRE", "label": "Prestataire"}}
],
"sites": [
{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}
],
"siren": "987654321", "accountNumber": "P0001",
"tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"},
"paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"},
"ribs": [
{"@id": "/api/provider_ribs/1", "@type": "ProviderRib", "id": 1, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"}
],
"updatedAt": "2026-06-11T10:00:00+02:00",
"isArchived": false
}
],
"view": {"@id": "/api/providers", "@type": "PartialCollectionView"}
}
```
> Les prestataires archivés sont **exclus** du `totalItems` (RG-3.16). Pour la **Commerciale** (sans `accounting.view`), `siren`/`tvaMode`/`paymentType`/`ribs`… **disparaissent** de chaque membre.
`GET /api/providers/{id}` (détail — user avec `accounting.view`, forme attendue) :
```json
{
"@id": "/api/providers/1", "@type": "Provider", "id": 1,
"companyName": "MAINTENANCE PRO SAS",
"categories": [{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE"}],
"sites": [{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}],
"siren": "987654321", "accountNumber": "P0001",
"tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"},
"nTva": "FR00987654321",
"paymentDelay": {"@id": "/api/payment_delays/11", "@type": "PaymentDelay", "id": 11, "code": "J30", "label": "30 jours"},
"paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"},
"contacts": [
{"@id": "/api/provider_contacts/1", "@type": "ProviderContact", "id": 1, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test"}
],
"addresses": [
{"@id": "/api/provider_addresses/1", "@type": "ProviderAddress", "id": 1, "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
"sites": [{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}],
"contacts": [{"@id": "/api/provider_contacts/1", "@type": "ProviderContact", "id": 1, "firstName": "Marie", "lastName": "Martin"}],
"categories": [{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE"}]}
],
"ribs": [{"@id": "/api/provider_ribs/1", "@type": "ProviderRib", "id": 1, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"}],
"isArchived": false
}
```
> Pour un user **sans** `accounting.view` (ex. Commerciale) : les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank`, `ribs` **sont absentes** (gating par omission — à confirmer par test).
### 4.1 `GET /api/providers` — Liste
- **Security** : `is_granted('technique.providers.view')`
- **Query params** (alimentent le panneau « Filtrer ») :
- `includeArchived=true|false` (default `false`)
- `categoryCode=` (filtre les prestataires ayant ≥ 1 `Category` de ce code ; répétable)
- `siteId=` (filtre via la relation **directe** `provider.sites` ; répétable) — *NB : au M3 le site est porté par le prestataire, le filtre joint `provider_site` (pas les adresses).*
- `search=` (fuzzy sur `companyName` + contacts liés `provider_contact` (firstName / lastName / email) via LEFT JOIN groupé par `provider.id`)
- **Tri par défaut** : `companyName ASC`
- **Cloisonnement par site (§ 2.13)** : si l'user **n'a pas** `sites.bypass_scope`, la liste est filtrée sur les prestataires dont `provider.sites` contient le `currentSite` (RG-3.17). Transparent pour le client (pas de query param).
- **Pagination** : standard Starseed (règle ABSOLUE n°13) — Hydra, 10/page, `?pagination=false` pour les selects. `ProviderProvider` branché sur `ApiPlatform\Doctrine\Orm\Paginator`. ⚠️ Le filtre de cloisonnement s'applique **avant** la pagination (le `totalItems` reflète le périmètre de l'user).
- **Anti N+1 (§ 2.12)** : hydratation des `categories` + `sites` via requêtes `IN` bornées séparées (pas de fetch-join combiné).
- **Codes** : `200` / `401` / `403`
### 4.2 `GET /api/providers/{id}` — Détail
- **Security** : `is_granted('technique.providers.view')`
- **Comportement** : prestataire + contacts + adresses + RIBs. Champs `provider:read:accounting` inclus seulement si `technique.providers.accounting.view`.
- **Cloisonnement par site (§ 2.13)** : un user sans `sites.bypass_scope` qui demande un prestataire **hors de son site courant** → **404** (ne pas révéler l'existence hors périmètre — RG-3.17).
- **Codes** : `200` / `404` / `401` / `403`
### 4.3 `POST /api/providers` — Création (formulaire principal)
- **Security** : `is_granted('technique.providers.manage')`
- **Body** (groupe `provider:write:main`) :
```json
{
"companyName": "MAINTENANCE PRO SAS",
"categories": ["/api/categories/300"],
"sites": ["/api/sites/87"]
}
```
- **Réponse 201** : le prestataire créé avec son `id`. Le front enchaîne les PATCH par onglet.
- **Codes** :
- `201` / `400` / `401` / `403`
- `409 Conflict` si doublon de nom (`companyName` — RG-3.10). SIREN/email non uniques.
- `422` : catégories vides (RG-3.09) ; sites vides (RG-3.03) ; catégorie hors type PRESTATAIRE (RG-3.09).
### 4.4 `PATCH /api/providers/{id}` — Modification
- **Security base** : `is_granted('technique.providers.manage')`
- **Security additionnelle** (dans le `ProviderProcessor`) :
- payload contenant un champ `provider:write:accounting` → exige `technique.providers.accounting.manage`
- payload contenant `isArchived` → exige `technique.providers.archive`
- **mode strict** (RG-3.15) : payload mélangeant des groupes hors permissions → 403 sur tout le payload.
- **Body** : merge-patch+json, champs modifiés uniquement.
- **Codes** : `200` / `400` / `401` / `403` / `404` / `409` / `422`
### 4.5 Sous-ressources
**Contacts** : `POST /api/providers/{id}/contacts`, `PATCH /api/provider_contacts/{id}`, `DELETE /api/provider_contacts/{id}`.
- **Security** : `is_granted('technique.providers.manage')`
- **RG-3.12** : au moins 1 bloc Contact valide pour finaliser l'onglet côté front. Côté back, la collection peut rester vide (pas de state machine).
**Adresses** : `POST /api/providers/{id}/addresses`, `PATCH /api/provider_addresses/{id}`, `DELETE /api/provider_addresses/{id}`.
- **Security** : `is_granted('technique.providers.manage')`
- Validations : ≥ 1 site (RG-3.05) ; catégories de type PRESTATAIRE uniquement (RG-3.09) ; `postalCode` matche `^[0-9]{4,5}$` (RG-3.06).
**RIBs** : `POST /api/providers/{id}/ribs`, `PATCH /api/provider_ribs/{id}`, `DELETE /api/provider_ribs/{id}`.
- **Security** : `is_granted('technique.providers.accounting.manage')`
- **RG-3.08** : si `paymentType.code = LCR`, suppression du dernier RIB → 409.
### 4.6 `GET /api/providers/export.xlsx` — Export
- **Security** : `is_granted('technique.providers.view')`
- **Comportement** : XLSX des prestataires **affichés** (mêmes filtres que la liste, non archivés par défaut).
- Colonnes : Nom prestataire, Contact principal (Nom + Prénom), Téléphone principal, Téléphone secondaire, Email, Catégories (CSV), Sites (CSV), SIREN (omis si pas `accounting.view`), Date de création. _(Colonnes contact alimentées depuis le contact principal `provider_contact` de plus petit `position`.)_
- **Implémentation** : controller custom `ProviderExportController` avec `#[Route(priority: 1)]` (règle ABSOLUE — conflit API Platform `{id}`). Lib : PhpSpreadsheet (déjà présente).
- **Réponse 200** : `Content-Disposition: attachment; filename="repertoire-prestataires-{YYYYMMDD}.xlsx"`
### 4.7 Référentiels (réutilisés M1/M2 — évolution security)
`GET /api/tva_modes`, `/api/payment_delays`, `/api/payment_types`, `/api/banks` existent. **Évolution M3** : élargir leur `security` pour autoriser aussi les rôles prestataires, p.ex. `... or is_granted('technique.providers.view')`. Tri `position ASC` puis `label ASC`. Pas d'écriture exposée (HP).
`GET /api/categories?typeCode=PRESTATAIRE` alimente les multi-selects Catégorie (prestataire + adresse). ✅ **Le filtre `?typeCode=` existe** (créé au M2) — il suffit de **seeder le type `PRESTATAIRE`** + ses catégories. **À vérifier** que le filtre fonctionne pour ce nouveau type (DoD).
## 5. Autorisation
### 5.1 Déclaration des permissions
Créer `TechniqueModule::permissions()` :
```php
['code' => 'technique.providers.view', 'label' => 'Voir les prestataires'],
['code' => 'technique.providers.manage', 'label' => 'Créer / modifier les prestataires (hors onglet Comptabilité)'],
['code' => 'technique.providers.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un prestataire'],
['code' => 'technique.providers.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un prestataire'],
['code' => 'technique.providers.archive', 'label' => 'Archiver / restaurer un prestataire'],
```
Synchronisation : `php bin/console app:sync-permissions`.
### 5.2 Mapping rôles MALIO ↔ permissions
Cf. § 2.9 (matrice détaillée — identique à la matrice M2 transposée sur `technique.providers`) + § 2.13 (cloisonnement par site via `sites.bypass_scope`). **Attribution `sites.bypass_scope` par défaut** : Admin (auto) + Bureau + Compta + Commerciale ; **Usine non** (cloisonnée à son site).
### 5.3 Synchronisation RBAC (3 sources OBLIGATOIRES — règle ABSOLUE Starseed n°8)
1. **`config/sidebar.php`** — **nouvelle section « Technique »** + item :
```php
[
'key' => 'technique',
'label' => 'sidebar.technique.section',
'items' => [
[
'label' => 'sidebar.technique.providers',
'to' => '/providers',
'icon' => 'mdi:account-wrench-outline',
'module' => 'technique',
'permission' => 'technique.providers.view',
],
],
],
```
2. **`frontend/tests/e2e/_fixtures/personas.ts`** — étendre les personas existants :
- Admin : `view` + `manage` + `accounting.view` + `accounting.manage` + `archive`
- Bureau : `view` + `manage`
- Compta : `view` + `accounting.view` + `accounting.manage`
- Commerciale : `view` + `manage` + `sites.bypass_scope`
- Bureau / Compta : + `sites.bypass_scope` (voient tous les sites)
- Usine : `view` **sans** `sites.bypass_scope` → cloisonné à son site (§ 2.13). Persona avec un `currentSite` positionné pour tester le filtre.
3. **`src/Module/Core/Infrastructure/Console/SeedE2ECommand.php`** — miroir back des mêmes personas.
> ⚠ Les 3 sources doivent être touchées dans le **même commit** (sinon drift / test cassé).
### 5.4 Vérification front
- `usePermissions()` filtre l'item sidebar et masque l'onglet Comptabilité (`technique.providers.accounting.view`).
- Bouton « Archiver » visible si `technique.providers.archive` (Admin seul).
## 6. Audit & dates
- `Provider`, `ProviderContact`, `ProviderAddress`, `ProviderRib` : `#[Auditable]`, tous champs audités (y compris `iban`/`bic`).
- Audit M2M automatique sur `provider.categories` et `provider.sites`.
- Timestampable + Blamable : pattern Shared standard (§ 2.8).
- Libellés i18n `audit.entity.technique_*` (§ 2.7).
## 7. Règles de gestion (RG)
> Les RG-3.03 → RG-3.08 reprennent le docx source. RG-3.01 / RG-3.02 sont **supprimées** (refonte-contact). Les RG-3.09 → RG-3.16 sont des **précisions back** (miroir M2) explicitement marquées.
### Formulaire principal
- ~~**RG-3.01**~~ _(SUPPRIMÉE — refonte-contact, 11/06)_ : le contact principal inline (Nom OU Prénom) est retiré du formulaire principal. Garantie « au moins un contact nommé » portée par **RG-3.04** + **RG-3.12** sur `ProviderContact`.
- ~~**RG-3.02**~~ _(SUPPRIMÉE du formulaire principal — refonte-contact)_ : plus de téléphones inline sur le formulaire principal. Le « maximum 2 téléphones » reste applicable aux blocs `ProviderContact` (`phonePrimary` + `phoneSecondary`).
- **RG-3.03** : Au moins un des 3 sites (86 / 17 / 82) doit être sélectionné sur le **formulaire principal** pour valider la création. `Assert\Count(min: 1)` sur `provider.sites` (M2M `provider_site`). **Spécificité M3** (le fournisseur n'avait pas de site sur le formulaire principal). **Écriture cloisonnée (§ 2.13)** : un user sans `sites.bypass_scope` ne peut choisir que des sites de ses `user_site` (sinon 422).
### Onglet Contact
- **RG-3.04** : Un bloc Contact est valide dès qu'**au moins 1 champ** est rempli (Nom, Prénom, Fonction, Téléphone ou Email). CHECK BDD `chk_provider_contact_name` (garde-fou minimal). Côté UI, le bouton « + Nouveau contact » est bloqué tant que le bloc en cours n'a aucun champ rempli.
### Onglet Adresse
- **RG-3.05** : Au moins un des 3 sites (86 / 17 / 82) doit être sélectionné sur **chaque adresse**. `Assert\Count(min: 1)` sur `providerAddress.sites` (M2M `provider_address_site`).
- **RG-3.06** : `city` préremplie depuis `postalCode` via l'API **BAN** (api-adresse.data.gouv.fr), appel **direct front** via `useAddressAutocomplete()` (réutilisé M1/M2). Si plusieurs villes correspondent → choix dans le select. Cas dégradé (API down) : Ville en texte libre + toast. Validation serveur : `postalCode` matche `^[0-9]{4,5}$` ; pas de contrôle strict de cohérence CP/Ville.
### Onglet Comptabilité
- **RG-3.07** : Le champ `bank` est visible et obligatoire **uniquement** si `paymentType.code = 'VIREMENT'` (options SG / CIC / CA). Validation server-side dans le `ProviderProcessor` : `payment_type = VIREMENT` et `bank IS NULL` → 422.
- **RG-3.08** : Les champs RIB (`label`, `bic`, `iban`) sont obligatoires si `paymentType.code = 'LCR'` :
- `paymentType = LCR` ET `provider.ribs.count() = 0` → 422 « Au moins un RIB est obligatoire pour le type LCR ».
- DELETE du dernier RIB d'un prestataire en LCR → 409.
- Autres types : RIBs optionnels (0..n).
### Précisions back (miroir M2)
- **RG-3.09** _(précision back)_ : les `Category` posées sur `provider.categories` ET sur `provider_address.categories` doivent être de **type `PRESTATAIRE`**. Toute catégorie d'un autre type → **422** (`categories: "Type de catégorie non autorisé (PRESTATAIRE attendu)."`). Front : multi-selects alimentés par `GET /api/categories?typeCode=PRESTATAIRE`.
- **RG-3.10** _(précision back)_ : `companyName` unique (case-insensitive) parmi les prestataires non archivés ET non soft-deletés (index partiel `uq_provider_company_name_active`). Doublon → 409 « Un prestataire nommé "{companyName}" existe déjà. » SIREN et email **non** uniques (§ 2.6).
- **RG-3.11** _(normalisation serveur)_ : `companyName` **UPPERCASE** ; `firstName`/`lastName` (sur `ProviderContact`) **Capitalize** ; téléphones **chiffres uniquement** ; `email` **lowercase**. Formatage `XX XX XX XX XX` à l'affichage front.
- **RG-3.12** _(front-driven)_ : au moins 1 bloc Contact valide pour finaliser l'onglet (cf. RG-3.04). Pas de test back.
- **RG-3.13** _(archivage)_ : PATCH `{ "isArchived": true }` exige `technique.providers.archive` (**Admin seul**). Pose `isArchived = true` + `archivedAt = now()`. Aucun autre champ dans la même requête.
- **RG-3.14** _(restauration)_ : PATCH `{ "isArchived": false }` exige la même permission. Pose `isArchived = false` + `archivedAt = null`. Conflit d'unicité (un autre prestataire actif a pris le nom) → 409.
- **RG-3.15** _(PATCH mix de groupes, mode strict)_ : un PATCH mélangeant plusieurs groupes alors que l'user n'a pas toutes les permissions → **403 sur tout le payload** (pas de filtrage silencieux). Le front ne doit jamais envoyer de champ hors-permission.
- **RG-3.16** _(liste / tri)_ : `GET /api/providers` exclut par défaut archivés (`is_archived = TRUE`) + soft-deletés (`deleted_at IS NOT NULL`). `?includeArchived=true` inclut les archivés (pas les soft-deletés). Tri par défaut `companyName ASC`.
- **RG-3.17** _(cloisonnement par site — § 2.13)_ : un user **sans** `sites.bypass_scope` ne voit (liste + détail) que les prestataires dont `provider.sites` contient son `currentSite`. Liste : filtrée avant pagination (`totalItems` = périmètre user). Détail hors périmètre → **404**. Users `bypass_scope` (Admin auto) → tous sites. Cloisonnement **piloté par l'utilisateur, pas par le rôle**.
## 8. Tests à automatiser
### 8.1 Cas à couvrir (back — PHPUnit)
- [ ] **RG-3.03** : POST prestataire sans site → 422 ; avec ≥ 1 site → 201
- [ ] **RG-3.04** : POST contact totalement vide → 422 (CHECK) ; 1 champ rempli → 200
- [ ] **RG-3.05** : POST adresse sans aucun site → 422
- [ ] **RG-3.06** : POST adresse `postalCode` invalide (3 chiffres) → 422 ; CP/ville incohérents → 200 (pas de contrôle strict)
- [ ] **RG-3.07** : POST Comptabilité `paymentType=VIREMENT` sans `bank` → 422 ; avec `bank` → 200
- [ ] **RG-3.08** : POST `paymentType=LCR` sans RIB → 422 ; DELETE du dernier RIB en LCR → 409
- [ ] **RG-3.09** : POST `categories` avec une `Category` de type ≠ PRESTATAIRE → 422 (sur provider ET sur provider_address)
- [ ] **RG-3.10** : POST `companyName` déjà pris → 409 ; même nom après archivage de l'ancien → 201 ; SIREN/email dupliqués → 201
- [ ] **RG-3.11** : POST `companyName="maintenance pro"` → persiste `"MAINTENANCE PRO"` ; normalisation `firstName`/`phonePrimary`/`email` testée via un bloc `ProviderContact`
- [ ] **RG-3.13/14** : PATCH isArchived=true par Bureau (sans `archive`) → 403 ; par Admin → 200 + archivedAt rempli ; restauration en conflit de nom → 409
- [ ] **RG-3.15** : Bureau PATCH `{companyName, siren}` → 403 sur tout le payload (strict)
- [ ] **RG-3.16** : GET liste sans flag → exclut archivés ; `?includeArchived=true` → inclut ; tri `companyName ASC`
- [ ] **RBAC** : Bureau / Commerciale / Compta / Usine sur chaque permission (matrice § 2.9) — 200/403 selon le verbe
- [ ] **🔴 Cloisonnement par site (RG-3.17 / § 2.13)** : user **sans** `bypass_scope`, `currentSite = 86` → la liste ne contient QUE les prestataires rattachés au site 86 (assertion sur `member` + `totalItems`) ; GET détail d'un prestataire site 17 → **404** ; user `bypass_scope` (admin) → voit tous les sites ; **écriture cloisonnée** : POST/PATCH par un user non-bypass avec un site hors de ses `user_site` (formulaire principal OU adresse) → 422 ; avec uniquement ses propres sites → 201/200
- [ ] **Compta** : GET prestataire retourne les champs accounting ; PATCH accounting → 200 ; PATCH contacts/adresses → 403 ; POST création → 403
- [ ] **Commerciale** : GET prestataire **sans** les champs accounting ; onglet Comptabilité masqué
- [ ] **🔴 Gating RIB (bug #4 M1)** : GET détail en tant que Commerciale → la clé `ribs` est **ABSENTE** (assertion sur le corps JSON)
- [ ] **🔴 Sérialisation booléen (bug #3 M1)** : GET détail expose bien la clé `isArchived` dans le JSON réel
- [ ] **Embed relations (bugs #1/#2 M1)** : GET **liste ET détail** → `categories[].code` + `.name` présents ; `sites[]` (relation directe) exposent `name` + `postalCode` (objet Site entier, PAS un IRI nu) ; `addresses[].sites[]` au détail
- [ ] **Filtre typeCode** : `GET /api/categories?typeCode=PRESTATAIRE` ne renvoie QUE les catégories de type PRESTATAIRE
- [ ] **Anti N+1 liste (§ 2.12)** : sur `GET /api/providers` avec N prestataires, nombre de requêtes SQL constant
- [ ] **Audit** : POST + PATCH + archive → audit_log `entity_type='Provider'`, `changes` correct ; iban/bic présents dans le diff ; M2M `sites`/`categories` tracés
- [ ] **Pagination** (règle n°13) : enveloppe Hydra (`totalItems` / `view`) ; `?pagination=false` renvoie tout
- [ ] **Migration** : `make db-reset` → schéma OK ; namespace racine ; CategoryType PRESTATAIRE présent APRÈS db-reset (fixture idempotente) ; index partiel `uq_provider_company_name_active` présent ; **toutes les colonnes ont un `COMMENT ON COLUMN`** (`ColumnsHaveSqlCommentTest` vert)
- [ ] **i18n audit** : `audit.entity.technique_provider`… présents (`AuditableEntitiesHaveI18nLabelTest` vert)
### 8.2 Cas à couvrir (front — Vitest)
- [ ] `usePaginatedList({url:'/providers'})` : exclusion archivés par défaut, envelope Hydra
- [ ] `useProviderForm()` : workflow par onglet (validation incrémentale, PATCH partiel) — **sans onglet Information**
- [ ] `useAddressAutocomplete()` : réutilisation M1/M2 (nominal + dégradé) — pas de nouveau test si déjà couvert
- [ ] Sélecteur de site formulaire principal (RG-3.03) : ≥ 1 requis
- [ ] `` : `` + « + Ajouter » → `/providers/new`
- [ ] Permissions : Compta accède à `/providers/{id}` mais onglet Comptabilité éditable seul ; Commerciale ne voit pas l'onglet Comptabilité
- [ ] `useFormErrors` : mapping 422 inline par champ (formulaire principal + blocs)
### 8.3 Tests E2E
**Non prévus au M3** (règle ABSOLUE n°7). Extension des personas existants pour ajouter les permissions `technique.providers.*` — cf. § 5.3.
### 8.4 Seed & fixtures démo (RETEX M1 §7 — prévu dès la spec)
`ProviderFixtures` idempotent couvrant tous les cas des RG :
- Catégories de type PRESTATAIRE seedées (au moins « Maintenance industrielle », « Nettoyage », « Transport »).
- ≥ 1 prestataire **complet** (≥ 1 site sur le formulaire principal, ≥ 1 contact, ≥ 1 adresse multi-sites, comptabilité + RIB).
- 1 prestataire **en LCR avec RIB** (RG-3.08) et 1 **en VIREMENT avec banque** (RG-3.07).
- 1 prestataire **archivé** (vérifier exclusion liste + restauration).
- Réutiliser les comptes de rôles démo (`bureau`, `compta`, `commerciale`, `usine`, `admin`).
> Idempotence obligatoire (le purger Doctrine vide `category`/`category_type` au `db-reset`). Le `CategoryType PRESTATAIRE` est seedé **en migration ET en fixture**.
### 8.5 Checklist RETEX (à cocher avant « spec prête »)
- [x] 3 maillons de sérialisation documentés pour chaque champ liste + détail (§ 4.0)
- [x] Décision embed vs GetCollection explicite et câblée (embed détail + sous-ressources write — § 3.3 / § 3.4 / § 4.5), **pas de POST-only**
- [ ] **Réponses JSON RÉELLES** à capturer (§ 4.0.bis) — gabarit posé, capture à faire au 1er ticket back (DoD avant front)
- [x] Matrice RBAC rôle × onglet + mode strict PATCH (§ 2.9 / RG-3.15)
- [x] Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit + i18n, routes à plat : rappelés
- [x] Réutilisations M1/M2 identifiées (référentiels compta partagés, taxonomie code/type, filtre `?typeCode=`, `usePaginatedList`, blocs, archive, normalisation, `useAddressAutocomplete`)
- [x] Seed/fixtures démo planifiés (§ 8.4)
- [x] **Décisions tranchées (Matthieu, 11/06)** : module `Technique` (§ 2.1) ✅ ; référentiels comptables « comme supplier » (ORM partagée) ✅ ; cloisonnement par site piloté user via `sites.bypass_scope` (§ 2.13 / RG-3.17) ✅ ; unicité nom seul (§ 2.6) ✅
## 9. Hors-périmètre (HP)
- **HP-M4-2** : **Remontée des référentiels comptables dans `Shared`** (ou module neutre) si isolation stricte souhaitée (cf. § 2.1). _NB : décision M3 = consommation ORM partagée, comme `Supplier` (validée Matthieu, 11/06)._
- _**(ex-HP-M4-1 — DÉSORMAIS DANS LE PÉRIMÈTRE M3)**_ : le **cloisonnement par site** (visibilité prestataires selon le site de l'utilisateur) est implémenté au M3 — cf. § 2.13 + RG-3.17. Le « bypass multi-sites » passe par `sites.bypass_scope`.
- **HP-M4-3** : **DELETE / soft delete d'un prestataire** (colonne `deleted_at` préparée, non exposée au M3).
- **HP-M4-4** : **CRUD admin des référentiels comptables** (`TvaMode` / `PaymentDelay` / `PaymentType` / `Bank`) — partagés, seed seulement.
- **HP-M4-5** : **CRUD admin de `CategoryType`** (le M3 seed seulement le type PRESTATAIRE).
- **HP-M4-6** : **Onglet Rapports** (front placeholder « À venir » ; aucun modèle ni API back).
- **HP-M4-7** : **Onglet Échanges** (placeholder « À venir »).
- **HP-M4-8** : **Validation IBAN/BIC stricte** (au M3, `Assert\Iban` / `Assert\Bic` standard sur `ProviderRib`).
- **HP-M4-9** : **Validation SIREN stricte** (Luhn) — au M3, `Assert\Length(9)` + `Assert\Regex('/^\d{9}$/')`.
- **HP-M4-10** : **Référencement entrant** (modules futurs ajoutant une FK `provider_id` : interventions, maintenance, etc.).
- **HP-M4-11** : **Export CSV** (XLSX uniquement au M3).
- **HP-M4-12** : **Liaison Prestataire ↔ Fournisseur / Client** (un même tiers multi-rôles). Au M3, entités strictement séparées.
## 10. Liens & dépendances
### Liens
- Spec front : [`./spec-front.md`](./spec-front.md)
- Spec M2 fournisseurs (pattern de référence direct) : [`../M2-suppliers/spec-back.md`](../M2-suppliers/spec-back.md)
- Spec M1 clients : [`../M1-clients/spec-back.md`](../M1-clients/spec-back.md)
- RETEX sérialisation : [`../_RETEX-M1-pour-M2.md`](../_RETEX-M1-pour-M2.md)
- Doc audit-log : [`../../audit-log.md`](../../audit-log.md)
- Site-aware (périmètre Usine) : [`../../modules/site-aware.md`](../../modules/site-aware.md)
- BAN api : `https://adresse.data.gouv.fr/api-doc/adresse`
- Trace fonctionnelle : `M3-reportoire-prestataires.docx` (V0.2) / `M3-reportoire-prestataires-V01.pdf` (V0.1, obsolète)
### Dépendances amont (déjà en place dans Starseed)
- Module `Commercial` : référentiels comptables `TvaMode` / `PaymentDelay` / `PaymentType` / `Bank` (**partagés**, relation ORM)
- Module `Catalog` (M0) : `Category` + `CategoryType` + **filtre `?typeCode=`** (créé au M2) (+ seed type PRESTATAIRE au M3)
- Module `Sites` : `Site` (3 sites 86/17/82) — M2M `provider_site` + `provider_address_site`
- Module `Core` : `User`, `Role`, `Permission`, `Audit`, JWT
- `Shared` : `TimestampableBlamableTrait` + `Subscriber`
- API Platform 4 + Doctrine ORM + PostgreSQL 16 + PhpSpreadsheet (export)
### Specs futures qui dépendent du M3
- **M-Interventions / Maintenance** : FK `provider_id`.
---
## 📦 Tickets Lesstime (à découper)
**TaskGroup Lesstime** : à créer — `M3 — Répertoire prestataires` (projet `ERP / Starseed`, projectId=6).
Ordre indicatif (back avant front, migration en tête) :
0. **Module `Technique` + Taxonomie PRESTATAIRE** — créer `TechniqueModule` (ID/LABEL/REQUIRED/permissions) + activer dans `config/modules.php` + layer front `modules/technique/` ; seed `CategoryType PRESTATAIRE` (migration `ON CONFLICT` + fixture idempotente) + catégories prestataires ; **vérifier** que le filtre `?typeCode=PRESTATAIRE` fonctionne. Prérequis du multi-select Catégorie.
1. **Migration BDD M3** (tables provider + provider_site + sous-collections + M2M + index partiel + COMMENT ON COLUMN)
2. **Entités + Repositories** (Provider, ProviderContact, ProviderAddress, ProviderRib) + **hydratation liste** (categories, sites — § 2.12)
3. **Provider + Processor** (ProviderProvider paginé, ProviderProcessor — normalisation, archivage, accounting conditionnel, mode strict, gating) + **filtre de cloisonnement par site** (§ 2.13 / RG-3.17 : `ProviderSiteScopeExtension` réutilisant `CurrentSiteProvider` + `sites.bypass_scope` ; liste filtrée, détail 404 hors périmètre)
4. **Sous-ressources** (ProviderContactProcessor, ProviderAddressProcessor, ProviderRibProcessor)
5. **Validators** (contrôle catégorie type PRESTATAIRE, RG-3.07/3.08, ≥1 site formulaire principal RG-3.03)
6. **Export XLSX** (ProviderExportController, priority:1)
7. **RBAC** : `TechniqueModule::permissions()` + sync 3 sources + tests personas
8. **Tests PHPUnit** : matrice RG-3.03 → RG-3.16 (§ 8.1) + capture JSON réel (§ 4.0.bis)
9. **Front : page Répertoire** (`/providers`) + `usePaginatedList`
10. **Front : page Création** (`/providers/new`) + `useProviderForm` (sans onglet Information)
11. **Front : page Consultation** (`/providers/{id}`) + onglets placeholder « À venir » (Rapports / Échanges)
12. **Front : page Modification** (`/providers/{id}/edit`)
13. **i18n + Sidebar** (section `sidebar.technique.section` + `sidebar.technique.providers` + permission, traductions, libellés audit)
### Actions manuelles dans Lesstime (Matthieu)
1. Créer le TaskGroup `M3 — Répertoire prestataires` (projet ERP / Starseed, projectId=6).
2. Créer les ~14 tickets ci-dessus (ticket 0 module+taxonomie inclus) avec dépendances séquentielles.
3. Mettre à jour le frontmatter (`lesstime_taskgroup_id`) avec l'id réel.
### ✅ Décisions tranchées (Matthieu, 11/06/2026)
1. **Module `Technique`** (§ 2.1) — nouveau module back + section sidebar « Technique ». ✅
2. **Référentiels comptables** — « comme supplier » : consommation ORM partagée (pas de remontée dans `Shared`). ✅
3. **Cloisonnement par site** (§ 2.13 / RG-3.17) — visibilité pilotée par l'**utilisateur** (son `currentSite`), automatique côté back ; bypass multi-sites via `sites.bypass_scope` (Admin auto + Bureau/Compta/Commerciale ; **Usine cloisonnée**). Indépendant du rôle. ✅
4. **Unicité = nom seul** (§ 2.6). ✅
5. **Écriture cloisonnée** (§ 2.13 / RG-3.03 / RG-3.05) — un user non-bypass ne peut attacher que **les sites dont il dispose** (`user_site`), formulaire principal ET adresses ; site hors périmètre → 422. ✅
### ⚠️ Point de raffinement à confirmer (non bloquant)
- **Attribution `sites.bypass_scope`** : confirmer la liste des profils « voient tous les sites » (défaut : Admin + Bureau + Compta + Commerciale ; Usine non).