From 00bd02858c43f42826fe973b20f6dc0856ffefeb Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Tue, 2 Jun 2026 08:00:42 +0000 Subject: [PATCH] =?UTF-8?q?[ERP-78]=20Refonte=20taxonomie=20Cat=C3=A9gorie?= =?UTF-8?q?s=20:=20type=20unique=20CLIENT=20+=20Category.code=20+=20RG-1.0?= =?UTF-8?q?3/1.29=20par=20code=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refonte de la taxonomie Catégories (décision produit 01/06) : le modèle est inversé. ## Modèle - **UN SEUL `category_type` : CLIENT**. `Distributeur` / `Courtier` / `Secteur` / `Autre` (+ catégories métier) deviennent des `Category` rattachées à CLIENT. - Filtrage métier sur un **`code` stable porté par `Category`** (NOT NULL, unique partiel `uq_category_code`), slug MAJUSCULE auto-généré du nom (`CategoryCodeGenerator`), figé à la création, exposé en **lecture seule**. ## Contenu - **M0** : `Category.code` (entité + migration corrective `Version20260602100000` au namespace racine + `COMMENT ON COLUMN` + catalogue + ligne `test-db-setup`). Retrofit `Version20260528120000` rendu conscient des colonnes. - **Seed** : type unique CLIENT, catégories codées (`Distributeur→DISTRIBUTEUR`, etc.), anciens types supprimés. Fixtures `CategoryType`/`Category`/`Client` alignées. - **RG-1.03** : `ClientProcessor::hasCategoryCode` — un distributor/broker doit porter la `Category` de code `DISTRIBUTEUR`/`COURTIER`. Filtre liste/export `categoryType` → `categoryCode`. - **RG-1.29** : `ClientAddress::validateCategoryCodes` — denylist des codes `DISTRIBUTEUR`/`COURTIER` sur une adresse (toute autre catégorie autorisée). - **Specs** M0/M1 (back + front) amendées. ## Tests `make php-cs-fixer-allow-risky` OK ; `make db-reset` OK (type unique CLIENT + 11 catégories codées, idempotent) ; `make test` **443 vert**. Ajouts : RG-1.03 courtier, génération/unicité/lecture-seule du code (`CategoryCodeTest`). ## Coordination - #76 (#500) : RG-1.29 réécrite ici sur le nouveau modèle ; #76 ne garde que le gap 2 (mapping CHECK adresse → 422), indépendant de la taxonomie. - ERP-68 (#486) : fixtures démo (déjà mergées via #41) adaptées ici au type unique CLIENT + codes. - Front #480–483 : selects Catégorie / distributeur / courtier basés sur le `code` (`?categoryCode=`), plus le type. Décisions actées avec le PO : `code` NOT NULL auto-généré (slug) ; périmètre complet (réécriture RG + fixtures déjà mergées). --------- Co-authored-by: Matthieu Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/42 Co-authored-by: THOLOT DECHENE Matthieu Co-committed-by: THOLOT DECHENE Matthieu --- docs/specs/M0-categories/spec-back.md | 2 + docs/specs/M1-clients/spec-back.md | 40 ++-- docs/specs/M1-clients/spec-front.md | 8 +- makefile | 3 + migrations/Version20260528120000.php | 46 +++-- migrations/Version20260602100000.php | 189 ++++++++++++++++++ .../Service/CategoryCodeGenerator.php | 77 +++++++ src/Module/Catalog/Domain/Entity/Category.php | 34 +++- .../CategoryRepositoryInterface.php | 7 + .../State/Processor/CategoryProcessor.php | 21 +- .../DataFixtures/CategoryFixtures.php | 109 +++++----- .../DataFixtures/CategoryTypeFixtures.php | 29 ++- .../Doctrine/DoctrineCategoryRepository.php | 17 ++ .../Domain/Entity/ClientAddress.php | 27 ++- .../Repository/ClientRepositoryInterface.php | 7 +- .../State/Processor/ClientProcessor.php | 17 +- .../State/Provider/ClientProvider.php | 6 +- .../Controller/ClientExportController.php | 4 +- .../DataFixtures/ClientFixtures.php | 16 +- .../Doctrine/DoctrineClientRepository.php | 21 +- .../Domain/Contract/CategoryInterface.php | 22 +- .../Database/CategoryCodeSql.php | 60 ++++++ .../Database/ColumnCommentsCatalog.php | 1 + .../Api/AbstractCatalogApiTestCase.php | 3 + .../Catalog/Api/CategoryCodeSqlSlugTest.php | 73 +++++++ tests/Module/Catalog/Api/CategoryCodeTest.php | 90 +++++++++ .../Api/AbstractCommercialApiTestCase.php | 74 +++++-- .../Commercial/Api/ClientAddressTest.php | 4 +- tests/Module/Commercial/Api/ClientApiTest.php | 43 ++++ .../Api/ClientExportControllerTest.php | 4 +- 30 files changed, 866 insertions(+), 188 deletions(-) create mode 100644 migrations/Version20260602100000.php create mode 100644 src/Module/Catalog/Application/Service/CategoryCodeGenerator.php create mode 100644 src/Shared/Infrastructure/Database/CategoryCodeSql.php create mode 100644 tests/Module/Catalog/Api/CategoryCodeSqlSlugTest.php create mode 100644 tests/Module/Catalog/Api/CategoryCodeTest.php 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..5cafdf9 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 @@ -742,7 +748,7 @@ class Client implements TimestampableInterface, BlamableInterface - **Security** : `is_granted('commercial.clients.view')` - **Query params** : - `includeArchived=true|false` (default `false`) - - `categoryType=` (filtre par type de catégorie via `SearchFilter`) + - `categoryCode=` (filtre les clients ayant ≥ 1 `Category` de ce code stable — ERP-78 ; ex. `DISTRIBUTEUR`, `COURTIER`) - `search=` (recherche fuzzy sur companyName + lastName + email) - **Tri par défaut** : `companyName ASC` - **Pagination** : front via `` (volumétrie cible faible). Pas de pagination serveur au M1. @@ -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 @@ -957,7 +963,7 @@ Cf. § 2.6. Pattern Shared standard. - [ ] **RG-1.01** : POST sans firstName ni lastName → 422 - [ ] **RG-1.02** : POST avec phoneSecondary rempli → persistance OK ; PATCH ajoutant un 3e téléphone → côté API, 2 colonnes uniquement (test que le payload ne peut pas créer un 3e) - [ ] **RG-1.03** : POST avec distributor ET broker → 422 ; POST distributor seul → 201 -- [ ] **RG-1.03** : POST distributor référençant un client SANS catégorie de type DISTRIBUTEUR → 422 (validation custom) +- [ ] **RG-1.03** : POST distributor référençant un client SANS catégorie de code DISTRIBUTEUR → 422 (validation custom `ClientProcessor::hasCategoryCode`) - [ ] **RG-1.04** : PATCH onglet Information par un user Commerciale avec champs incomplets → 422 ; même PATCH par Admin → 200 - [ ] **RG-1.05** : POST contact sans firstName ni lastName → 422 (BDD CHECK lève une exception) - [ ] **RG-1.06/07/08** : POST adresse avec isProspect=true ET isDelivery=true → 422 / CHECK 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 ». | diff --git a/makefile b/makefile index 6738ff5..9977884 100644 --- a/makefile +++ b/makefile @@ -208,6 +208,8 @@ migration-migrate: # exprimables via les attributs Doctrine ORM (fonctionnel + partiel), donc # ils disparaissent apres schema:update. On les recree par dbal:run-sql : # - `uq_category_name_type_active` (M0 Catalog) : tests RG-1.07. +# - `uq_category_code` (Catalog ERP-78) : unicite du code categorie parmi +# les actifs (slug du nom), pilote RG-1.03/1.29. # - `uq_client_company_name_active` (M1 Commercial) : unicite nom societe # parmi actifs non archives/non supprimes (RG-1.16), tests ERP-55. # Sans ces restores, les POST doublons remontent 201 au lieu de 409. @@ -225,6 +227,7 @@ test-db-setup: $(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions $(SYMFONY_CONSOLE) --env=test --no-interaction app:seed-rbac $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL" + $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" fixtures: diff --git a/migrations/Version20260528120000.php b/migrations/Version20260528120000.php index e82ad37..5c87a83 100644 --- a/migrations/Version20260528120000.php +++ b/migrations/Version20260528120000.php @@ -39,20 +39,40 @@ final class Version20260528120000 extends AbstractMigration public function up(Schema $schema): void { - // Ne commente que les tables deja presentes a ce stade de la chaine de - // migrations. Les modules crees plus tard (ex: M1 Commercial, 06-01) - // figurent desormais dans le catalogue partage mais leurs tables - // n'existent pas encore ici : elles posent leurs propres COMMENT dans - // leur migration dediee (regle ABSOLUE n°12). Garde-fou indispensable, - // sinon l'ajout d'un module au catalogue casse ce retrofit avec un - // "relation X does not exist". - $existingTables = array_values(array_filter( - array_keys(ColumnCommentsCatalog::comments()), - static fn (string $table): bool => $schema->hasTable($table), - )); + // Ne commente que les tables ET colonnes deja presentes a ce stade de la + // chaine de migrations. Les tables des modules crees plus tard (M1 + // Commercial, 06-01) ET les colonnes ajoutees ensuite sur une table + // existante (ex: category.code, ERP-78 06-02) figurent desormais dans le + // catalogue partage mais n'existent pas encore ici : elles posent leur + // propre COMMENT dans leur migration dediee (regle ABSOLUE n°12). Garde-fou + // indispensable (table + colonne), sinon enrichir le catalogue casse ce + // retrofit avec un "relation/column X does not exist". + foreach (ColumnCommentsCatalog::comments() as $table => $entries) { + if (!$schema->hasTable($table)) { + continue; + } - foreach (ColumnCommentsCatalog::toSqlStatements($existingTables) as $sql) { - $this->addSql($sql); + $dbTable = $schema->getTable($table); + $quotedTable = '"'.str_replace('"', '""', $table).'"'; + + foreach ($entries as $column => $description) { + if ('_table' === $column) { + $this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description)); + + continue; + } + + if (!$dbTable->hasColumn($column)) { + continue; + } + + $this->addSql(sprintf( + 'COMMENT ON COLUMN %s.%s IS $_$%s$_$', + $quotedTable, + '"'.str_replace('"', '""', $column).'"', + $description, + )); + } } } diff --git a/migrations/Version20260602100000.php b/migrations/Version20260602100000.php new file mode 100644 index 0000000..03d9521 --- /dev/null +++ b/migrations/Version20260602100000.php @@ -0,0 +1,189 @@ + elle s'executerait avant la creation + * des tables et le seed dont elle depend. Le namespace racine garantit l'ordre + * par timestamp. + * + * Idempotence : `ADD COLUMN IF NOT EXISTS`, `INSERT ... ON CONFLICT` / guards + * `NOT EXISTS`, `CREATE UNIQUE INDEX IF NOT EXISTS`. En prod la table `category` + * est vide (aucune fixture metier) : l'ajout de `code NOT NULL` est sur. En + * dev/test, le purger Doctrine vide `category`/`category_type` avant les + * fixtures, qui reproduisent le meme etat final (cf. CategoryTypeFixtures / + * CategoryFixtures). + */ +final class Version20260602100000 extends AbstractMigration +{ + /** + * Categories systeme reportees depuis les anciens types : nom => code. + * Le code est la cle metier stable (RG-1.03 / RG-1.29). + */ + private const array SYSTEM_CATEGORIES = [ + 'Distributeur' => 'DISTRIBUTEUR', + 'Courtier' => 'COURTIER', + 'Secteur' => 'SECTEUR', + 'Autre' => 'AUTRE', + ]; + + /** Anciens codes de `category_type` devenus inutiles. */ + private const array LEGACY_TYPE_CODES = ['DISTRIBUTEUR', 'COURTIER', 'SECTEUR', 'AUTRE']; + + public function getDescription(): string + { + return 'ERP-78 : Category.code + type unique CLIENT (categories Distributeur/Courtier/Secteur/Autre codees, anciens types supprimes).'; + } + + public function up(Schema $schema): void + { + // 1. Colonne `code` (nullable d'abord pour pouvoir backfiller, NOT NULL ensuite). + $this->addSql('ALTER TABLE category ADD COLUMN IF NOT EXISTS code VARCHAR(50) DEFAULT NULL'); + + // 2. Type unique CLIENT (idempotent via l'index unique uq_category_type_code). + $this->addSql(<<<'SQL' + INSERT INTO category_type (code, label) VALUES ('CLIENT', 'Client') + ON CONFLICT (code) DO NOTHING + SQL); + + // 3. Re-pointer toute categorie pre-existante (rattachee a un ancien type) + // vers le type CLIENT, en lui donnant un code derive du nom si absent. + // En prod la table est vide -> no-op ; defensif pour les envs qui + // auraient deja seede des categories sous les anciens types. Le slug + // SQL est le miroir EXACT de CategoryCodeGenerator::slugify (cf. + // CategoryCodeSql + CategoryCodeSqlSlugTest) : un nom accentue produit + // le meme code que la generation applicative (« Independant » -> + // INDEPENDANT, et non IND_PENDANT). + $this->addSql( + 'UPDATE category c ' + ."SET category_type_id = (SELECT id FROM category_type WHERE code = 'CLIENT'), " + .'code = COALESCE(c.code, '.CategoryCodeSql::slugExpression('c.name').') ' + .'WHERE c.category_type_id IN (SELECT id FROM category_type WHERE code IN (:legacyCodes))', + ['legacyCodes' => self::LEGACY_TYPE_CODES], + ['legacyCodes' => \Doctrine\DBAL\ArrayParameterType::STRING], + ); + + // 4. Creer les 4 categories systeme sous CLIENT (si leur code est libre + // parmi les actifs). created_at/updated_at NOT NULL -> now() ; le blame + // reste null (seed hors contexte HTTP, libelle « Systeme » cote front). + foreach (self::SYSTEM_CATEGORIES as $name => $code) { + $this->addSql(<<<'SQL' + INSERT INTO category (name, code, category_type_id, created_at, updated_at) + SELECT :name, :code, ct.id, NOW(), NOW() + FROM category_type ct + WHERE ct.code = 'CLIENT' + AND NOT EXISTS ( + SELECT 1 FROM category c WHERE c.code = :code AND c.deleted_at IS NULL + ) + SQL, ['name' => $name, 'code' => $code]); + } + + // 5. Backfill defensif : toute categorie encore sans code recoit un slug + // de son nom (garantit que le SET NOT NULL passe). Meme expression de + // slug fidele au generateur applicatif (CategoryCodeSql). + $this->addSql( + 'UPDATE category SET code = '.CategoryCodeSql::slugExpression('name').' WHERE code IS NULL', + ); + + // 6. Index unique partiel sur le code parmi les actifs (non exprimable en + // ORM : recree aussi dans `test-db-setup` apres schema:update). + $this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL'); + + // 7. Code desormais obligatoire. + $this->addSql('ALTER TABLE category ALTER COLUMN code SET NOT NULL'); + + // 8. Documentation SQL (regle ABSOLUE n°12). Dollar-quoting Postgres. + $this->addSql(<<<'SQL' + COMMENT ON COLUMN category.code IS $_$Code technique stable (slug MAJUSCULE du nom, <= 50) — unique parmi les actifs (uq_category_code). Fige a la creation. DISTRIBUTEUR/COURTIER pilotent RG-1.03/1.29.$_$ + SQL); + + // 9. Supprimer les anciens types devenus orphelins (aucune categorie ne + // les reference plus apres le re-pointage de l'etape 3). Le guard + // NOT EXISTS evite de casser sur la FK RESTRICT category.category_type_id. + $this->addSql(<<<'SQL' + DELETE FROM category_type + WHERE code IN (:legacyCodes) + AND NOT EXISTS ( + SELECT 1 FROM category c WHERE c.category_type_id = category_type.id + ) + SQL, ['legacyCodes' => self::LEGACY_TYPE_CODES], ['legacyCodes' => \Doctrine\DBAL\ArrayParameterType::STRING]); + + // 10. Realigner la doc SQL de client_address_category (migration mergee + // Version20260601000000, non editable) sur le nouveau modele RG-1.29. + $this->addSql(<<<'SQL' + COMMENT ON TABLE client_address_category IS $_$Jointure M2M client_address <-> category — codes DISTRIBUTEUR/COURTIER interdits sur une adresse (RG-1.29).$_$ + SQL); + $this->addSql(<<<'SQL' + COMMENT ON COLUMN client_address_category.category_id IS $_$FK -> category.id, ON DELETE RESTRICT — categorie d adresse (tout code sauf DISTRIBUTEUR/COURTIER, RG-1.29).$_$ + SQL); + } + + public function down(Schema $schema): void + { + // Best-effort : rollback du modele CLIENT vers les 4 anciens types. + // 1. Retirer l'index unique sur le code. + $this->addSql('DROP INDEX IF EXISTS uq_category_code'); + + // 2. Recreer les 4 anciens types. + $this->addSql(<<<'SQL' + INSERT INTO category_type (code, label) VALUES + ('DISTRIBUTEUR', 'Distributeur'), + ('COURTIER', 'Courtier'), + ('SECTEUR', 'Secteur'), + ('AUTRE', 'Autre') + ON CONFLICT (code) DO NOTHING + SQL); + + // 3. Re-pointer les categories systeme (par code) vers leur type d'origine. + // Codes inlines : constantes controlees (self::SYSTEM_CATEGORIES), pas + // d'entree utilisateur — evite le binding d'un parametre nomme repete. + foreach (self::SYSTEM_CATEGORIES as $name => $code) { + $this->addSql(sprintf( + "UPDATE category SET category_type_id = (SELECT id FROM category_type WHERE code = '%s') WHERE code = '%s'", + $code, + $code, + )); + } + + // 4. Supprimer le type CLIENT s'il ne reference plus aucune categorie. + $this->addSql(<<<'SQL' + DELETE FROM category_type + WHERE code = 'CLIENT' + AND NOT EXISTS ( + SELECT 1 FROM category c WHERE c.category_type_id = category_type.id + ) + SQL); + + // 5. Retirer la colonne code (les categories libres sans type d'origine + // restent sous CLIENT si encore presentes — rollback uniquement + // pertinent en prod ou seules les 4 categories systeme existent). + $this->addSql('ALTER TABLE category DROP COLUMN IF EXISTS code'); + } +} diff --git a/src/Module/Catalog/Application/Service/CategoryCodeGenerator.php b/src/Module/Catalog/Application/Service/CategoryCodeGenerator.php new file mode 100644 index 0000000..318f7c1 --- /dev/null +++ b/src/Module/Catalog/Application/Service/CategoryCodeGenerator.php @@ -0,0 +1,77 @@ + DISTRIBUTEUR + * - « Agro-alimentaire » -> AGRO_ALIMENTAIRE + * - « Transport/Logistique » -> TRANSPORT_LOGISTIQUE + * + * Le code est FIGE a la creation (jamais recalcule sur renommage) afin de rester + * une cle deterministe stable entre environnements (RG-1.03 / RG-1.29 cote M1). + * + * Unicite : l'index partiel `uq_category_code` (WHERE deleted_at IS NULL) impose + * l'unicite parmi les categories actives. Deux noms distincts peuvent produire + * le meme slug (« Agro alimentaire » / « Agro-alimentaire ») : on suffixe alors + * le code par `_2`, `_3`... jusqu'a obtenir un code libre. + */ +final class CategoryCodeGenerator +{ + /** Longueur maximale de la colonne `category.code`. */ + private const int MAX_LENGTH = 50; + + private readonly AsciiSlugger $slugger; + + public function __construct( + private readonly CategoryRepositoryInterface $categoryRepository, + ) { + $this->slugger = new AsciiSlugger(); + } + + /** + * Slug brut (sans garantie d'unicite) — utile pour les seeds deterministes. + */ + public function slugify(string $name): string + { + $slug = $this->slugger->slug($name, '_')->upper()->toString(); + + // Borne a la longueur colonne, puis retire un eventuel `_` terminal + // introduit par la troncature. + $slug = substr($slug, 0, self::MAX_LENGTH); + $slug = trim($slug, '_'); + + // Garde-fou : un nom uniquement compose de caracteres non alphanumeriques + // (theorique, le nom est NotBlank + Length>=2) donnerait un slug vide. + return '' === $slug ? 'CATEGORY' : $slug; + } + + /** + * Code unique parmi les categories actives : slug du nom, suffixe `_N` en + * cas de collision. `$excludeId` ignore la categorie courante (PATCH). + */ + public function generateUnique(string $name, ?int $excludeId = null): string + { + $base = $this->slugify($name); + $candidate = $base; + $suffix = 2; + + while ($this->categoryRepository->existsActiveByCode($candidate, $excludeId)) { + $suffixStr = '_'.$suffix; + // Retronque la base pour que `base + suffixe` tienne dans 50 caracteres. + $candidate = substr($base, 0, self::MAX_LENGTH - strlen($suffixStr)).$suffixStr; + ++$suffix; + } + + return $candidate; + } +} diff --git a/src/Module/Catalog/Domain/Entity/Category.php b/src/Module/Catalog/Domain/Entity/Category.php index 5040e02..3c55a94 100644 --- a/src/Module/Catalog/Domain/Entity/Category.php +++ b/src/Module/Catalog/Domain/Entity/Category.php @@ -74,10 +74,11 @@ use Symfony\Component\Validator\Constraints as Assert; )] #[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)] #[ORM\Table(name: 'category')] -// Index nommes pour matcher la migration (cf. Role/Permission/Site). L'index -// unique partiel `uq_category_name_type_active` (LOWER(name), category_type_id -// WHERE deleted_at IS NULL) reste possede par la seule migration : Doctrine ORM -// ne sait pas exprimer un index fonctionnel + partiel via attribut. +// Index nommes pour matcher la migration (cf. Role/Permission/Site). Les index +// uniques partiels `uq_category_name_type_active` (LOWER(name), category_type_id +// WHERE deleted_at IS NULL) et `uq_category_code` (code WHERE deleted_at IS NULL) +// restent possedes par la seule migration : Doctrine ORM ne sait pas exprimer un +// index partiel via attribut. #[ORM\Index(name: 'idx_category_deleted_at', columns: ['deleted_at'])] #[ORM\Index(name: 'idx_category_type_id', columns: ['category_type_id'])] #[ORM\Index(name: 'idx_category_created_by', columns: ['created_by'])] @@ -109,6 +110,16 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt #[Groups(['category:read', 'category:write'])] private ?string $name = null; + // Code technique stable (slug MAJUSCULE du nom) — NOT NULL + unique parmi les + // actifs (index partiel `uq_category_code` possede par la migration). Genere + // par le CategoryProcessor a la creation puis fige (jamais recalcule sur + // renommage) : sert de cle metier deterministe (RG-1.03 / RG-1.29). Lecture + // seule cote API (hors groupe category:write) : le front filtre dessus mais + // ne le saisit pas. + #[ORM\Column(length: 50)] + #[Groups(['category:read'])] + private ?string $code = null; + #[ORM\ManyToOne(targetEntity: CategoryType::class)] #[ORM\JoinColumn(name: 'category_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')] #[Assert\NotNull(message: 'Type de catégorie obligatoire.')] @@ -141,6 +152,21 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt return $this; } + /** + * Implemente CategoryInterface : code technique stable de la categorie. + */ + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(string $code): static + { + $this->code = $code; + + return $this; + } + public function getCategoryType(): ?CategoryType { return $this->categoryType; diff --git a/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php b/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php index 838c75d..5a43d5c 100644 --- a/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php +++ b/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php @@ -13,6 +13,13 @@ interface CategoryRepositoryInterface public function save(Category $category): void; + /** + * Vrai si une categorie active (deleted_at IS NULL) porte deja ce code. + * `$excludeId` exclut une categorie precise du test (cas PATCH). Sert a + * garantir l'unicite du code generee par le CategoryCodeGenerator (ERP-78). + */ + public function existsActiveByCode(string $code, ?int $excludeId = null): bool; + /** * Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut. * - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08) diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php index 6daa337..c97ef7c 100644 --- a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php +++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php @@ -7,6 +7,7 @@ namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor; use ApiPlatform\Metadata\DeleteOperationInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; +use App\Module\Catalog\Application\Service\CategoryCodeGenerator; use App\Module\Catalog\Domain\Entity\Category; use DateTimeImmutable; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; @@ -16,10 +17,13 @@ use Symfony\Component\HttpKernel\Exception\HttpException; /** * Processor Category : applique les regles de gestion en ecriture. * - * - POST / PATCH : trim du nom (RG-1.03) puis delegation au persist_processor - * Doctrine ORM. Toute UniqueConstraintViolationException remontee par Postgres - * (collision sur l'index partiel uq_category_name_type_active) est traduite - * en HTTP 409 avec le message attendu par la spec (RG-1.07). + * - POST / PATCH : trim du nom (RG-1.03) ; a la CREATION, generation du `code` + * technique stable (slug MAJUSCULE du nom, unique parmi les actifs — ERP-78) + * via CategoryCodeGenerator ; puis delegation au persist_processor Doctrine + * ORM. Le code est FIGE a la creation (jamais recalcule sur PATCH). Toute + * UniqueConstraintViolationException remontee par Postgres (collision sur + * l'index partiel uq_category_name_type_active) est traduite en HTTP 409 avec + * le message attendu par la spec (RG-1.07). * - DELETE : soft delete (RG-1.12). On NE delegue PAS au remove_processor ; * on pose deletedAt = now() puis on delegue au persist_processor pour que * le UPDATE Doctrine parte et que le TimestampableBlamableSubscriber mette @@ -32,6 +36,7 @@ final class CategoryProcessor implements ProcessorInterface public function __construct( #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] private readonly ProcessorInterface $persistProcessor, + private readonly CategoryCodeGenerator $codeGenerator, ) {} public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed @@ -62,6 +67,14 @@ final class CategoryProcessor implements ProcessorInterface $data->setName(trim($data->getName())); } + // ERP-78 : le code est genere a la CREATION puis fige. On le (re)genere + // uniquement s'il est absent (POST, ou entite seedee sans code) — un PATCH + // sur une categorie existante conserve son code. Genere depuis le nom + // (NotBlank, deja trimme), unique parmi les actifs. + if (null === $data->getCode() && null !== $data->getName()) { + $data->setCode($this->codeGenerator->generateUnique($data->getName(), $data->getId())); + } + try { return $this->persistProcessor->process($data, $operation, $uriVariables, $context); } catch (UniqueConstraintViolationException $e) { diff --git a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php index 11f5658..d8247a4 100644 --- a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php +++ b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php @@ -14,19 +14,19 @@ use RuntimeException; use Symfony\Component\DependencyInjection\Attribute\Autowire; /** - * Fixtures dev/test du module Catalog : ~12 categories de demonstration reparties - * sur les 4 types metier (DISTRIBUTEUR / COURTIER / SECTEUR / AUTRE). Alimente le - * repertoire clients (ClientFixtures, module Commercial) avec des donnees - * realistes couvrant les categorisations RG-1.03 (DISTRIBUTEUR/COURTIER) et - * RG-1.29 (SECTEUR/AUTRE sur adresse). + * Fixtures dev/test du module Catalog : ~11 categories de demonstration, toutes + * rattachees au type unique CLIENT (refonte taxonomie ERP-78). Chaque categorie + * porte un `code` stable. Alimente le repertoire clients (ClientFixtures, module + * Commercial) avec des donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / + * COURTIER) et RG-1.29 (codes interdits sur adresse). * - * Depend de CategoryTypeFixtures : les 4 CategoryType doivent etre seedes avant - * de pouvoir y rattacher des Category. + * Depend de CategoryTypeFixtures : le type CLIENT doit etre seede avant de + * pouvoir y rattacher des Category. * - * Idempotence : lookup par (name, categoryType) parmi les categories non - * supprimees (deletedAt null), coherent avec l'index unique partiel - * uq_category_name_type_active (LOWER(name), category_type_id WHERE deleted_at - * IS NULL). Rejouable sans doublon meme si le purger Doctrine est desactive. + * Idempotence : lookup par `code` parmi les categories non supprimees (deletedAt + * null), coherent avec l'index unique partiel uq_category_code (code WHERE + * deleted_at IS NULL). Rejouable sans doublon meme si le purger Doctrine est + * desactive. * * Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by * restent null (« Systeme » cote front), c'est attendu. @@ -34,39 +34,33 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire; * Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`, * la fixture ne charge rien : les tests seedent et nettoient leurs propres * categories (prefixe dedie) et comptent sur une table `category` vierge — y - * injecter 12 categories de demo casserait comptages et cleanups FK + * injecter des categories de demo casserait comptages et cleanups FK * (client_category). Cf. ClientFixtures (meme garde-fou). */ class CategoryFixtures extends Fixture implements DependentFixtureInterface { + /** Code du type unique (cf. CategoryTypeFixtures, migration ERP-78). */ + private const string CLIENT_TYPE_CODE = 'CLIENT'; + /** - * Source unique des categories de demonstration : code de type metier => - * liste de noms. Les noms sont stockes tels quels (l'unicite est - * case-insensitive cote index). + * Source unique des categories de demonstration : nom => code stable. Les 4 + * premieres (Distributeur / Courtier / Secteur / Autre) sont les categories + * « systeme » reportees des anciens types ; leurs codes pilotent les RG. * - * @var array> + * @var array */ private const CATEGORIES = [ - 'SECTEUR' => [ - 'BTP', - 'Industrie', - 'Agro-alimentaire', - 'Transport/Logistique', - 'Services', - ], - 'DISTRIBUTEUR' => [ - 'Distributeur Grand Sud-Ouest', - 'Distributeur National Premium', - 'Grossiste régional', - ], - 'COURTIER' => [ - 'Cabinet de courtage Léonard', - 'Cabinet de courtage Bernard', - ], - 'AUTRE' => [ - 'Indépendant', - 'Association', - ], + 'Distributeur' => 'DISTRIBUTEUR', + 'Courtier' => 'COURTIER', + 'Secteur' => 'SECTEUR', + 'Autre' => 'AUTRE', + 'BTP' => 'BTP', + 'Industrie' => 'INDUSTRIE', + 'Agro-alimentaire' => 'AGRO_ALIMENTAIRE', + 'Transport/Logistique' => 'TRANSPORT_LOGISTIQUE', + 'Services' => 'SERVICES', + 'Association' => 'ASSOCIATION', + 'Indépendant' => 'INDEPENDANT', ]; public function __construct( @@ -90,41 +84,39 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface return; } - // Index des types metier par code (CategoryTypeFixtures les a seedes). - $typesByCode = []; + $clientType = null; foreach ($this->categoryTypeRepository->findAllOrderedByLabel() as $type) { - $typesByCode[$type->getCode()] = $type; + if (self::CLIENT_TYPE_CODE === $type->getCode()) { + $clientType = $type; + + break; + } } - foreach (self::CATEGORIES as $typeCode => $names) { - $type = $typesByCode[$typeCode] ?? null; - if (!$type instanceof CategoryType) { - // Misconfiguration : CategoryTypeFixtures n'a pas tourne avant. - throw new RuntimeException(sprintf( - 'CategoryTypeFixtures doit avoir seede le type "%s" avant CategoryFixtures.', - $typeCode, - )); - } + if (!$clientType instanceof CategoryType) { + // Misconfiguration : CategoryTypeFixtures n'a pas tourne avant. + throw new RuntimeException( + 'CategoryTypeFixtures doit avoir seede le type "CLIENT" avant CategoryFixtures.', + ); + } - foreach ($names as $name) { - $this->ensureCategory($manager, $name, $type); - } + foreach (self::CATEGORIES as $name => $code) { + $this->ensureCategory($manager, $name, $code, $clientType); } $manager->flush(); } /** - * Cree la categorie (name, type) si elle n'existe pas encore parmi les - * categories actives, sinon la laisse en place. Lookup aligne sur l'index - * unique partiel (nom + type, hors soft-deleted). + * Cree la categorie (name, code) sous le type CLIENT si son code n'existe pas + * encore parmi les categories actives, sinon la laisse en place. Lookup + * aligne sur l'index unique partiel uq_category_code. */ - private function ensureCategory(ObjectManager $manager, string $name, CategoryType $type): void + private function ensureCategory(ObjectManager $manager, string $name, string $code, CategoryType $type): void { $existing = $manager->getRepository(Category::class)->findOneBy([ - 'name' => $name, - 'categoryType' => $type, - 'deletedAt' => null, + 'code' => $code, + 'deletedAt' => null, ]); if (null !== $existing) { @@ -133,6 +125,7 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface $category = new Category(); $category->setName($name); + $category->setCode($code); $category->setCategoryType($type); $manager->persist($category); } diff --git a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php index 43b7686..2a2d5b4 100644 --- a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php +++ b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php @@ -10,17 +10,19 @@ use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; /** - * Fixtures du module Catalog : seed des types de categorie metier (M1). + * Fixtures du module Catalog : seed du type de categorie (M1). * - * La table `category_type` est creee vide au M0 ; le M1 la peuple avec les 4 - * types DISTRIBUTEUR / COURTIER / SECTEUR / AUTRE (cf. spec M1 § 3.3). + * Refonte taxonomie ERP-78 : le modele n'a plus qu'UN SEUL `category_type`, + * CLIENT (code CLIENT, label « Client »). Distributeur / Courtier / Secteur / + * Autre (et les categories metier fines) sont desormais des `Category` codees + * rattachees a ce type (cf. CategoryFixtures + migration Version20260602100000). * - * Pourquoi une fixture EN PLUS du seed de la migration (Version20260601000000) : - * `category_type` est une entite managee par l ORM, donc le purger Doctrine la - * vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les 4 types - * seedes par la migration disparaitraient apres `make db-reset` / setup de test. - * Le seed migration couvre la prod (ou les fixtures ne tournent pas) ; cette - * fixture re-aligne dev et test. Les deux chemins produisent un etat identique. + * Pourquoi une fixture EN PLUS du seed de la migration : `category_type` est une + * entite managee par l ORM, donc le purger Doctrine la vide avant chaque + * `doctrine:fixtures:load`. Sans cette fixture, le type CLIENT seede par la + * migration disparaitrait apres `make db-reset` / setup de test. Le seed + * migration couvre la prod (ou les fixtures ne tournent pas) ; cette fixture + * re-aligne dev et test. Les deux chemins produisent un etat identique. * * Idempotence : lookup par `code` parmi les types existants avant insertion, * sur le modele d AppFixtures::ensureSystemRole. Rejouable sans doublon meme @@ -29,14 +31,11 @@ use Doctrine\Persistence\ObjectManager; class CategoryTypeFixtures extends Fixture { /** - * Source unique des 4 types metier : code technique => libelle FR. - * Doit rester aligne sur le seed de la migration Version20260601000000. + * Source unique du type : code technique => libelle FR. Doit rester aligne + * sur le seed de la migration Version20260602100000 (type unique CLIENT). */ private const TYPES = [ - 'DISTRIBUTEUR' => 'Distributeur', - 'COURTIER' => 'Courtier', - 'SECTEUR' => 'Secteur', - 'AUTRE' => 'Autre', + 'CLIENT' => 'Client', ]; public function __construct( diff --git a/src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryRepository.php b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryRepository.php index 68612b2..aad21d5 100644 --- a/src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryRepository.php +++ b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryRepository.php @@ -31,6 +31,23 @@ class DoctrineCategoryRepository extends ServiceEntityRepository implements Cate $this->getEntityManager()->flush(); } + public function existsActiveByCode(string $code, ?int $excludeId = null): bool + { + $qb = $this->createQueryBuilder('c') + ->select('1') + ->andWhere('c.code = :code') + ->andWhere('c.deletedAt IS NULL') + ->setParameter('code', $code) + ->setMaxResults(1) + ; + + if (null !== $excludeId) { + $qb->andWhere('c.id != :excludeId')->setParameter('excludeId', $excludeId); + } + + return [] !== $qb->getQuery()->getResult(); + } + public function createListQueryBuilder(bool $includeDeleted = false): QueryBuilder { $qb = $this->createQueryBuilder('c') diff --git a/src/Module/Commercial/Domain/Entity/ClientAddress.php b/src/Module/Commercial/Domain/Entity/ClientAddress.php index 148a0ac..bfceeac 100644 --- a/src/Module/Commercial/Domain/Entity/ClientAddress.php +++ b/src/Module/Commercial/Domain/Entity/ClientAddress.php @@ -39,7 +39,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; * - sites : SiteInterface (module Sites) via resolve_target_entities * - contacts : ClientContact (meme module) * - categories : CategoryInterface (module Catalog) via resolve_target_entities - * — limitees aux types SECTEUR/AUTRE (RG-1.29, validateCategoryTypes, ERP-76) + * — codes DISTRIBUTEUR/COURTIER interdits (RG-1.29, validateCategoryCodes, ERP-78) * * Audite (#[Auditable]) + Timestampable/Blamable. * @@ -87,8 +87,12 @@ class ClientAddress implements TimestampableInterface, BlamableInterface { use TimestampableBlamableTrait; - /** RG-1.29 : seuls ces types de categorie qualifient une adresse physique. */ - private const array ALLOWED_CATEGORY_TYPES = ['SECTEUR', 'AUTRE']; + /** + * RG-1.29 (ERP-78) : ces codes de categorie decrivent une relation entre + * clients (distributeur / courtier) et n'ont pas de sens sur une adresse. + * Toute autre categorie du type CLIENT est autorisee. + */ + private const array FORBIDDEN_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER']; #[ORM\Id] #[ORM\GeneratedValue] @@ -165,7 +169,7 @@ class ClientAddress implements TimestampableInterface, BlamableInterface #[Groups(['client_address:read', 'client_address:write'])] private Collection $contacts; - // RG-1.29 : categories de type SECTEUR/AUTRE uniquement (validateCategoryTypes). + // RG-1.29 : categories de code DISTRIBUTEUR/COURTIER interdites (validateCategoryCodes). /** @var Collection */ #[ORM\ManyToMany(targetEntity: CategoryInterface::class)] #[ORM\JoinTable(name: 'client_address_category')] @@ -232,18 +236,19 @@ class ClientAddress implements TimestampableInterface, BlamableInterface } /** - * RG-1.29 : seules les categories de type SECTEUR / AUTRE qualifient une - * adresse physique. Les types DISTRIBUTEUR / COURTIER decrivent une relation - * entre clients (RG-1.03) et n'ont pas de sens sur une adresse -> 422 avec - * violation sur le champ `categories`. S'appuie sur - * CategoryInterface::getCategoryTypeCode() (pas d'import du module Catalog). + * RG-1.29 (ERP-78) : une adresse interdit les categories de code + * DISTRIBUTEUR / COURTIER — elles decrivent une relation entre clients + * (RG-1.03) et n'ont pas de sens sur une adresse physique -> 422 avec + * violation sur le champ `categories`. Toute autre categorie (type unique + * CLIENT) est acceptee. S'appuie sur CategoryInterface::getCode() (pas + * d'import du module Catalog — regle ABSOLUE n°1). */ #[Assert\Callback] - public function validateCategoryTypes(ExecutionContextInterface $context): void + public function validateCategoryCodes(ExecutionContextInterface $context): void { foreach ($this->categories as $category) { if ($category instanceof CategoryInterface - && !in_array($category->getCategoryTypeCode(), self::ALLOWED_CATEGORY_TYPES, true)) { + && in_array($category->getCode(), self::FORBIDDEN_CATEGORY_CODES, true)) { $context->buildViolation('Type de catégorie non autorisé sur une adresse.') ->atPath('categories') ->addViolation() diff --git a/src/Module/Commercial/Domain/Repository/ClientRepositoryInterface.php b/src/Module/Commercial/Domain/Repository/ClientRepositoryInterface.php index a5c43d7..0f6041f 100644 --- a/src/Module/Commercial/Domain/Repository/ClientRepositoryInterface.php +++ b/src/Module/Commercial/Domain/Repository/ClientRepositoryInterface.php @@ -20,8 +20,9 @@ interface ClientRepositoryInterface * - Tri par defaut : companyName ASC (RG-1.26). * - $search : recherche fuzzy insensible a la casse sur companyName + * lastName + email (metacaracteres LIKE echappes). Ignore si null/vide. - * - $categoryType : restreint aux clients possedant au moins une categorie - * du type donne (code). Ignore si null/vide. + * - $categoryCode : restreint aux clients possedant au moins une categorie + * du code donne (ERP-78 : filtrage par code de Category, plus par type). + * Ignore si null/vide. * * Filtrage centralise ICI (et non dans les providers/controllers) pour que * la liste paginee (ClientProvider) et l'export (ClientExportController) @@ -30,6 +31,6 @@ interface ClientRepositoryInterface public function createListQueryBuilder( bool $includeArchived = false, ?string $search = null, - ?string $categoryType = null, + ?string $categoryCode = null, ): QueryBuilder; } diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php index f0c5c90..fa1f6c8 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php @@ -457,8 +457,9 @@ final class ClientProcessor implements ProcessorInterface /** * RG-1.03 : distributor et broker mutuellement exclusifs ; un distributor - * doit referencer un client de categorie DISTRIBUTEUR (idem broker -> - * COURTIER). + * doit referencer un client portant la categorie de code DISTRIBUTEUR (idem + * broker -> COURTIER). Depuis ERP-78, le filtrage se fait sur le code de la + * Category (et non plus sur le type, devenu unique CLIENT). */ private function validateDistributorBroker(Client $data): void { @@ -473,7 +474,7 @@ final class ClientProcessor implements ProcessorInterface ); } - if (null !== $distributor && !$this->hasCategoryType($distributor, 'DISTRIBUTEUR')) { + if (null !== $distributor && !$this->hasCategoryCode($distributor, 'DISTRIBUTEUR')) { $this->throwViolation( 'distributor', 'Le distributeur référencé doit être un client de catégorie DISTRIBUTEUR.', @@ -481,7 +482,7 @@ final class ClientProcessor implements ProcessorInterface ); } - if (null !== $broker && !$this->hasCategoryType($broker, 'COURTIER')) { + if (null !== $broker && !$this->hasCategoryCode($broker, 'COURTIER')) { $this->throwViolation( 'broker', 'Le courtier référencé doit être un client de catégorie COURTIER.', @@ -530,13 +531,13 @@ final class ClientProcessor implements ProcessorInterface } /** - * Vrai si au moins une categorie du client porte le type donne. S'appuie - * sur CategoryInterface::getCategoryTypeCode() (pas d'import de Category). + * Vrai si au moins une categorie du client porte le code donne. S'appuie sur + * CategoryInterface::getCode() (pas d'import de Category — regle ABSOLUE n°1). */ - private function hasCategoryType(Client $client, string $typeCode): bool + private function hasCategoryCode(Client $client, string $code): bool { foreach ($client->getCategories() as $category) { - if ($category instanceof CategoryInterface && $category->getCategoryTypeCode() === $typeCode) { + if ($category instanceof CategoryInterface && $category->getCode() === $code) { return true; } } diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php index 8d7c640..8125e03 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php @@ -24,7 +24,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire; * exclus au M1) — RG-1.25 ; * - tri par defaut companyName ASC — RG-1.26 ; * - filtres ?search=... (fuzzy companyName + lastName + email) et - * ?categoryType= (clients ayant >= 1 categorie de ce type) ; + * ?categoryCode= (clients ayant >= 1 categorie de ce code — ERP-78) ; * - pagination obligatoire (convention Starseed ERP-72) : Paginator ORM ; * echappatoire ?pagination=false pour alimenter un