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