8fae987e15
Auto Tag Develop / tag (push) Successful in 6s
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>
1124 lines
76 KiB
Markdown
1124 lines
76 KiB
Markdown
---
|
||
# === 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.
|