From 636f2ccb8e49f53f705921546061c39fb91025c8 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 2 Jun 2026 09:20:58 +0200 Subject: [PATCH] feat(catalog) : single CLIENT category type + corrective migration Migration corrective Version20260602100000 (namespace racine) : ajoute Category.code, cree le type unique CLIENT, reporte Distributeur/Courtier/ Secteur/Autre en Category codees sous CLIENT, supprime les anciens types. Fixtures alignees (type unique CLIENT, categories codees). Index partiel uq_category_code recree dans test-db-setup. --- makefile | 3 + migrations/Version20260602100000.php | 188 ++++++++++++++++++ .../DataFixtures/CategoryFixtures.php | 109 +++++----- .../DataFixtures/CategoryTypeFixtures.php | 29 ++- 4 files changed, 256 insertions(+), 73 deletions(-) create mode 100644 migrations/Version20260602100000.php 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/Version20260602100000.php b/migrations/Version20260602100000.php new file mode 100644 index 0000000..60ca994 --- /dev/null +++ b/migrations/Version20260602100000.php @@ -0,0 +1,188 @@ + 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. + $this->addSql(<<<'SQL' + UPDATE category c + SET category_type_id = (SELECT id FROM category_type WHERE code = 'CLIENT'), + code = COALESCE( + c.code, + LEFT(UPPER(REGEXP_REPLACE(c.name, '[^A-Za-z0-9]+', '_', 'g')), 50) + ) + WHERE c.category_type_id IN ( + SELECT id FROM category_type WHERE code IN (:legacyCodes) + ) + SQL, ['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). + $this->addSql(<<<'SQL' + UPDATE category + SET code = LEFT(UPPER(REGEXP_REPLACE(name, '[^A-Za-z0-9]+', '_', 'g')), 50) + WHERE code IS NULL + SQL); + + // 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/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(