Files
Starseed/docs/specs/M2-suppliers/spec-back.md
T
matthieu 8fae987e15
Auto Tag Develop / tag (push) Successful in 6s
docs(commercial) : refonte contact — suppression du contact inline (specs M1 + M2) (#54)
Acte la décision refonte-contact dans les specs : le contact principal inline (firstName/lastName/phonePrimary/phoneSecondary/email) est retiré des entités tiers (Client, Supplier). Les contacts vivent uniquement dans ClientContact / SupplierContact (onglet Contacts). Garantie « >=1 contact nommé » préservée par RG-1.05/1.14 (M1) et RG-2.04/2.13 (M2).

- M1 (spec-back/spec-front/cahier) : modèle Client sans contact inline ; RG-1.01/1.02 supprimées ; D1 (recherche) / D2 (export) décrites ; version V1.
- M2 (spec-back/spec-front) : FICHIERS NOUVEAUX (non versionnés sur develop), introduits déjà corrigés (Supplier sans contact inline, RG-2.01/2.02 supprimées) ; version V0.2.
- docs/specs/M1-clients/refonte-contact/ : décision (README) + tickets (M1 back/front/specs, M2 specs) + prompts + amendement des tickets M2.

Lesstime : tâches #103 (M1 back), #104 (M1 front), #105 (M1 specs), #106 (M2 specs) ; tickets M2 #85-#97 amendés.
---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #54
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-03 13:16:11 +00:00

1124 lines
76 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
# === 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, '<table>')`. 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
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Sites\Domain\Entity\Site; // référence ORM partagée (comme M1) — pas de logique inter-module
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\SupplierProcessor;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Provider\SupplierProvider;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineSupplierRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('commercial.suppliers.view')",
// Cohérence M1/ERP-62 : la LISTE embarque catégories + sites (pas de
// champ dérivé aplati). Maillon (c) : category:read + site:read dans
// le contexte pour exposer Category(code/name) + Site(name/postalCode).
// ⚠ Le SupplierRepository DOIT fetch-join categories + addresses.sites
// pour éviter le N+1 sur la liste (cf. § 2.12).
normalizationContext: ['groups' => [
'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<int, Category> 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<int, Site> */
#[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<int, SupplierContact> */
#[ORM\OneToMany(mappedBy: 'supplier', targetEntity: SupplierContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[Groups(['supplier:item:read'])]
private Collection $contacts;
/** @var Collection<int, SupplierAddress> */
#[ORM\OneToMany(mappedBy: 'supplier', targetEntity: SupplierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[Groups(['supplier:item:read'])]
private Collection $addresses;
/** @var Collection<int, SupplierRib> 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=<code>` (filtre les fournisseurs ayant ≥ 1 `Category` de ce code ; répétable pour multi-sélection)
- `siteId=<id>` (filtre les fournisseurs ayant ≥ 1 adresse rattachée à ce site ; répétable — jointure `addresses.sites`)
- `search=<text>` (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
- [ ] `<SuppliersRepositoryPage>` : `<MalioDataTable>` + « + 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.