--- # === IDENTITÉ === module: M2 nom: "Répertoire fournisseurs" ecran: repertoire-fournisseurs owner_spec: Matthieu backup_spec: Tristan version: V0.2 # Historique : V0.2 (2026-06-03) — Refonte contact : suppression du contact inline du Supplier # (firstName/lastName/phonePrimary/phoneSecondary/email retirés). Contacts uniquement dans # SupplierContact. Aligné sur M1. Cf. docs/specs/M1-clients/refonte-contact/README.md date_redaction: 2026-06-02 # === LIENS === spec_front: ./spec-front.md maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-36987&p=f&m=dev" # === LIEN LESSTIME === lesstime_taskgroup_id: 26 # M2 — Répertoire fournisseurs (projet STARSEED #6) lesstime_project_id: 6 statut_global: a_dev # === DÉPENDANCES AMONT === depend_de: - M1-clients # référentiels comptables (TvaMode/PaymentDelay/PaymentType/Bank) + pattern Client* réutilisé - M0-categories # Category + CategoryType (étendu par seed M2 : type FOURNISSEUR) - 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 2 : Répertoire fournisseurs ## 1. Contexte Cette spec **complète et précise** la [spec front V0.1](./spec-front.md) (`M2-reportoire-fournisseurs.docx` du 01/06/2026, historique V0 22/05 → V0.1 01/06) 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** : extension du module `Commercial` existant (`src/Module/Commercial/`), aux côtés des Clients (M1). Le M2 est la **deuxième sous-section métier Tiers** du Commercial (Fournisseurs), construite sur le **pattern jumeau de `Client`** déjà éprouvé au M1 (`Supplier` / `SupplierContact` / `SupplierAddress` / `SupplierRib`). **Dépendances déjà en place sur `develop`** (héritées du M1) : - `Commercial` → `Client*` + 4 référentiels comptables `TvaMode` / `PaymentDelay` / `PaymentType` / `Bank` (entités lecture seule, déjà seedées — **partagées sans duplication** par le M2). - `Catalog` (M0) → `Category` + `CategoryType` (le M2 ajoute le type `FOURNISSEUR`). - `Sites` → 3 sites Châtellerault (86) / Saint-Jean (17) / Pommevic (82). - `Shared` → `TimestampableBlamableTrait` + `Subscriber` (ERP-52). - `Core` → User, Role, Permission, Audit, JWT. ## 2. Décisions d'archi ### 2.1 Module — Extension de `Commercial`, entités jumelles de `Client` Le fournisseur M2 vit sous `src/Module/Commercial/` (déjà existant). Pas de nouveau module `Suppliers`. Rationale identique au M1 : - Cohérence MALIO : `Commercial` = couche **Tiers** (Clients + Fournisseurs + Prestataires). - Le M1 a déjà posé le pattern `Client` / `ClientContact` / `ClientAddress` / `ClientRib` + Provider/Processor + normalisation + archivage. Le M2 le **réplique à l'identique** sous `Supplier*` (décision : tables dédiées, pas de table polymorphe partagée — clients et fournisseurs divergeront fonctionnellement, l'isolation prime). - La sidebar porte déjà l'item `suppliers` → `/suppliers` (sans permission). Le M2 lui attache `commercial.suppliers.view`. Le `CommercialModule.php` actuel expose déjà les 5 permissions `commercial.clients.*`. Le M2 **ajoute 5 permissions `commercial.suppliers.*`** (cf. § 5.1). ### 2.2 IDs entier auto-increment Postgres natif Cohérent avec M0/M1 et l'ensemble Starseed. Pas d'UUID, pas de ULID. ### 2.3 Référentiels comptables — réutilisation M1 (zéro duplication) Les 4 tables `tva_mode` / `payment_delay` / `payment_type` / `bank` (+ leurs entités lecture seule et leurs seeds) sont **celles du M1**. Le M2 ne crée **aucune** nouvelle table de référentiel comptable : `supplier.tva_mode_id`, `supplier.payment_delay_id`, `supplier.payment_type_id`, `supplier.bank_id` pointent vers les mêmes tables. Conséquence sur les endpoints : `GET /api/tva_modes`, `/api/payment_delays`, `/api/payment_types`, `/api/banks` existent déjà (M1). La seule évolution : leur `security` doit autoriser **aussi** les rôles fournisseurs (cf. § 4.7). > **Confirmé sur le JSON réel (02/06)** : les formes sont conformes (`id`/`code`/`label`/`position`). Les codes pivots `VIREMENT` et `LCR` (RG-2.07/2.08) existent bien dans `payment_types`. **Nuance** : `tva_modes` ne contient que des modes « ventes » (`FRANCE_VENTES`/`EXPORT_VENTES`/`INTRACOM_VENTES`). La spec fonctionnelle (docx) dit seulement « Mode de TVA — liste depuis une table », **sans** distinguer achats/ventes → au M2 on **réutilise les modes existants** (pas de seed « achats »). Point à confirmer avec le métier : si un mode « achats » est requis pour les fournisseurs, l'ajouter via un seed (référentiel partagé). Tracé en HP-M3-2. ### 2.4 Catégories — nouveau `CategoryType` `FOURNISSEUR` Le multi-select « Catégorie » du fournisseur référence des `Category` rattachées à un **nouveau `CategoryType` de code `FOURNISSEUR`** (label « Fournisseur »), seedé par le M2. Décision Matthieu (02/06) : on assume des **types distincts** (`CLIENT` / `FOURNISSEUR`, et `PRESTA` à venir) — chacun avec sa taxonomie. Rationale : les catégories clients (Agro-alimentaire…) ne sont pas valides pour un fournisseur (Négociant, Coopérative…). > ⚠️ **CONSTAT JSON RÉEL (02/06) — brique manquante à construire** : la refonte **ERP-78 a unifié sur un type unique `CLIENT`** et **le filtre `?typeCode=` est INOPÉRANT** (`GET /api/categories?typeCode=FOURNISSEUR` renvoie les 11 catégories CLIENT, filtre ignoré ; `GET /api/category_types` → un seul type `CLIENT`). Donc le M2 doit : > 1. **recréer** un `CategoryType` `FOURNISSEUR` (seed migration + fixture idempotente) ; > 2. **implémenter** un vrai filtre `?typeCode=` sur `/api/categories` (module Catalog) — il n'existe pas en prod ; > 3. seeder les catégories fournisseurs (Négociant, Coopérative…) sous ce type. > → matérialisé en **ticket back dédié** (cf. § Tickets). Réintroduit volontairement le multi-type qu'ERP-78 avait retiré. > ⚠️ **Forme réelle de `Category`** : expose `code` **et `name`** (PAS `label`) sous `category:read`, plus `categoryType{ id, code, label }`. Le **libellé affiché côté front = `category.name`**. Le M2M `supplier_category` / `supplier_address_category` ne contraint que des `Category` de type `FOURNISSEUR` (RG-2.10). > **Pas d'auto-référence distributeur/courtier au M2** : contrairement au `Client`, le `Supplier` n'a pas de relation `distributor`/`broker`. On ne réimporte aucune classe d'un autre module : on consomme le contrat partagé / les read-groups de `Category`. ### 2.5 Archive vs soft delete — deux mécanismes distincts (identique M1) | Mécanisme | Colonne | Visibilité défaut | Restauration | Utilisateur | |---|---|---|---|---| | **Archive** (fonctionnel) | `is_archived` (bool, default false) + `archived_at` | masqué | Oui (toggle UI) | **Admin seul** via `commercial.suppliers.archive` | | **Soft delete** (technique) | `deleted_at` (timestamptz nullable) | masqué | HP M3+ | Aucun rôle au M2 (HP) | Conséquences (miroir M1) : - `DELETE /api/suppliers/{id}` **non exposé** au M2 (404 si appelé). - `GET /api/suppliers?includeArchived=true` permet de voir les archivés (permission `commercial.suppliers.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). > **Différence RBAC notable avec le docx** : le tableau « Rôles & permissions » du docx ne donne l'Archive qu'à **Admin** (Bureau/Compta/Commerciale = « Non »). On s'aligne strictement : `commercial.suppliers.archive` = Admin uniquement. ### 2.6 Unicité partielle Postgres — nom de société > **Décision validée (Matthieu, 02/06/2026 — alignée sur la décision Q4 du M1)** : l'unicité métier porte **uniquement sur le nom de fournisseur** (`company_name`). Le SIREN et l'email principal ne sont **pas** uniques (un même SIREN peut couvrir plusieurs établissements ; un email peut servir plusieurs fiches). Index unique partiel (`WHERE is_archived = FALSE AND deleted_at IS NULL`) sur `LOWER(company_name)`. Doublon → `409 Conflict` géré par le `SupplierProcessor`. ### 2.7 Audit & traces temporelles Pattern Starseed standard, miroir M1 : - `#[Auditable]` sur `Supplier`, `SupplierContact`, `SupplierAddress`, `SupplierRib`. - **Tous les champs auditables** (pas d'`#[AuditIgnore]`) — y compris `SupplierRib.iban` et `SupplierRib.bic` (audit admin-only côté Starseed → traçabilité comptable, décision M1 reportée). - Audit M2M automatique sur `supplier.categories` (`{categories: {added:[...], removed:[...]}}`). ### 2.8 Timestampable + Blamable Toutes les entités métier nouvelles implémentent `TimestampableInterface` + `BlamableInterface` et utilisent `TimestampableBlamableTrait` : `Supplier`, `SupplierContact`, `SupplierAddress`, `SupplierRib`. Les référentiels partagés (`TvaMode`...) restent whitelistés dans `EntitiesAreTimestampableBlamableTest::EXCLUDED` (déjà fait au M1). ### 2.9 Permissions RBAC — granularité (5 permissions, identique M1) | Permission | Admin | Bureau | Compta | Commerciale | Usine | |---|---|---|---|---|---| | `commercial.suppliers.view` | ✅ | ✅ | ✅ | ✅ (sauf compta) | ❌ | | `commercial.suppliers.manage` | ✅ | ✅ | ❌ | ✅ | ❌ | | `commercial.suppliers.accounting.view` | ✅ | ❌ | ✅ | ❌ | ❌ | | `commercial.suppliers.accounting.manage` | ✅ | ❌ | ✅ | ❌ | ❌ | | `commercial.suppliers.archive` | ✅ | ❌ | ❌ | ❌ | ❌ | Notes (miroir M1) : - **Compta édite uniquement l'onglet Comptabilité** (`accounting.manage`) d'un fournisseur existant. Compta ne peut pas **créer** un fournisseur (pas de `manage` global). - **Commerciale** a `view` + `manage` mais **pas** `accounting.view` → l'onglet Comptabilité est masqué (front) et filtré (back, 2 niveaux : `security` API Platform + `SupplierProvider`). - **Bureau** : `view` + `manage` (tout sauf Comptabilité). - **Usine** : aucune permission → item sidebar invisible, accès direct 403. ### 2.10 Validation incrémentale par onglet (workflow front-driven, identique M1) Le `Supplier` est créé en BDD **dès validation du formulaire principal** via `POST /api/suppliers`. Les onglets suivants déclenchent des **PATCH partiels** avec des groupes de sérialisation dédiés : - `supplier:write:main` — formulaire principal (POST + PATCH) - `supplier:write:information` — onglet Information - `supplier:write:contacts` — onglet Contact (sous-ressource `supplier_contact`) - `supplier:write:addresses` — onglet Adresse (sous-ressource `supplier_address`) - `supplier:write:accounting` — onglet Comptabilité (security séparée) - `supplier:write:archive` — toggle archive (security `commercial.suppliers.archive`) **Pas de state machine côté back** (pas de `status = draft|active`). Le fournisseur est actif dès POST réussi. La complétude des onglets est de la responsabilité du front. ### 2.11 Normalisation serveur des entrées texte (identique M1) Réutilisation du même pattern que `ClientFieldNormalizer`, dupliqué en `SupplierFieldNormalizer` (service interne appelé par les Processors avant validation) : ```php final class SupplierFieldNormalizer { 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 côté front. Le back stocke `0612345678` (chiffres seuls). ### 2.12 Liste : embed catégories + sites + fetch-joins (cohérence M1/ERP-62) Décision d'alignement (02/06/2026) : la **liste** `GET /api/suppliers` **embarque** les `categories[]` (avec `code`/`name`) et les `sites[]` (avec `name`/`postalCode` — pas de `code`), comme la liste Clients après ERP-62 — et **non** des champs dérivés aplatis. Conséquence performance : le `DoctrineSupplierRepository` **DOIT** poser des **fetch-joins** (`leftJoin`+`addSelect`) sur `categories` et `addresses.sites` dans la requête de liste pour éviter le N+1. Les `sites` de la liste sont agrégés/dédoublonnés via `Supplier::getSites()` (cf. § 3.3). Le contrat de sérialisation (groupes `category:read` / `site:read` dans le contexte) est posé **une seule fois** sur l'entité — source de vérité unique, le front ne le redéfinit pas. > Dépendance confirmée sur le JSON réel (#82 mergé) : `Category` expose `code`/`name` sous `category:read` ; `Site` expose `name`/`postalCode`/`city`/`color` sous `site:read` (**pas de `code`**). L'embed est pleinement matérialisé. ## 3. Modèle de données ### 3.1 Diagramme ``` +----------------------+ +--------------------------+ +--------------+ | supplier |--n:m-->| supplier_category |<--n:m--| category | | | +--------------------------+ | type=FOURNI. | | id (PK) | +--------------+ | company_name | | (contact inline | +--------------------------+ +--------------+ | retiré V1 — |--1:n-->| supplier_contact | | site | | firstName, | +--------------------------+ | (Sites) | | lastName, phones, | +--------------+ | email) | +--------------------------+ ^ | is_archived |--1:n-->| supplier_address |--n:m-------+ | archived_at | +--------------------------+ | deleted_at | | (address_type radio) | -- Information -- | +--n:m--+--> supplier_contact | description | | | competitors | +--------------------------+ +-----------------+ | founded_at |--1:n-->| supplier_rib | | tva_mode (M1) | | employees_count | +--------------------------+ | payment_* (M1) | | revenue_amount | label / bic / iban | bank (M1) | | director_name | +-----------------+ | profit_amount | | volume_forecast (NEW) | -- Comptabilité (sur supplier) -- +----------------------+ siren / account_number / tva_mode_id / n_tva / payment_delay_id / payment_type_id / bank_id (nullable) ``` **Particularités M2 (différences vs `client`)** : - **Pas** de `distributor_id` / `broker_id` (pas d'auto-référence), donc pas de contrainte CHECK distributor/broker. - **Pas** de `triage_service` sur l'entité principale — le « Prestataire de triage » est porté **par l'adresse** (`supplier_address.triage_provider`). - Ajout d'un champ Information **`volume_forecast`** (Volume prévisionnel — entier) absent du `client`. - `supplier_address` remplace les 3 booléens M1 (`is_prospect`/`is_delivery`/`is_billing`) par **un seul champ enum `address_type`** (radio Prospect / Départ / Rendu — mutuellement exclusifs par construction). Plus de `billing_email` (pas d'email facturation au M2). - `supplier_address` ajoute `bennes` (entier, nullable) et `triage_provider` (booléen). - 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** : la migration crée un schéma avec **FK cross-module** (`user`, `category`, `site`, et FK vers les référentiels comptables M1). Le namespace modulaire casserait l'ordre (`make db-reset`) car Doctrine Migrations 3.x trie par FQCN alphabétique. → exception racine de la règle ABSOLUE n°11. Le seed du `CategoryType FOURNISSEUR` se fait **en deux endroits** (migration `ON CONFLICT` pour la prod + fixture idempotente pour survivre au purger en dev/test — cf. M1 § 3.3). > **Rappel règle ABSOLUE n°12** : chaque colonne créée ci-dessous DOIT recevoir son `COMMENT ON COLUMN`. Les 4 colonnes Timestampable/Blamable passent par le helper `addStandardTimestampableBlamableComments($schema, '')`. Le SQL ci-dessous montre la structure ; les `COMMENT ON COLUMN` (un par colonne métier) sont à écrire dans la migration (exemples §3.2.bis). ```sql -- ===================================================================== -- Seed taxonomie : nouveau type FOURNISSEUR (référentiels comptables = M1, non recréés) -- ===================================================================== INSERT INTO category_type (code, label) VALUES ('FOURNISSEUR', 'Fournisseur') ON CONFLICT (code) DO NOTHING; -- ===================================================================== -- Table principale `supplier` -- ===================================================================== CREATE TABLE supplier ( id SERIAL PRIMARY KEY, -- Formulaire principal company_name VARCHAR(180) NOT NULL, -- Contact inline retiré (V1, refonte-contact) : first_name / last_name / phone_primary / -- phone_secondary / email vivent uniquement dans supplier_contact (onglet Contacts). -- Onglet Information (Commerciale obligatoire — RG-2.03 — null sinon) description TEXT, competitors VARCHAR(255), founded_at DATE, employees_count INT, revenue_amount NUMERIC(15,2), director_name VARCHAR(120), profit_amount NUMERIC(15,2), volume_forecast INT, -- NEW vs client -- 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é M2) is_archived BOOLEAN NOT NULL DEFAULT FALSE, archived_at TIMESTAMPTZ, -- Soft delete (préparé, non exposé au M2) deleted_at TIMESTAMPTZ, -- Timestampable + Blamable created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ 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_supplier_is_archived ON supplier(is_archived); CREATE INDEX idx_supplier_deleted_at ON supplier(deleted_at); CREATE INDEX idx_supplier_created_by ON supplier(created_by); CREATE INDEX idx_supplier_updated_by ON supplier(updated_by); -- Unicité métier (partielle : ignore archives + soft-delete) — nom de société uniquement (cf. § 2.6) CREATE UNIQUE INDEX uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL; -- ===================================================================== -- M2M supplier ↔ category (catégories de type FOURNISSEUR — RG-2.10) -- ===================================================================== CREATE TABLE supplier_category ( supplier_id INT NOT NULL REFERENCES supplier(id) ON DELETE CASCADE, category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT, PRIMARY KEY (supplier_id, category_id) ); CREATE INDEX idx_supplier_category_category ON supplier_category(category_id); -- ===================================================================== -- Sous-collection : Contacts (1:n) -- ===================================================================== CREATE TABLE supplier_contact ( id SERIAL PRIMARY KEY, supplier_id INT NOT NULL REFERENCES supplier(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 TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, created_by INT REFERENCES "user"(id) ON DELETE SET NULL, updated_by INT REFERENCES "user"(id) ON DELETE SET NULL, -- RG-2.04 : au moins Nom OU Prénom CONSTRAINT chk_supplier_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL) ); CREATE INDEX idx_supplier_contact_supplier ON supplier_contact(supplier_id); -- ===================================================================== -- Sous-collection : Adresses (1:n) -- ===================================================================== CREATE TABLE supplier_address ( id SERIAL PRIMARY KEY, supplier_id INT NOT NULL REFERENCES supplier(id) ON DELETE CASCADE, -- Radio Prospect / Départ / Rendu (mutuellement exclusifs — RG-2.09) address_type VARCHAR(20) NOT NULL, -- 'PROSPECT' | 'DEPART' | 'RENDU' 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), bennes INT, -- NEW (spécifique fournisseur) triage_provider BOOLEAN NOT NULL DEFAULT FALSE, -- NEW (Prestataire de triage) position INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, created_by INT REFERENCES "user"(id) ON DELETE SET NULL, updated_by INT REFERENCES "user"(id) ON DELETE SET NULL, -- RG-2.09 : valeur enum contrôlée CONSTRAINT chk_supplier_address_type CHECK (address_type IN ('PROSPECT', 'DEPART', 'RENDU')) ); CREATE INDEX idx_supplier_address_supplier ON supplier_address(supplier_id); -- M2M supplier_address ↔ site (RG-2.06 : ≥ 1 site) CREATE TABLE supplier_address_site ( supplier_address_id INT NOT NULL REFERENCES supplier_address(id) ON DELETE CASCADE, site_id INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT, PRIMARY KEY (supplier_address_id, site_id) ); -- M2M supplier_address ↔ supplier_contact CREATE TABLE supplier_address_contact ( supplier_address_id INT NOT NULL REFERENCES supplier_address(id) ON DELETE CASCADE, supplier_contact_id INT NOT NULL REFERENCES supplier_contact(id) ON DELETE CASCADE, PRIMARY KEY (supplier_address_id, supplier_contact_id) ); -- M2M supplier_address ↔ category (catégorie d'adresse, type FOURNISSEUR — RG-2.10) CREATE TABLE supplier_address_category ( supplier_address_id INT NOT NULL REFERENCES supplier_address(id) ON DELETE CASCADE, category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT, PRIMARY KEY (supplier_address_id, category_id) ); -- ===================================================================== -- Sous-collection : RIB (1:n) -- ===================================================================== CREATE TABLE supplier_rib ( id SERIAL PRIMARY KEY, supplier_id INT NOT NULL REFERENCES supplier(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 TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ 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_supplier_rib_supplier ON supplier_rib(supplier_id); ``` ### 3.2.bis Commentaires SQL obligatoires (échantillon) ```php $this->addSql("COMMENT ON TABLE supplier IS 'Répertoire fournisseurs (M2 Commercial) — entités archivables.'"); $this->addSql("COMMENT ON COLUMN supplier.company_name IS 'Raison sociale du fournisseur — stockée en MAJUSCULES. Unique parmi non-archivés/non-supprimés (RG-2.06).'"); $this->addSql("COMMENT ON COLUMN supplier.volume_forecast IS 'Volume prévisionnel (entier) — onglet Information. Obligatoire pour le rôle Commerciale (RG-2.03).'"); $this->addSql("COMMENT ON COLUMN supplier.payment_type_id IS 'Type de règlement — FK -> payment_type.id (référentiel partagé M1), ON DELETE RESTRICT. Pilote RG-2.07 (Banque) et RG-2.08 (RIB).'"); $this->addSql("COMMENT ON COLUMN supplier.bank_id IS 'Banque — FK -> bank.id (M1). Obligatoire ssi payment_type=VIREMENT (RG-2.07), null sinon.'"); $this->addSql("COMMENT ON COLUMN supplier_address.address_type IS 'Type d''adresse : PROSPECT | DEPART | RENDU (radio exclusif — RG-2.09).'"); $this->addSql("COMMENT ON COLUMN supplier_address.bennes IS 'Nombre de bennes sur le site fournisseur (entier nullable).'"); $this->addSql("COMMENT ON COLUMN supplier_address.triage_provider IS 'Le fournisseur est prestataire de triage sur cette adresse. Faux par défaut.'"); // + COMMENT ON COLUMN sur TOUTES les autres colonnes métier (cf. règle n°12) $this->addStandardTimestampableBlamableComments($schema, 'supplier'); $this->addStandardTimestampableBlamableComments($schema, 'supplier_contact'); $this->addStandardTimestampableBlamableComments($schema, 'supplier_address'); $this->addStandardTimestampableBlamableComments($schema, 'supplier_rib'); ``` ### 3.3 Entité `Supplier` — squelette ```php [ 'supplier:read', 'category:read', 'site:read', 'default:read', ]], provider: SupplierProvider::class, ), new Get( security: "is_granted('commercial.suppliers.view')", // RETEX M1 §1/§2 : le DÉTAIL embarque les sous-collections (contacts, // adresses, ribs) ET leurs relations imbriquées. Les 3 maillons doivent // être présents : groupe sur la propriété (supplier:item:read), groupe // dans ce contexte, ET read-group de chaque entité imbriquée // (category:read, site:read) — sinon embed = IRI vide. normalizationContext: ['groups' => [ 'supplier:read', 'supplier:item:read', // embed contacts / addresses 'supplier:read:accounting', // scalaires compta + embed ribs (filtré par le Provider selon accounting.view) 'category:read', // embed des Category (id/code/name) — relation imbriquée 'site:read', // embed des Site (id/name/postalCode/city/color, pas de code) — relation imbriquée 'default:read', ]], // Le Provider RETIRE supplier:read:accounting du contexte si l'user // n'a pas is_granted('commercial.suppliers.accounting.view'). provider: SupplierProvider::class, ), new Post( security: "is_granted('commercial.suppliers.manage')", normalizationContext: ['groups' => ['supplier:read', 'default:read']], denormalizationContext: ['groups' => ['supplier:write:main']], processor: SupplierProcessor::class, ), new Patch( security: "is_granted('commercial.suppliers.manage')", // Le SupplierProcessor inspecte les groupes envoyés pour autoriser // onglet par onglet (cf. § 2.10 + § 5). Patch des champs comptables // exige is_granted('commercial.suppliers.accounting.manage') ; // patch isArchived exige is_granted('commercial.suppliers.archive'). normalizationContext: ['groups' => ['supplier:read', 'default:read']], denormalizationContext: ['groups' => [ 'supplier:write:main', 'supplier:write:information', 'supplier:write:accounting', 'supplier:write:archive', ]], provider: SupplierProvider::class, processor: SupplierProcessor::class, ), // Pas de Delete au M2 (HP M3). Archivage via PATCH { isArchived: true }. ], )] #[ORM\Entity(repositoryClass: DoctrineSupplierRepository::class)] #[ORM\Table(name: 'supplier')] #[Auditable] class Supplier implements TimestampableInterface, BlamableInterface { use TimestampableBlamableTrait; #[ORM\Id, ORM\GeneratedValue, ORM\Column] #[Groups(['supplier:read'])] private ?int $id = null; #[ORM\Column(length: 180)] #[Assert\NotBlank(message: 'Le nom du fournisseur est obligatoire.', normalizer: 'trim')] #[Assert\Length(min: 2, max: 180, normalizer: 'trim')] #[Groups(['supplier:read', 'supplier:write:main'])] private ?string $companyName = null; // Contact inline retiré (V1, refonte-contact) : firstName / lastName / phonePrimary / // phoneSecondary / email ne sont plus portés par Supplier — ils vivent dans SupplierContact // (onglet Contacts). Garantie « ≥ 1 contact nommé » via RG-2.04 + RG-2.13. /** @var Collection Catégories de type FOURNISSEUR (RG-2.10) */ // Embarquée en LISTE et DÉTAIL (cohérence M1/ERP-62). Collection bornée. // Maillon (c) : pour voir id/code/name, le contexte inclut 'category:read'. #[ORM\ManyToMany(targetEntity: Category::class)] #[ORM\JoinTable(name: 'supplier_category')] #[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')] #[Groups(['supplier:read', 'supplier:write:main'])] private Collection $categories; // === Sites agrégés pour la LISTE (colonne « Site » du répertoire) === // Cohérence M1/ERP-62 : on EMBARQUE les Site (objets entiers). Renvoie les Site // dédoublonnés issus des adresses ; sérialisés via 'site:read' → name/postalCode/ // city/color (⚠ Site N'A PAS de champ `code` : « 86/17/82 » = préfixe du postalCode, // libellé = `name`). Identique au Client.getSites() racine déjà en prod (fix #82). // ⚠ Fetch-join obligatoire (addresses.sites) côté repository — anti N+1 (§ 2.12). /** @return array */ #[Groups(['supplier:read'])] public function getSites(): array { $sites = []; foreach ($this->addresses as $a) { foreach ($a->getSites() as $s) { $sites[$s->getId()] = $s; // dédoublonnage par id } } return array_values($sites); } // === Onglet Information === #[ORM\Column(type: 'text', nullable: true)] #[Groups(['supplier:read', 'supplier:write:information'])] private ?string $description = null; #[ORM\Column(length: 255, nullable: true)] #[Groups(['supplier:read', 'supplier:write:information'])] private ?string $competitors = null; #[ORM\Column(type: 'date_immutable', nullable: true)] #[Groups(['supplier:read', 'supplier:write:information'])] private ?DateTimeImmutable $foundedAt = null; #[ORM\Column(nullable: true)] #[Assert\PositiveOrZero] #[Groups(['supplier:read', 'supplier:write:information'])] private ?int $employeesCount = null; #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)] #[Groups(['supplier:read', 'supplier:write:information'])] private ?string $revenueAmount = null; #[ORM\Column(length: 120, nullable: true)] #[Groups(['supplier:read', 'supplier:write:information'])] private ?string $directorName = null; #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)] #[Groups(['supplier:read', 'supplier:write:information'])] private ?string $profitAmount = null; // NEW vs Client : Volume prévisionnel #[ORM\Column(nullable: true)] #[Assert\PositiveOrZero] #[Groups(['supplier:read', 'supplier:write:information'])] private ?int $volumeForecast = null; // === Onglet Comptabilité (lecture/écriture conditionnées par permission — cf. M1) === #[ORM\Column(length: 20, nullable: true)] #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] private ?string $siren = null; #[ORM\Column(length: 40, nullable: true)] #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] private ?string $accountNumber = null; #[ORM\ManyToOne(targetEntity: TvaMode::class)] #[ORM\JoinColumn(name: 'tva_mode_id', nullable: true, onDelete: 'RESTRICT')] #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] private ?TvaMode $tvaMode = null; #[ORM\Column(length: 40, nullable: true)] #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] private ?string $nTva = null; #[ORM\ManyToOne(targetEntity: PaymentDelay::class)] #[ORM\JoinColumn(name: 'payment_delay_id', nullable: true, onDelete: 'RESTRICT')] #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] private ?PaymentDelay $paymentDelay = null; #[ORM\ManyToOne(targetEntity: PaymentType::class)] #[ORM\JoinColumn(name: 'payment_type_id', nullable: true, onDelete: 'RESTRICT')] #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] private ?PaymentType $paymentType = null; #[ORM\ManyToOne(targetEntity: Bank::class)] #[ORM\JoinColumn(name: 'bank_id', nullable: true, onDelete: 'RESTRICT')] #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] private ?Bank $bank = null; // === Sous-collections — EMBARQUÉES dans le DÉTAIL (RETEX M1 §2) === // Maillon (a) OBLIGATOIRE : sans #[Groups], jamais sérialisées (erreur n°1 du M1). // Embed borné dans le Get racine → ne viole pas la règle n°13 (pas une GetCollection exposée). // Édition via sous-ressources POST/PATCH/DELETE (cf. § 4.5). /** @var Collection */ #[ORM\OneToMany(mappedBy: 'supplier', targetEntity: SupplierContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[Groups(['supplier:item:read'])] private Collection $contacts; /** @var Collection */ #[ORM\OneToMany(mappedBy: 'supplier', targetEntity: SupplierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[Groups(['supplier:item:read'])] private Collection $addresses; /** @var Collection RIB embarqués dans le groupe COMPTA (gated par le Provider) */ #[ORM\OneToMany(mappedBy: 'supplier', targetEntity: SupplierRib::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[Groups(['supplier: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 du M1, cf. § 4.0.ter) : le #[Groups] DOIT être sur // le GETTER avec #[SerializedName] — sinon Symfony dérive l'attribut "archived" // (strip de "is") et droppe la clé "isArchived" du JSON. À tester sur JSON réel. #[Groups(['supplier:read', 'supplier:write:archive'])] #[SerializedName('isArchived')] public function isArchived(): bool { return $this->isArchived; } #[ORM\Column(type: 'datetime_immutable', nullable: true)] #[Groups(['supplier:read'])] private ?DateTimeImmutable $archivedAt = null; // NB : `updatedAt` (du TimestampableBlamableTrait) doit être exposé dans le // groupe `supplier:read` — il alimente la colonne « Dernière activité » du // datatable du répertoire (cf. spec-front). #[ORM\Column(type: 'datetime_immutable', nullable: true)] private ?DateTimeImmutable $deletedAt = null; public function __construct() { $this->categories = new ArrayCollection(); $this->contacts = new ArrayCollection(); $this->addresses = new ArrayCollection(); $this->ribs = new ArrayCollection(); } // Getters / setters omis — pattern Starseed standard. } ``` ### 3.4 Squelettes des autres entités Même pattern que les jumelles `Client*` (`#[Auditable]`, `TimestampableBlamableTrait`, FK `supplier_id`). **Chaque propriété affichée porte un read-group** (RETEX M1 §1 maillon (a)) : **`SupplierContact`** — toutes les propriétés métier dans `['supplier:item:read', 'supplier:write:contacts']` : `firstName`, `lastName`, `jobTitle`, `phonePrimary`, `phoneSecondary`, `email`, `id`. Embed sous `supplier.contacts` au détail ; éditables via la sous-ressource. **`SupplierAddress`** — propriétés dans `['supplier:item:read', 'supplier:write:addresses']` : `addressType` (enum string `PROSPECT|DEPART|RENDU`, `#[Assert\Choice]`), `country`, `postalCode`, `city`, `street`, `streetComplement`, `bennes` (int nullable), `triageProvider` (bool — ⚠ piège #3 : `#[Groups]` + `#[SerializedName('triageProvider')]` **sur le getter** `isTriageProvider()`/`getTriageProvider()`, sinon clé droppée), `id`. Relations imbriquées (maillon (c) — read-groups à inclure dans le contexte du `Get` racine) : - M2M `sites` → `#[Groups(['supplier:item:read'])]` sur la propriété ; `Site` expose `id`/`name`/`postalCode`/`city`/`color` en `site:read` (**pas de `code`** — cf. § 2.4) (`Assert\Count(min:1)` — RG-2.06). - M2M `contacts` → `#[Groups(['supplier:item:read'])]` ; embarque des `SupplierContact` (déjà en `supplier:item:read`). - M2M `categories` → `#[Groups(['supplier:item:read'])]` ; `Category` expose `id`/`code`/`name` en `category:read` (libellé = `name` ; type FOURNISSEUR — RG-2.10). **Pas** de `billingEmail`. **`SupplierRib`** — propriétés dans `['supplier:read:accounting', 'supplier:write:accounting']` : `label`, `bic`, `iban`, `id`. Embed sous `supplier.ribs` **uniquement** si l'user a `accounting.view` (le Provider gère le retrait du groupe). Aucun `#[AuditIgnore]` sur `iban`/`bic` (audit admin-only, décision M1 reportée). > ⚠ **`Site` et `Category` appartiennent à d'autres modules** — on ne les importe pas pour de la logique ; on consomme leurs read-groups (`site:read`, `category:read`), confirmés sur le JSON réel : `Category` = `code` + **`name`** (pas `label`) ; `Site` = `name`/`postalCode`/`city`/`color` (**pas de `code`** ; « 86/17/82 » = préfixe `postalCode`). L'embed est pleinement matérialisé (fix M1 #82 OK). Côté Catalog, le **filtre `?typeCode=` reste à implémenter** (cf. § 2.4). **Référentiels (`TvaMode`, `PaymentDelay`, `PaymentType`, `Bank`)** : **réutilisés du M1**, aucune nouvelle entité (cf. § 2.3). Embarqués dans les scalaires compta via `supplier:read:accounting` (id + label). ## 4. API REST (API Platform) ### 4.0 Contrat de sérialisation (RETEX M1 — section critique) > **Leçon M1** : ~80 % des frictions venaient du contrat de sérialisation, pas du métier. Pour **chaque champ affiché** par le front (liste OU détail), les **3 maillons** doivent être prouvés ici : (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. Si un seul manque → champ vide / IRI. **Contexte par opération** : | Opération | `normalizationContext` (groupes) | |---|---| | `GetCollection` (liste) | `supplier:read` + `category:read` + `site:read` + `default:read` | | `Get` (détail) | `supplier:read` + `supplier:item:read` + `supplier:read:accounting`¹ + `category:read` + `site:read` + `default:read` | ¹ `supplier:read:accounting` retiré par le `SupplierProvider` si l'user n'a pas `commercial.suppliers.accounting.view`. **LISTE — champ datatable → maillons** : | Champ affiché | Propriété (a) | Dans contexte liste (b) | Imbriqué (c) | |---|---|---|---| | Nom | `companyName` ∈ `supplier:read` | ✅ | — | | Catégories | `categories` ∈ `supplier:read` (embed) | ✅ | `category:read` ✅ (code/**name**) | | Site | `getSites()` ∈ `supplier:read` (embed, Site[] dédoublonné) | ✅ | `site:read` ✅ (**name**/postalCode, pas de code) | | Dernière activité | `updatedAt` ∈ `supplier:read` | ✅ | — | > Choix d'alignement M1/ERP-62 (§ 2.12) : la liste **embarque** `categories[]` (code/name) et `sites[]` (name/postalCode). Elle n'embarque pas `contacts`/`addresses` complets. **Fetch-joins obligatoires** (`categories`, `addresses.sites`) dans le repository pour éviter le N+1. **DÉTAIL — champ → maillons** : | Bloc / champ | Propriété (a) | Dans contexte détail (b) | Imbriqué (c) | |---|---|---|---| | Scalaires principaux + Information | `supplier:read` | ✅ | — | | `categories[]` (id/code/name) | `categories` ∈ `supplier:read` | ✅ | `category:read` ✅ | | `contacts[]` (5 champs) | `contacts` ∈ `supplier:item:read` | ✅ | propriétés `SupplierContact` ∈ `supplier:item:read` ✅ | | `addresses[]` (scalaires) | `addresses` ∈ `supplier:item:read` | ✅ | propriétés `SupplierAddress` ∈ `supplier:item:read` ✅ | | `addresses[].sites[]` | `sites` ∈ `supplier:item:read` | ✅ | `site:read` ✅ | | `addresses[].categories[]` | `categories` ∈ `supplier:item:read` | ✅ | `category:read` ✅ | | `addresses[].contacts[]` | `contacts` ∈ `supplier:item:read` | ✅ | propriétés `SupplierContact` ∈ `supplier:item:read` ✅ | | Scalaires Comptabilité (siren, refs…) | `supplier:read:accounting` | ✅ (gated) | refs (`tvaMode`…) id+label ∈ `supplier:read:accounting` | | `ribs[]` (label/bic/iban) | `ribs` ∈ `supplier:read:accounting` | ✅ (gated) | — | ### 4.0.bis Réponses JSON de référence (DoD — à confirmer sur l'API réelle) > **Definition of Done de cette spec back (RETEX M1 §3)** : avant d'écrire les tickets front, créer un fournisseur de test et **coller ici les réponses RÉELLES** de `GET /api/suppliers` et `GET /api/suppliers/{id}`. Les containers n'étant pas lancés au moment de la rédaction, le JSON ci-dessous est le **contrat CIBLE** — à valider/remplacer par la réponse réelle (`make start` puis `curl`). Toute donnée affichée par le front DOIT apparaître dans ce JSON. > **Forme d'enveloppe confirmée sur le M1 réel** (API Platform 4.2) : JSON-LD **sans préfixe `hydra:`** → clés `member` / `totalItems` / `view`, avec `@type: "Collection"` et `view.@type: "PartialCollectionView"`. `Content-Type: application/ld+json; charset=utf-8`. Pagination défaut 10 confirmée. Login réel = `POST /api/login_check` (nginx réécrit vers `/login_check`), réponse `204` + cookie HttpOnly `BEARER`. `GET /api/suppliers` (liste, ADMIN) : ```json { "@context": "/api/contexts/Supplier", "@id": "/api/suppliers", "@type": "Collection", "totalItems": 13, "member": [ { "@id": "/api/suppliers/1", "@type": "Supplier", "id": 1, "companyName": "RECYCLA SAS", "categories": [ {"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"} ], "sites": [ {"@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}, {"@id": "/api/sites/2", "id": 2, "name": "Saint-Jean", "postalCode": "17400", "city": "Fontenet", "color": "#…"} ], "updatedAt": "2026-02-17T09:30:00+00:00", "isArchived": false } ], "view": { "@id": "/api/suppliers?page=1", "@type": "PartialCollectionView", "first": "/api/suppliers?page=1", "last": "/api/suppliers?page=2", "next": "/api/suppliers?page=2" } } ``` > Les fournisseurs archivés sont **exclus** du `totalItems` (sur le M1, 14 clients en base → `totalItems: 13` car 1 archivé filtré par le Provider). `categories[]` (avec `code`/`name`) et `sites[]` (avec `name`/`postalCode` — **pas de `code`**) sont **embarqués** (cohérence M1/ERP-62, § 2.12) ; `sites` est l'agrégat dédoublonné des adresses via `Supplier::getSites()`. Fetch-joins repository obligatoires (anti N+1). `GET /api/suppliers/1` (détail — user avec `accounting.view`) : ```json { "@id": "/api/suppliers/1", "@type": "Supplier", "id": 1, "companyName": "RECYCLA SAS", "categories": [ {"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"} ], "description": "…", "competitors": "…", "foundedAt": "2008-04-01", "employeesCount": 42, "revenueAmount": "1500000.00", "directorName": "…", "profitAmount": "120000.00", "volumeForecast": 8000, "contacts": [ {"@id": "/api/supplier_contacts/1", "id": 1, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable achats", "phonePrimary": "0612345678", "phoneSecondary": null, "email": "marie.martin@recycla.fr"} ], "addresses": [ {"@id": "/api/supplier_addresses/1", "id": 1, "addressType": "DEPART", "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias", "streetComplement": null, "bennes": 3, "triageProvider": true, "sites": [{"@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}], "categories": [{"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}], "contacts": [{"@id": "/api/supplier_contacts/1", "id": 1, "firstName": "Marie", "lastName": "Martin"}]} ], "siren": "123456789", "accountNumber": "F0001", "tvaMode": {"@id": "/api/tva_modes/1", "id": 1, "label": "France (ventes)"}, "nTva": "FR00123456789", "paymentDelay": {"@id": "/api/payment_delays/2", "id": 2, "label": "30 jours"}, "paymentType": {"@id": "/api/payment_types/2", "id": 2, "code": "LCR", "label": "LCR"}, "bank": null, "ribs": [ {"@id": "/api/supplier_ribs/1", "id": 1, "label": "Compte principal", "bic": "SOGEFRPP", "iban": "FR7630003035400005000000123"} ], "isArchived": false, "archivedAt": null, "updatedAt": "2026-02-17T09:30:00+00:00" } ``` > Pour un user **sans** `accounting.view` (ex. Commerciale) : les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank`, `ribs` **sont absentes** (pas `null` — réellement non sérialisées car le Provider retire le groupe). Le gating par **omission de clé** est confirmé confortable côté front. Le blame `updatedBy` est sérialisé en **IRI** (`"/api/me"` quand c'est l'user courant) — en tenir compte côté front. ### 4.0.ter Pièges de sérialisation CONSTATÉS sur le M1 réel → parade M2 (OBLIGATOIRE) > Capture réelle du contrat M1 (clients) effectuée le 02/06/2026. Les 5 divergences ci-dessous sont **des bugs présents en prod sur le M1** ; chacune a une parade à appliquer/vérifier au M2. Tous sont des oublis silencieux du contrat de sérialisation (aucune erreur levée). | # | Bug constaté sur M1 réel | Cause | Parade M2 | |---|---|---|---| | 1 | `categories[]` embarquées sous Client = `@id`/`@type`/`createdAt`/`updatedAt` seulement — **pas de `code` ni `name`** | `Category.code`/`name` portent **uniquement** `category:read`, absent du contexte de sérialisation du `Get` Client | LISTE **et** DÉTAIL : `category:read` est inclus dans le `normalizationContext` (§ 3.3 / § 2.12). **Test sur JSON réel** que `categories[].code` et `.name` sont présents en liste ET en détail. ✅ Confirmé OK sur M1 réel (fix). | | 2 | `addresses[].sites[]` embarqués = `@id`/`@type` nu | `Site` expose ses champs sous `site:read`/`me:read`, absent du contexte Client | LISTE (via `getSites()`) **et** DÉTAIL : `site:read` inclus dans le contexte (§ 3.3 / § 2.12). **Fetch-joins** repository pour le N+1. ✅ **Fix M1 #82 confirmé OK** : `Site` embarqué entier (`name`/`postalCode`/`city`/`color` — pas de `code`). | | 3 | 🔴 `ClientAddress.isProspect/isDelivery/isBilling` **totalement absents du JSON** alors que `is_delivery=TRUE` en base | Le `#[Groups]` est sur la **propriété** `isDelivery`, mais le **getter** `isDelivery()` n'a ni `#[Groups]` ni `#[SerializedName]` → Symfony dérive l'attribut `delivery` (strip du préfixe `is`) et **droppe** le champ | M2 a **éliminé ces 3 booléens** (remplacés par l'enum `addressType` string — RG-2.09, donc immunisé). MAIS pour **tout booléen restant** (`triageProvider`, `isArchived`), poser `#[Groups]` **+** `#[SerializedName('isX')]` **sur le getter** (cf. § 3.3) et le **tester sur le JSON réel**. | | 4 | 🔴 `ribs[]` (label/bic/iban) **visibles par la Commerciale** (sans `accounting.view`) | `ClientRib` sous `client_rib:read`, présent **inconditionnellement** dans le contexte `Get` ; le context builder ne gate QUE les 7 scalaires de Client, pas les RIB | M2 met `ribs` dans le groupe **`supplier:read:accounting`** (§ 3.3) — le même groupe gaté/retiré par le `SupplierProvider`. **Test obligatoire** : Commerciale → `ribs` ABSENT (§ 8.1). | | 5 | `member`/`totalItems`/`view` sans préfixe `hydra:` ; `updatedBy` en IRI `/api/me` | Forme JSON-LD d'API Platform 4.2 | Contrat documenté tel quel (§ 4.0.bis). Le front consomme `member`/`totalItems`/`view` (déjà géré par `usePaginatedList`). | > **Dépendance confirmée sur le JSON réel (02/06)** : l'embed des `sites[]` (liste via `getSites()` ET détail via `addresses[].sites[]`) est **pleinement matérialisé** (fix M1 #82 OK). `site:read` expose `name`/`street`/`postalCode`/`city`/`color`/`fullAddress` — **il n'y a PAS de champ `code`** : le « 86/17/82 » de la maquette est le **préfixe du `postalCode`** (86100/17400/82400) et le libellé du site est `name` (Chatellerault/Saint-Jean/Pommevic). La spec front référence donc `name` + `postalCode`, jamais `Site.code`. Côté Catalog, le **filtre `?typeCode=` reste à implémenter** (§ 2.4) et le type `FOURNISSEUR` à recréer. > **Règle de rédaction M2 (anti-régression)** : aucun champ n'est déclaré « exposé/embarqué » sans avoir été **vu dans un JSON réel**. Les tests fonctionnels assertent sur le **corps de réponse**, jamais sur l'annotation. ### 4.1 `GET /api/suppliers` — Liste - **Security** : `is_granted('commercial.suppliers.view')` - **Query params** (alimentent le panneau « Filtrer » du front — cf. spec-front) : - `includeArchived=true|false` (default `false`) - `categoryCode=` (filtre les fournisseurs ayant ≥ 1 `Category` de ce code ; répétable pour multi-sélection) - `siteId=` (filtre les fournisseurs ayant ≥ 1 adresse rattachée à ce site ; répétable — jointure `addresses.sites`) - `search=` (fuzzy sur `companyName` + contacts liés `supplier_contact` (firstName / lastName / email) via LEFT JOIN groupé par `supplier.id` — décision D1, refonte-contact) - **Tri par défaut** : `companyName ASC` - **Pagination** : standard Starseed (règle ABSOLUE n°13) — Hydra activée, 10/page, `?pagination=false` pour les selects. `SupplierProvider` branché sur `ApiPlatform\Doctrine\Orm\Paginator`. - **Fetch-joins (anti N+1, § 2.12)** : la requête de liste du `DoctrineSupplierRepository` pose des `leftJoin`+`addSelect` sur `categories` et `addresses.sites` (la pagination Doctrine reste correcte car ces relations sont des collections chargées via Paginator). - **Réponse 200** (JSON-LD) : champs `supplier:read` + `categories[]` (code/name) / `sites[]` (name/postalCode) embarqués. Les champs `supplier:read:accounting` n'apparaissent que si l'user a `accounting.view`. - **Codes** : `200` / `401` / `403` ### 4.2 `GET /api/suppliers/{id}` — Détail - **Security** : `is_granted('commercial.suppliers.view')` - **Comportement** : fournisseur + contacts + adresses + RIBs. Les champs `supplier:read:accounting` sont inclus seulement si `commercial.suppliers.accounting.view`. - **Codes** : `200` / `404` / `401` / `403` ### 4.3 `POST /api/suppliers` — Création (formulaire principal) - **Security** : `is_granted('commercial.suppliers.manage')` - **Body** (groupe `supplier:write:main`) : ```json { "companyName": "RECYCLA SAS", "categories": ["/api/categories/12", "/api/categories/15"] } ``` - **Réponse 201** : le fournisseur 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-2.11). SIREN/email non uniques. - `422` : catégories vides ; catégorie hors type FOURNISSEUR (RG-2.10). _(RG-2.01 supprimée V1 — complétude contact via onglet Contacts : RG-2.04 / RG-2.13.)_ ### 4.4 `PATCH /api/suppliers/{id}` — Modification - **Security base** : `is_granted('commercial.suppliers.manage')` - **Security additionnelle** (dans le `SupplierProcessor`) : - payload contenant un champ `supplier:write:accounting` → exige `commercial.suppliers.accounting.manage` - payload contenant `isArchived` → exige `commercial.suppliers.archive` - **mode strict** (RG-2.16) : 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/suppliers/{id}/contacts`, `PATCH /api/supplier_contacts/{id}`, `DELETE /api/supplier_contacts/{id}`. - **Security** : `is_granted('commercial.suppliers.manage')` - **RG-2.13** : au moins 1 bloc Contact valide (Nom OU Prénom) pour finaliser l'onglet côté front. Côté back, la collection peut rester vide (pas de state machine). **Adresses** : `POST /api/suppliers/{id}/addresses`, `PATCH /api/supplier_addresses/{id}`, `DELETE /api/supplier_addresses/{id}`. - **Security** : `is_granted('commercial.suppliers.manage')` - Validations : `addressType ∈ {PROSPECT,DEPART,RENDU}` (RG-2.09) ; ≥ 1 site (RG-2.06) ; catégories de type FOURNISSEUR uniquement (RG-2.10) ; `postalCode` matche `^[0-9]{4,5}$` (RG-2.05). **RIBs** : `POST /api/suppliers/{id}/ribs`, `PATCH /api/supplier_ribs/{id}`, `DELETE /api/supplier_ribs/{id}`. - **Security** : `is_granted('commercial.suppliers.accounting.manage')` - **RG-2.08** : si `paymentType.code = LCR`, suppression du dernier RIB → 409. ### 4.6 `GET /api/suppliers/export.xlsx` — Export - **Security** : `is_granted('commercial.suppliers.view')` - **Comportement** : XLSX des fournisseurs **affichés** (mêmes filtres que la liste, non archivés par défaut). - Colonnes : Nom fournisseur, 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. _(V1, décision D2 : colonnes contact alimentées depuis le **contact principal** `supplier_contact` de plus petit `position` — plus de contact inline sur le Supplier.)_ - **Implémentation** : controller custom `SupplierExportController` avec `#[Route(priority: 1)]` (règle ABSOLUE — conflit API Platform `{id}`). Lib : PhpSpreadsheet (déjà présente, M1). - **Réponse 200** : `Content-Disposition: attachment; filename="repertoire-fournisseurs-{YYYYMMDD}.xlsx"` ### 4.7 Référentiels (réutilisés M1 — évolution security) `GET /api/tva_modes`, `/api/payment_delays`, `/api/payment_types`, `/api/banks` existent (M1). **Évolution M2** : élargir leur `security` pour autoriser aussi les rôles fournisseurs, p.ex. `is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')`. Tri `position ASC` puis `label ASC`. Pas d'écriture exposée (HP). `GET /api/categories?typeCode=FOURNISSEUR` alimentera les multi-selects Catégorie (fournisseur + adresse). ⚠️ **Ce filtre n'existe pas en prod** (vérifié sur le JSON réel : `?typeCode=` est ignoré, seul le type CLIENT existe — ERP-78). Le M2 doit le **recréer** : type `FOURNISSEUR` + filtre `?typeCode=` sur `/api/categories` (module Catalog). Cf. § 2.4 + ticket dédié. ## 5. Autorisation ### 5.1 Déclaration des permissions Enrichir `CommercialModule::permissions()` (ajout aux 5 permissions clients existantes) : ```php // ... commercial.clients.* déjà présentes ... ['code' => 'commercial.suppliers.view', 'label' => 'Voir les fournisseurs'], ['code' => 'commercial.suppliers.manage', 'label' => 'Créer / modifier les fournisseurs (hors onglet Comptabilité)'], ['code' => 'commercial.suppliers.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un fournisseur'], ['code' => 'commercial.suppliers.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un fournisseur'], ['code' => 'commercial.suppliers.archive', 'label' => 'Archiver / restaurer un fournisseur'], ``` Synchronisation : `php bin/console app:sync-permissions`. ### 5.2 Mapping rôles MALIO ↔ permissions Cf. § 2.9 (matrice détaillée — identique à la matrice M1 transposée sur `suppliers`). ### 5.3 Synchronisation RBAC (3 sources OBLIGATOIRES — règle ABSOLUE Starseed n°8) 1. **`config/sidebar.php`** — item « Répertoire fournisseurs » déjà présent (`to => '/suppliers'`), **à compléter** avec la permission : ```php [ 'label' => 'sidebar.commercial.suppliers', 'to' => '/suppliers', 'icon' => 'mdi:account-arrow-left-outline', 'module' => 'commercial', 'permission' => 'commercial.suppliers.view', // ← à ajouter ], ``` 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` - Usine : aucune 3. **`src/Module/Core/Infrastructure/Console/SeedE2ECommand.php`** — miroir back des mêmes personas. > ⚠ Toute modif d'une de ces 3 sources sans les 2 autres = drift garanti (test cassé). Les 3 doivent être touchées dans le même commit. ### 5.4 Vérification front - `usePermissions()` filtre l'item sidebar et masque l'onglet Comptabilité (`commercial.suppliers.accounting.view`). - Bouton « Archiver » visible si `commercial.suppliers.archive` (Admin seul). ## 6. Audit & dates - `Supplier`, `SupplierContact`, `SupplierAddress`, `SupplierRib` : `#[Auditable]`, tous champs audités (y compris `iban`/`bic` — cf. § 2.7). - Audit M2M automatique sur `supplier.categories` → `{categories: {added:[...], removed:[...]}}`. - Timestampable + Blamable : pattern Shared standard (cf. § 2.8). ## 7. Règles de gestion (RG) Les RG-2.01 → RG-2.08 reprennent **mot pour mot** le docx source. Les RG-2.09 → RG-2.17 sont des **précisions back** (miroir M1) explicitement marquées. ### Formulaire principal - ~~**RG-2.01**~~ _(SUPPRIMÉE — V1, 2026-06-03, refonte-contact)_ : le contact principal inline est retiré du `Supplier`. Garantie « au moins un contact nommé » portée par **RG-2.04** + **RG-2.13** sur `SupplierContact`. - ~~**RG-2.02**~~ _(SUPPRIMÉE du Supplier — V1)_ : plus de téléphones inline sur le `Supplier`. Le « maximum 2 téléphones » reste applicable aux blocs `SupplierContact`. ### Onglet Information - **RG-2.03** : Pour le rôle **Commerciale**, **tous** les champs de l'onglet Information (`description`, `competitors`, `foundedAt`, `employeesCount`, `revenueAmount`, `directorName`, `profitAmount`, `volumeForecast`) sont obligatoires sur **POST et tout PATCH**. Pour les autres rôles, optionnels. Validator custom `SupplierInformationCompletenessValidator` invoqué par le `SupplierProcessor` quand l'user porte le rôle Commerciale. - **Conséquence** (miroir RG-1.04) : le POST n'exposant que `supplier:write:main`, une Commerciale obtient **422** sur tout POST tant que l'Information n'est pas complète → la complétude se fait via les PATCH `supplier:write:information`. Un Admin (non gaté) crée normalement (201). ### Onglet Contact - **RG-2.04** : Un bloc Contact est valide dès qu'**au moins** `firstName` OU `lastName` est rempli. CHECK BDD `chk_supplier_contact_name`. Côté UI, le bouton « + Nouveau contact » est bloqué tant que le bloc en cours n'a pas Prénom OU Nom. ### Onglet Adresse - **RG-2.05** : `city` préremplie depuis `postalCode` via l'API **BAN** (api-adresse.data.gouv.fr), appel **direct front** via `useAddressAutocomplete()` (composable déjà créé au M1, réutilisé). L'adresse est une saisie assistée basée sur CP + ville. 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. - **RG-2.06** : Au moins un des 3 sites (86 / 17 / 82) doit être sélectionné sur chaque adresse. `Assert\Count(min: 1)` sur `supplierAddress.sites`. ### Onglet Comptabilité - **RG-2.07** : Le champ `bank` est visible et obligatoire **uniquement** si `paymentType.code = 'VIREMENT'` (options SG / CIC / CA). Validation server-side dans le `SupplierProcessor` : `payment_type = VIREMENT` et `bank IS NULL` → 422. - **RG-2.08** : Les champs RIB (`label`, `bic`, `iban`) sont obligatoires si `paymentType.code = 'LCR'`. C'est-à-dire : - `paymentType = LCR` ET `supplier.ribs.count() = 0` → 422 « Au moins un RIB est obligatoire pour le type LCR ». - DELETE du dernier RIB d'un fournisseur en LCR → 409. - Autres types : RIBs optionnels (0..n). ### Précisions back (miroir M1) - **RG-2.09** _(précision back)_ : `address_type` est un **enum exclusif** `PROSPECT | DEPART | RENDU` (radio côté front, une seule valeur). CHECK BDD `chk_supplier_address_type`. Remplace les 3 booléens prospect/livraison/facturation du `client`. - **RG-2.10** _(précision back)_ : les `Category` posées sur `supplier.categories` ET sur `supplier_address.categories` doivent être de **type `FOURNISSEUR`**. Toute catégorie d'un autre type → **422** (`categories: "Type de catégorie non autorisé (FOURNISSEUR attendu)."`). Front : les multi-selects sont alimentés par `GET /api/categories?typeCode=FOURNISSEUR`. - **RG-2.11** _(précision back)_ : `companyName` unique (case-insensitive) parmi les fournisseurs non archivés ET non soft-deletés (index partiel `uq_supplier_company_name_active`). Doublon → 409 « Un fournisseur nommé "{companyName}" existe déjà. » SIREN et email **non** uniques (cf. § 2.6). - **RG-2.12** _(normalisation serveur)_ : `companyName` **UPPERCASE** ; `firstName`/`lastName` (sur `SupplierContact` ; scope `Supplier` retiré en V1) **Capitalize** ; téléphones **chiffres uniquement** ; `email` **lowercase**. Formatage `XX XX XX XX XX` à l'affichage front. - **RG-2.13** _(front-driven)_ : au moins 1 bloc Contact valide pour finaliser l'onglet (cf. RG-2.04). Pas de test back. - **RG-2.14** _(archivage)_ : PATCH `{ "isArchived": true }` exige `commercial.suppliers.archive` (**Admin seul**). Pose `isArchived = true` + `archivedAt = now()`. Aucun autre champ dans la même requête. - **RG-2.15** _(restauration)_ : PATCH `{ "isArchived": false }` exige la même permission. Pose `isArchived = false` + `archivedAt = null`. Conflit d'unicité (un autre fournisseur actif a pris le nom) → 409. - **RG-2.16** _(PATCH mix de groupes, mode strict)_ : un PATCH mélangeant plusieurs groupes de sérialisation 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 (onglets masqués = pas de payload). - **RG-2.17** _(liste / tri)_ : `GET /api/suppliers` 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`. ## 8. Tests à automatiser ### 8.1 Cas à couvrir (back — PHPUnit) - [ ] ~~RG-2.01~~ _(supprimée V1)_ : complétude contact couverte par RG-2.04 / RG-2.13 sur `SupplierContact` - [ ] ~~RG-2.02~~ _(supprimée du Supplier V1)_ : téléphones inline retirés du Supplier (testés sur `SupplierContact`) - [ ] **RG-2.03** : PATCH Information par Commerciale incomplet → 422 ; par Admin → 200 ; POST par Commerciale → 422 (Information non renseignable au POST) - [ ] **RG-2.04** : POST contact sans firstName ni lastName → 422 (CHECK) - [ ] **RG-2.05** : POST adresse `postalCode` invalide (3 chiffres) → 422 ; CP/ville incohérents → 200 (pas de contrôle strict) - [ ] **RG-2.06** : POST adresse sans aucun site → 422 - [ ] **RG-2.07** : POST Comptabilité `paymentType=VIREMENT` sans `bank` → 422 ; avec `bank` → 200 - [ ] **RG-2.08** : POST `paymentType=LCR` sans RIB → 422 ; DELETE du dernier RIB en LCR → 409 - [ ] **RG-2.09** : POST adresse `addressType` hors enum → 422 (CHECK / Assert\Choice) ; les 3 valeurs valides → 200 - [ ] **RG-2.10** : POST `categories` avec une `Category` de type ≠ FOURNISSEUR → 422 (sur supplier ET sur supplier_address) - [ ] **RG-2.11** : POST `companyName` déjà pris → 409 ; même nom après archivage de l'ancien → 201 ; SIREN/email dupliqués → 201 - [ ] **RG-2.12** : POST `companyName="recycla sas"` → persiste `"RECYCLA SAS"` ; normalisation `firstName`/`phonePrimary`/`email` testée via un bloc `SupplierContact` (`"MARIE"`→`"Marie"`, `"06.12.34.56.78"`→`"0612345678"`, `"Marie@RECYCLA.FR"`→`"marie@recycla.fr"`) - [ ] **RG-2.14/15** : PATCH isArchived=true par Bureau (sans `archive`) → 403 ; par Admin → 200 + archivedAt rempli ; restauration en conflit de nom → 409 - [ ] **RG-2.16** : Bureau PATCH `{companyName, siren}` → 403 sur tout le payload (strict) - [ ] **RG-2.17** : 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 - [ ] **Compta** : GET fournisseur retourne les champs accounting ; PATCH accounting → 200 ; PATCH info/contacts/adresses → 403 ; POST création → 403 (pas de `manage` global) - [ ] **Commerciale** : GET fournisseur **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, pas sur l'annotation) - [ ] **🔴 Sérialisation booléens (bug #3 M1)** : POST fournisseur + adresse `triageProvider=true`, fournisseur `isArchived` → GET détail expose bien les clés `triageProvider` et `isArchived` dans le JSON réel - [ ] **Embed relations (bugs #1/#2 M1)** : GET **liste ET détail** → `categories[].code` + `.name` présents ; `sites[]` (liste, via `getSites()`) et `addresses[].sites[]` (détail) exposent `name` + `postalCode` (objet Site entier, PAS un IRI nu) - [ ] **Filtre typeCode (brique à créer)** : `GET /api/categories?typeCode=FOURNISSEUR` ne renvoie QUE les catégories de type FOURNISSEUR (aujourd'hui le filtre est ignoré → test rouge tant que non implémenté) - [ ] **Anti N+1 liste (§ 2.12)** : sur `GET /api/suppliers` avec N fournisseurs, compter les requêtes SQL — les fetch-joins (`categories`, `addresses.sites`) doivent éviter l'explosion (pas de requête par ligne) - [ ] **Audit** : POST + PATCH + archive → audit_log `entity_type='Supplier'`, `changes` correct ; iban/bic présents dans le diff - [ ] **Pagination** (règle n°13) : `GET /api/suppliers` renvoie l'envelope Hydra (`totalItems` / `view`) ; `?pagination=false` renvoie tout (alim. select) - [ ] **Migration** : `make db-reset` → schéma OK ; namespace racine ; CategoryType FOURNISSEUR présent APRÈS db-reset (fixture idempotente) ; index partiel `uq_supplier_company_name_active` présent ; **toutes les colonnes ont un `COMMENT ON COLUMN`** (`ColumnsHaveSqlCommentTest` vert) ### 8.2 Cas à couvrir (front — Vitest) - [ ] `useSuppliersRepository()` / `usePaginatedList({url:'/suppliers'})` : exclusion archivés par défaut, envelope Hydra - [ ] `useSupplierForm()` : workflow par onglet (validation incrémentale, PATCH partiel) - [ ] `useAddressAutocomplete()` : réutilisation M1 (cas nominal + dégradé) — pas de nouveau test si déjà couvert - [ ] Radio `addressType` (Prospect/Départ/Rendu) : exclusivité, mapping enum - [ ] `` : `` + « + Ajouter » → `/suppliers/new` - [ ] Permissions : Compta accède à `/suppliers/{id}` mais onglet Comptabilité éditable seul ; Commerciale ne voit pas l'onglet Comptabilité ### 8.3 Tests E2E **Non prévus au M2** (règle ABSOLUE n°7). Extension des personas existants pour ajouter les permissions `commercial.suppliers.*` — cf. § 5.3. ### 8.4 Seed & fixtures démo (RETEX M1 §7 — prévu dès la spec) Le M1 a subi un aller-retour (ERP-68) faute de fixtures alignées. Pour le M2, prévoir **dès le ticket migration/fixtures** un `SupplierFixtures` idempotent couvrant **tous les cas des RG**, pour vérifier le gating et le golden path sans bricolage : - Catégories de type FOURNISSEUR seedées (`CategoryFixtures` étendu) — au moins « Négociant », « Coopérative ». - ≥ 1 fournisseur **complet** (Information remplie, ≥ 1 contact, ≥ 1 adresse multi-sites, comptabilité + RIB). - 1 fournisseur **en LCR avec RIB** (RG-2.08) et 1 **en VIREMENT avec banque** (RG-2.07). - 1 fournisseur avec une adresse de chaque `addressType` (PROSPECT / DEPART / RENDU — RG-2.09). - 1 fournisseur **archivé** (vérifier exclusion liste + restauration). - Réutiliser les comptes de rôles démo existants (`bureau`, `compta`, `commerciale`, `usine`, `admin`) pour tester la matrice § 2.9. > Idempotence obligatoire (le purger Doctrine vide `category`/`category_type` au `db-reset` — cf. M1 § 3.3). Le `CategoryType FOURNISSEUR` 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** collées (§ 4.0.bis) — *en attente de `make start` + curl (DoD avant tickets front)* - [x] Matrice RBAC rôle × onglet + mode strict PATCH (§ 2.9 / RG-2.16) - [x] Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit, routes à plat : rappelés - [x] Réutilisations M1 identifiées (référentiels compta partagés, taxonomie code/type, `usePaginatedList`, blocs, archive, normalisation) - [x] Seed/fixtures démo planifiés (§ 8.4) ## 9. Hors-périmètre (HP) - **HP-M3-1** : **DELETE / soft delete d'un fournisseur** (colonne `deleted_at` préparée, non exposée au M2). - **HP-M3-2** : **CRUD admin des référentiels comptables** (`TvaMode` / `PaymentDelay` / `PaymentType` / `Bank`) — partagés M1, seed seulement. - **HP-M3-3** : **CRUD admin de `CategoryType`** (le M2 seed seulement le type FOURNISSEUR). - **HP-M3-4** : **Onglet Transport** (front placeholder « À venir » — cf. spec-front ; aucun modèle ni API back). - **HP-M3-5** : **Onglet Statistiques** (placeholder « À venir »). - **HP-M3-6** : **Onglet Rapports** (placeholder « À venir »). - **HP-M3-7** : **Onglet Échanges** (placeholder « À venir »). - **HP-M3-8** : **Périmètre Commerciale** (« consultation selon périmètre » — formulation floue du docx). Au M2, Commerciale voit **tous** les fournisseurs (sauf Comptabilité). Cloisonnement par portefeuille = spec dédiée. - **HP-M3-9** : **Validation IBAN/BIC stricte** (au M2, `Assert\Iban` / `Assert\Bic` standard sur `SupplierRib`). - **HP-M3-10** : **Validation SIREN stricte** (Luhn) — au M2, `Assert\Length(9)` + `Assert\Regex('/^\d{9}$/')`. - **HP-M3-11** : **Référencement entrant** (modules futurs ajoutant une FK `supplier_id` : Commandes fournisseurs, Réceptions, etc.). - **HP-M3-12** : **Export CSV** (XLSX uniquement au M2). - **HP-M3-13** : **Liaison Client ↔ Fournisseur** (un même tiers à la fois client et fournisseur). Au M2, entités strictement séparées. ## 10. Liens & dépendances ### Liens - Spec front : [`./spec-front.md`](./spec-front.md) - Spec M1 clients (pattern de référence) : [`../M1-clients/spec-back.md`](../M1-clients/spec-back.md) - Spec M0 catégories : [`../M0-categories/spec-back.md`](../M0-categories/spec-back.md) - Doc audit-log : [`../../audit-log.md`](../../audit-log.md) - BAN api : `https://adresse.data.gouv.fr/api-doc/adresse` - Maquette Figma : `https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-36987&p=f&m=dev` - Trace fonctionnelle V0.1 : `M2-reportoire-fournisseurs.docx` / `M2-reportoire-fournisseurs-V01.pdf` ### Dépendances amont (déjà en place dans Starseed) - Module `Commercial` (M1) : `Client*` + référentiels comptables `TvaMode` / `PaymentDelay` / `PaymentType` / `Bank` (**partagés**) - Module `Catalog` (M0) : `Category` + `CategoryType` (+ seed type FOURNISSEUR au M2) - Module `Sites` : `Site` (3 sites 86/17/82) — M2M `supplier_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 M2 - **M-Commandes fournisseurs** : FK `supplier_id`. - **M-Réceptions / Triage** : exploitation de `supplier_address.bennes` + `triage_provider`. --- ## 📦 Tickets Lesstime (à découper) **TaskGroup Lesstime** : à créer — `M2 — Répertoire fournisseurs` (projet `ERP / Starseed`, projectId=6). Ordre indicatif (back avant front, migration en tête) : 0. **Taxonomie FOURNISSEUR (Catalog)** — recréer le `CategoryType` `FOURNISSEUR` (seed migration + fixture idempotente) + **implémenter le filtre `?typeCode=`** sur `/api/categories` (inopérant en prod, ERP-78) + seed catégories fournisseurs (Négociant, Coopérative…). Prérequis du multi-select Catégorie. 1. **Migration BDD M2** (tables supplier + sous-collections + M2M + index partiel + COMMENT ON COLUMN) 2. **Entités + Repositories** (Supplier, SupplierContact, SupplierAddress, SupplierRib) + **fetch-joins liste** (categories, addresses.sites — § 2.12) 3. **Provider + Processor** (SupplierProvider paginé, SupplierProcessor — normalisation, archivage, accounting conditionnel, mode strict) 4. **Sous-ressources** (SupplierContactProcessor, SupplierAddressProcessor, SupplierRibProcessor) 5. **Validators** (SupplierInformationCompletenessValidator, contrôle catégorie type FOURNISSEUR, RG-2.07/2.08) 6. **Export XLSX** (SupplierExportController, priority:1) 7. **RBAC** : `CommercialModule::permissions()` + sync 3 sources + tests personas 8. **Tests PHPUnit** : matrice RG-2.01 → RG-2.17 (§ 8.1) 9. **Front : page Répertoire** (`/suppliers`) + `usePaginatedList` 10. **Front : page Création** (`/suppliers/new`) + `useSupplierForm` 11. **Front : page Consultation** (`/suppliers/{id}`) + onglets placeholder « À venir » 12. **Front : page Modification** (`/suppliers/{id}/edit`) 13. **i18n + Sidebar** (clé `sidebar.commercial.suppliers` + permission, traductions) ### Actions manuelles dans Lesstime (Matthieu) 1. Créer le TaskGroup `M2 — Répertoire fournisseurs` (projet ERP / Starseed). 2. Créer les ~14 tickets ci-dessus (ticket 0 taxonomie inclus) avec dépendances séquentielles. 3. Mettre à jour le frontmatter (`lesstime_taskgroup_id`) avec l'id réel.