From 0b33bcb0f287f6f8fcbad873adaeb3d32f970d7b Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Mon, 8 Jun 2026 06:57:32 +0000 Subject: [PATCH 1/3] feat(catalog) : taxonomie FOURNISSEUR (type + filtre ?typeCode= + seed) (ERP-84) (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ERP-84 — Taxonomie FOURNISSEUR (Catalog) Prérequis du multi-select « Catégorie » de l'écran Ajouter fournisseur (#94) et de #92. Spec : `docs/specs/M2-suppliers/spec-back.md` § 2.4 + § 4.7. ### Contexte ERP-78 avait unifié la taxonomie sur un **type unique CLIENT** ; `GET /api/categories?typeCode=FOURNISSEUR` renvoyait alors les catégories CLIENT (filtre **ignoré**, un seul `CategoryType`). Le filtre `?typeCode=` n'existait pas en prod. ### Changements - **Filtre `?typeCode=` réel** sur `GET /api/categories` : `CategoryProvider` lit le filtre (même pattern que `includeDeleted`) et le passe à `DoctrineCategoryRepository::createListQueryBuilder`, qui joint le `CategoryType` et filtre sur son `code`. N'altère pas l'échappatoire `?pagination=false` ni la pagination Hydra. - **CategoryType FOURNISSEUR recréé** : migration racine `Version20260605120000` (`INSERT … ON CONFLICT` pour le type + 5 catégories de démo en `NOT EXISTS` : Négociant, Coopérative, Producteur, Grossiste, Importateur). Aucune colonne créée → pas de `COMMENT ON COLUMN`. - **Fixtures étendues** : `CategoryTypeFixtures` + `CategoryFixtures` seedent FOURNISSEUR de façon idempotente (survit à `make db-reset`). - **Test** : `CategoryTypeCodeFilterTest` (filtre exclusif, compat pagination Hydra, code inexistant → liste vide). ### Vérifications - `make php-cs-fixer-allow-risky` : clean. - `make test` : **483 tests OK** (1844 assertions). - Après `make db-reset` : - `/api/category_types` → `CLIENT` + `FOURNISSEUR`. - `?typeCode=FOURNISSEUR` → uniquement les 5 catégories FOURNISSEUR. - `?typeCode=CLIENT` → 11 catégories, type unique CLIENT. ### Critères d'acceptation - [x] `CategoryType` FOURNISSEUR présent après `make db-reset`. - [x] `?typeCode=FOURNISSEUR` ne renvoie QUE les catégories FOURNISSEUR. - [x] Catégories fournisseurs seedées sous ce type. - [x] `make test` vert. --------- Co-authored-by: Matthieu Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/63 Co-authored-by: THOLOT DECHENE Matthieu Co-committed-by: THOLOT DECHENE Matthieu --- migrations/Version20260605120000.php | 102 ++++++++++++++++++ .../CategoryRepositoryInterface.php | 5 +- .../State/Provider/CategoryProvider.php | 20 +++- .../DataFixtures/CategoryFixtures.php | 92 +++++++++------- .../DataFixtures/CategoryTypeFixtures.php | 20 ++-- .../Doctrine/DoctrineCategoryRepository.php | 12 ++- .../Api/CategoryTypeCodeFilterTest.php | 86 +++++++++++++++ 7 files changed, 287 insertions(+), 50 deletions(-) create mode 100644 migrations/Version20260605120000.php create mode 100644 tests/Module/Catalog/Api/CategoryTypeCodeFilterTest.php diff --git a/migrations/Version20260605120000.php b/migrations/Version20260605120000.php new file mode 100644 index 0000000..6814005 --- /dev/null +++ b/migrations/Version20260605120000.php @@ -0,0 +1,102 @@ + pas de `COMMENT ON COLUMN` (regle ABSOLUE n°12). + * + * Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire + * Catalog : avec plusieurs migrations_paths, Doctrine Migrations 3.x trie par + * FQCN alphabetique -> une migration `App\Module\Catalog\...` passerait avant les + * `DoctrineMigrations\...` sur base vide, donc avant la creation de la table + * `category_type`. Le namespace racine garantit l'ordre par timestamp. + * + * Idempotence : `INSERT ... ON CONFLICT (code) DO NOTHING` pour le type, + * `INSERT ... SELECT ... WHERE NOT EXISTS` pour chaque categorie (aligne sur le + * pattern ERP-78 etape 4). En prod la table `category` est vide (aucune fixture + * metier). En dev/test, le purger Doctrine vide `category`/`category_type` avant + * les fixtures qui reproduisent le meme etat final (CategoryTypeFixtures / + * CategoryFixtures etendus a FOURNISSEUR). + */ +final class Version20260605120000 extends AbstractMigration +{ + /** + * Categories de demonstration du type FOURNISSEUR : nom => code stable. Le + * code est la cle metier (slug MAJUSCULE du nom, miroir du + * CategoryCodeGenerator) et reste unique parmi les actifs (uq_category_code, + * partage avec les codes CLIENT — aucune collision ici). + */ + private const array SUPPLIER_CATEGORIES = [ + 'Négociant' => 'NEGOCIANT', + 'Coopérative' => 'COOPERATIVE', + 'Producteur' => 'PRODUCTEUR', + 'Grossiste' => 'GROSSISTE', + 'Importateur' => 'IMPORTATEUR', + ]; + + public function getDescription(): string + { + return 'ERP-84 : recree le CategoryType FOURNISSEUR + seed des categories fournisseurs (Negociant, Cooperative...).'; + } + + public function up(Schema $schema): void + { + // 1. Type FOURNISSEUR (idempotent via l'index unique uq_category_type_code). + $this->addSql(<<<'SQL' + INSERT INTO category_type (code, label) VALUES ('FOURNISSEUR', 'Fournisseur') + ON CONFLICT (code) DO NOTHING + SQL); + + // 2. Categories de demonstration sous FOURNISSEUR (si le 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::SUPPLIER_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 = 'FOURNISSEUR' + AND NOT EXISTS ( + SELECT 1 FROM category c WHERE c.code = :code AND c.deleted_at IS NULL + ) + SQL, ['name' => $name, 'code' => $code]); + } + } + + public function down(Schema $schema): void + { + // Best-effort : on retire d'abord les categories seedees (par code), puis + // le type s'il n'est plus reference (guard NOT EXISTS sur la FK RESTRICT). + $this->addSql( + 'DELETE FROM category WHERE code IN (:codes) ' + ."AND category_type_id = (SELECT id FROM category_type WHERE code = 'FOURNISSEUR')", + ['codes' => array_values(self::SUPPLIER_CATEGORIES)], + ['codes' => \Doctrine\DBAL\ArrayParameterType::STRING], + ); + + $this->addSql(<<<'SQL' + DELETE FROM category_type + WHERE code = 'FOURNISSEUR' + AND NOT EXISTS ( + SELECT 1 FROM category c WHERE c.category_type_id = category_type.id + ) + SQL); + } +} diff --git a/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php b/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php index 5a43d5c..60f7eb8 100644 --- a/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php +++ b/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php @@ -23,7 +23,10 @@ interface CategoryRepositoryInterface /** * Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut. * - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08) + * - $typeCode non null : ne garde que les categories dont le CategoryType + * porte ce code (filtre `?typeCode=`, ex. FOURNISSEUR / CLIENT). Sert au + * multi-select Categorie du fournisseur (M2, RG-2.10). * - Tri : name ASC (RG-1.10). */ - public function createListQueryBuilder(bool $includeDeleted = false): QueryBuilder; + public function createListQueryBuilder(bool $includeDeleted = false, ?string $typeCode = null): QueryBuilder; } diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/CategoryProvider.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/CategoryProvider.php index bc7f4c7..25fc1b5 100644 --- a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/CategoryProvider.php +++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/CategoryProvider.php @@ -40,7 +40,7 @@ final class CategoryProvider implements ProviderInterface $includeDeleted = $this->readIncludeDeleted($context); if ($operation instanceof CollectionOperationInterface) { - $qb = $this->repository->createListQueryBuilder($includeDeleted); + $qb = $this->repository->createListQueryBuilder($includeDeleted, $this->readTypeCode($context)); // Echappatoire ?pagination=false : retourne la collection complete sans Paginator. // Utile pour les drawers Role/Permission/Site/CategoryType qui alimentent un