From 5b16fd9928484c5f28dc4eecc68448ad9c860ade Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 2 Jun 2026 09:20:58 +0200 Subject: [PATCH] docs : amend M0/M1 specs for category taxonomy refonte (ERP-78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec M1 § 3.3 (type unique CLIENT + Category.code), RG-1.03 (filtre par code), RG-1.29 (interdiction par code), spec-front (selects par code) et note d'evolution dans la spec M0. --- docs/specs/M0-categories/spec-back.md | 2 ++ docs/specs/M1-clients/spec-back.md | 36 ++++++++++++++++----------- docs/specs/M1-clients/spec-front.md | 8 +++--- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/docs/specs/M0-categories/spec-back.md b/docs/specs/M0-categories/spec-back.md index f9aeea2..106975c 100644 --- a/docs/specs/M0-categories/spec-back.md +++ b/docs/specs/M0-categories/spec-back.md @@ -118,6 +118,8 @@ Aucun pattern soft delete existant dans Starseed (vérifié, aucune entité ne p Index unique partiel sur `(LOWER(name), category_type_id) WHERE deleted_at IS NULL`. Permet de recréer une catégorie avec le même `(name, type)` après suppression logique. Postgres supporte nativement (`CREATE UNIQUE INDEX ... WHERE`). Pattern propre, pas besoin de validator applicatif maison côté unicité — la contrainte SQL fait le job. +> **🔗 Évolution ERP-78 (refonte taxonomie M1)** : `Category` porte désormais une colonne **`code`** (`VARCHAR(50)`, NOT NULL), slug MAJUSCULE auto-généré du nom (figé à la création, lecture seule via l'API), avec un **second index unique partiel** `uq_category_code (code) WHERE deleted_at IS NULL`. Ce code est la clé métier stable utilisée par le M1 Commercial (RG-1.03 / RG-1.29). Détail : `docs/specs/M1-clients/spec-back.md` § 3.3. + ### 2.5 Audit & traces temporelles — deux niveaux complémentaires Deux mécanismes **indépendants** cohabitent : diff --git a/docs/specs/M1-clients/spec-back.md b/docs/specs/M1-clients/spec-back.md index 3fa11b9..96eea8a 100644 --- a/docs/specs/M1-clients/spec-back.md +++ b/docs/specs/M1-clients/spec-back.md @@ -465,26 +465,32 @@ CREATE TABLE client_rib ( CREATE INDEX idx_client_rib_client ON client_rib(client_id); ``` -### 3.3 Seed `CategoryType` (extension du M0) +### 3.3 Seed taxonomie — type unique `CLIENT` + `Category.code` (refonte ERP-78) -Au M0, la table `category_type` a été créée mais reste vide (HP-1 du M0). Le M1 lève cette restriction avec un seed initial des **types métier** dont le module Tiers a besoin : +> **⚠ Refonte ERP-78 (décision produit 01/06) — le modèle ci-dessous remplace l'ancien.** +> Historique : à l'origine (#38), `DISTRIBUTEUR` / `COURTIER` / `SECTEUR` / `AUTRE` étaient des **`category_type`**. Le modèle a été **inversé** : +> +> - **UN SEUL `category_type` : `CLIENT`** (code `CLIENT`, label « Client »). +> - `Distributeur` / `Courtier` / `Secteur` / `Autre` (+ catégories métier fines) sont désormais des **`Category`** rattachées au type `CLIENT`. +> - Le filtrage métier ne se fait plus sur le **type** mais sur un **`code` stable porté par la `Category`** (NOT NULL, unique parmi les actifs — index partiel `uq_category_code`). Le code est un **slug MAJUSCULE auto-généré du nom** (`CategoryCodeGenerator`), figé à la création, et exposé en **lecture seule** (groupe `category:read`). Les codes `DISTRIBUTEUR` / `COURTIER` (anciennement portés par le type) sont reportés sur les `Category` correspondantes. + +Seed cible (migration corrective `Version20260602100000`, namespace racine) : ```sql -INSERT INTO category_type (code, label, position) VALUES - ('DISTRIBUTEUR', 'Distributeur', 10), - ('COURTIER', 'Courtier', 20), - ('SECTEUR', 'Secteur', 30), - ('AUTRE', 'Autre', 99); +-- Type unique +INSERT INTO category_type (code, label) VALUES ('CLIENT', 'Client') ON CONFLICT (code) DO NOTHING; +-- Catégories système sous CLIENT (codes stables pilotant les RG) +-- Distributeur -> DISTRIBUTEUR, Courtier -> COURTIER, Secteur -> SECTEUR, Autre -> AUTRE ``` -> **Note** : le CRUD admin de `CategoryType` reste HP (cf. M0). +> **Note** : le CRUD admin de `CategoryType` reste HP (cf. M0). Le `code` de `Category` n'est PAS saisissable via l'API (auto-généré côté serveur). > -> **Seed en DEUX endroits (décision 29/05, vérifiée empiriquement)** : le `make db-reset` lance les fixtures, dont le purger Doctrine **vide `category_type`** (entité M0 mappée) avant `load()` → un seed posé uniquement en migration disparaît en dev/test. Donc : -> 1. **Migration** (`ON CONFLICT (code) DO NOTHING`) → sert en **prod** (pas de fixtures). -> 2. **Fixture Commercial idempotente** (ex. `CommercialReferentialFixtures`) re-seedant les 4 types → survit au `db-reset`, satisfait le critère « 4 types présents après db-reset ». +> **Seed en DEUX endroits (décision 29/05, vérifiée empiriquement)** : le `make db-reset` lance les fixtures, dont le purger Doctrine **vide `category` / `category_type`** (entités M0 mappées) avant `load()` → un seed posé uniquement en migration disparaît en dev/test. Donc : +> 1. **Migration** (`ON CONFLICT` / guards `NOT EXISTS`) → sert en **prod** (pas de fixtures). +> 2. **Fixtures idempotentes** (`CategoryTypeFixtures` → type CLIENT ; `CategoryFixtures` → catégories codées sous CLIENT) → survivent au `db-reset`. > > ⚠ **À venir en ERP-54** : `tva_mode` / `payment_delay` / `payment_type` / `bank` ne sont pas encore des entités mappées au M1.0 → le purger ne les touche pas, leur seed migration survit. **Dès qu'ERP-54 crée leurs entités, ils seront purgés au db-reset** → il faudra les ajouter à la même fixture référentielle. -> 🔗 **Coordination ERP-68** : ERP-53 pose la fixture référentielle minimale (4 category_types). ERP-68 l'**étend** (clients de démo, ~12-15) sans la dupliquer. +> 🔗 **Coordination ERP-68** : ERP-78 (cette refonte) atterrit avant ERP-68. `CategoryFixtures` / `ClientFixtures` ont été adaptées au type unique CLIENT + codes (les tiers distributeur/courtier portent les `Category` de code DISTRIBUTEUR/COURTIER). ### 3.4 Entité `Client` — squelette @@ -881,7 +887,7 @@ Cf. § 2.6. Pattern Shared standard. - **RG-1.01** : Au moins l'un des champs `firstName` (Prénom du contact principal) ou `lastName` (Nom du contact principal) doit être renseigné. Sinon → 422. - **RG-1.02** : Le champ `phoneSecondary` est optionnel et apparaît au clic sur un bouton `+` côté front. Maximum 2 téléphones (primary + secondary). Comportement purement front au niveau UI ; côté serveur, les 2 colonnes existent et sont distinctes. -- **RG-1.03** : Les champs `distributor` et `broker` sont **mutuellement exclusifs** (au plus une seule des deux est renseignée). Tentative d'envoyer les deux → 422. Contrainte CHECK en base également : `NOT (distributor_id IS NOT NULL AND broker_id IS NOT NULL)`. La liste front de `distributor` = clients ayant au moins une catégorie de type `DISTRIBUTEUR` ; idem pour `broker` avec `COURTIER`. +- **RG-1.03** : Les champs `distributor` et `broker` sont **mutuellement exclusifs** (au plus une seule des deux est renseignée). Tentative d'envoyer les deux → 422. Contrainte CHECK en base également : `NOT (distributor_id IS NOT NULL AND broker_id IS NOT NULL)`. Un `distributor` référencé doit porter une **`Category` de code `DISTRIBUTEUR`** ; un `broker` une **`Category` de code `COURTIER`** — sinon 422. _(Refonte ERP-78 : le filtrage se fait sur le `code` de la `Category`, plus sur le type — `ClientProcessor::hasCategoryCode`.)_ La liste front de `distributor` = clients ayant une catégorie de code `DISTRIBUTEUR`, via `GET /api/clients?categoryCode=DISTRIBUTEUR` ; idem `broker` avec `COURTIER`. ### Onglet Information @@ -946,9 +952,9 @@ Cf. § 2.6. Pattern Shared standard. - **RG-1.28** : Si un PATCH contient des champs de **plusieurs groupes** de sérialisation et que l'utilisateur **n'a pas toutes les permissions** correspondantes, le `ClientProcessor` renvoie **403 Forbidden sur l'ensemble du payload** (mode strict — pas de filtrage silencieux). Le front est responsable de ne JAMAIS envoyer de champs hors-permission (les onglets masqués via `usePermissions()` ne génèrent pas de payload). Cette règle protège contre les appels API directs malveillants. Exemple : un Bureau qui envoie `{ "companyName": "...", "siren": "..." }` → 403, le message d'erreur précise « Champ `siren` requiert la permission `commercial.clients.accounting.manage` ». -### Catégorie sur ClientAddress (filtrage par type) +### Catégorie sur ClientAddress (filtrage par code) -- **RG-1.29** : Le `` Catégorie de l'onglet Adresse n'expose **que** les `Category` dont `categoryType.code IN ('SECTEUR', 'AUTRE')`. Les types `DISTRIBUTEUR` et `COURTIER` qualifient une **relation entre clients** (cf. RG-1.03) et n'ont pas de sens sur une adresse physique. Implémentation : `ClientAddressProvider` filtre côté serveur via paramètre de requête à l'endpoint `GET /api/categories?categoryType.code[]=SECTEUR&categoryType.code[]=AUTRE` (SearchFilter API Platform). Côté validation du POST/PATCH : si l'utilisateur tente de poster une catégorie de type DISTRIBUTEUR ou COURTIER sur une adresse → **422** avec violation `categories: "Type de catégorie non autorisé sur une adresse."`. +- **RG-1.29** _(refonte ERP-78)_ : sur une adresse, les `Category` de **code `DISTRIBUTEUR` ou `COURTIER`** sont **interdites** — elles qualifient une **relation entre clients** (cf. RG-1.03) et n'ont pas de sens sur une adresse physique. **Toute autre** catégorie (type unique CLIENT) est autorisée. Validation du POST/PATCH : poster une catégorie de code DISTRIBUTEUR/COURTIER sur une adresse → **422** avec violation `categories: "Type de catégorie non autorisé sur une adresse."` (`ClientAddress::validateCategoryCodes`). Côté front, le `` Catégorie de l'onglet Adresse exclut les `Category` de code `DISTRIBUTEUR` / `COURTIER` (le `code` est exposé en lecture sur `/api/categories`). ## 8. Tests à automatiser diff --git a/docs/specs/M1-clients/spec-front.md b/docs/specs/M1-clients/spec-front.md index 4b627e0..142f245 100644 --- a/docs/specs/M1-clients/spec-front.md +++ b/docs/specs/M1-clients/spec-front.md @@ -96,8 +96,8 @@ C'est le 1er bloc à remplir. Sans validation de ce formulaire, les onglets ne s | **Téléphone secondaire** | `` (masque tel) | Non | Apparaît au clic sur le bouton `+` (RG-1.02). Max 2 — bouton `+` disparaît une fois rempli. | | **Email** | `` type email | Oui | RG-1.21 (lowercase) | | **Distributeur / Courtier** | `` | Non | Valeurs : `Dépend du distributeur` / `Dépend du courtier` / `Aucun`. RG-1.03 conditionne les 2 champs suivants. | -| **Nom du distributeur** | `` | Conditionnel | Visible si « Dépend du distributeur ». Liste = clients ayant ≥ 1 catégorie de type `DISTRIBUTEUR`. RG-1.03. | -| **Nom du courtier** | `` | Conditionnel | Visible si « Dépend du courtier ». Liste = clients ayant ≥ 1 catégorie de type `COURTIER`. RG-1.03. | +| **Nom du distributeur** | `` | Conditionnel | Visible si « Dépend du distributeur ». Liste = clients ayant ≥ 1 catégorie de **code** `DISTRIBUTEUR` (ERP-78), via `GET /api/clients?categoryCode=DISTRIBUTEUR`. RG-1.03. | +| **Nom du courtier** | `` | Conditionnel | Visible si « Dépend du courtier ». Liste = clients ayant ≥ 1 catégorie de **code** `COURTIER` (ERP-78), via `GET /api/clients?categoryCode=COURTIER`. RG-1.03. | | **Prestation de triage** | `` | Non | — | **Action** : « Valider » (``) → POST `/api/clients` ([`spec-back.md` § 4.3](./spec-back.md)). Si succès, on passe automatiquement à l'onglet « Information ». @@ -150,7 +150,7 @@ Saisir une ou plusieurs adresses du client, rattachées à un ou plusieurs sites | **Prospect** | `` | Non | RG-1.06 — masque Adresse de livraison + Facturation si coché | | **Adresse de livraison** | `` | Non | RG-1.07 — masque Prospect si coché | | **Facturation** | `` | Non | RG-1.08 — masque Prospect si coché ; affiche le champ Email (RG-1.11) | -| **Catégorie** | `` (multi) | Oui | Liste des `Category` de **type SECTEUR + AUTRE** uniquement (cf. décision Q5 — DISTRIBUTEUR et COURTIER qualifient une relation entre clients, pas un lieu) | +| **Catégorie** | `` (multi) | Oui | Liste des `Category` **hors codes `DISTRIBUTEUR` / `COURTIER`** (ERP-78 — ces codes qualifient une relation entre clients, pas un lieu). Le front exclut ces 2 codes du select (le `code` est exposé en lecture sur `/api/categories`). | | **Pays** | `` | Oui | Préremplie « France » | | **Code postal** | `` (masque numérique) | Oui | RG-1.09 — déclenche autocomplete ville via BAN | | **Ville** | `` | Oui | RG-1.09 — alimentée par api-adresse.data.gouv.fr suivant le CP | @@ -268,7 +268,7 @@ Le composant `Code postal` + `Ville` + `Adresse` est branché sur **api-adresse. | # | Zone d'ombre V0 | Résolution (cf. `spec-back.md`) | |---|---|---| -| 1 | Catégorie en multi-select non clarifiée (1 ou n par client) | **M2M `client_category`** validée. CategoryType seedé avec `DISTRIBUTEUR`, `COURTIER`, `SECTEUR`, `AUTRE` (HP-3 du M0 levé). | +| 1 | Catégorie en multi-select non clarifiée (1 ou n par client) | **M2M `client_category`** validée. Refonte ERP-78 : type unique `CLIENT` ; `Distributeur`/`Courtier`/`Secteur`/`Autre` (+ catégories métier) sont des `Category` portant un `code` stable (HP-3 du M0 levé). | | 2 | Distributeur / Courtier : liste de quoi ? | **Auto-référence Client** via 2 FK nullables `distributor_id` et `broker_id` (cf. RG-1.03). Une seule des deux est remplie à la fois. | | 3 | Onglet « Comptabilité » : qui édite ? | **Admin et Compta** peuvent éditer l'onglet Comptabilité (`commercial.clients.accounting.manage`). Bureau / Commerciale ne voient pas l'onglet. Compta ne peut pas créer un client (pas de `manage` global), mais peut éditer la partie comptable d'un client existant. | | 4 | Workflow par onglet | **Sauvegarde incrémentale**. POST formulaire principal crée le `Client` (status implicite « actif »). Chaque onglet validé = PATCH partiel par groupe de sérialisation dédié. Pas d'état « draft ». |