From 0b33bcb0f287f6f8fcbad873adaeb3d32f970d7b Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Mon, 8 Jun 2026 06:57:32 +0000 Subject: [PATCH 01/21] 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 sans pagination. + * + * Item (GET /api/suppliers/{id} + provider de PATCH) : + * - 404 si introuvable OU soft-delete (deleted_at non null, jamais expose au + * M2) ; les archives restent consultables/restaurables en detail. + * + * Le filtrage des champs comptables en lecture (groupe supplier:read:accounting) + * n'est PAS fait ici mais dans SupplierReadGroupContextBuilder : un Provider + * retourne des donnees mais ne peut pas influencer les groupes de serialisation. + * Le contexte de normalisation est construit par le SerializerContextBuilder, en + * amont du serializer — c'est le point d'extension idiomatique d'API Platform + * pour conditionner le groupe accounting selon la permission de l'utilisateur. + * + * @implements ProviderInterface + */ +final class SupplierProvider implements ProviderInterface +{ + public function __construct( + #[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineSupplierRepository')] + private readonly SupplierRepositoryInterface $repository, + private readonly Pagination $pagination, + ) {} + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Supplier|null + { + if ($operation instanceof CollectionOperationInterface) { + return $this->provideCollection($operation, $context); + } + + return $this->provideItem($uriVariables); + } + + /** + * @param array $context + * + * @return list|Paginator + */ + private function provideCollection(Operation $operation, array $context): array|Paginator + { + $filters = $context['filters'] ?? []; + $includeArchived = $this->readBool($filters['includeArchived'] ?? false); + $archivedOnly = $this->readBool($filters['archivedOnly'] ?? false); + $search = $filters['search'] ?? null; + // categoryCode accepte un code unique (?categoryCode=NEGOCIANT, selects) + // OU une liste (?categoryCode[]=A&categoryCode[]=B, drawer multi). + $categoryCodes = $this->readStringList($filters['categoryCode'] ?? []); + $siteIds = $this->readIntList($filters['siteId'] ?? []); + + // Filtrage delegue au repository (logique partagee avec l'export XLSX). + $qb = $this->repository->createListQueryBuilder( + $includeArchived, + is_string($search) ? $search : null, + $categoryCodes, + $siteIds, + $archivedOnly, + ); + + // Echappatoire ?pagination=false : collection complete sans Paginator + // (regle n°13 — utile pour un (regle n°13). + $data = $http->request('GET', '/api/suppliers?search='.$token.'&pagination=false', ['headers' => ['Accept' => self::LD]])->toArray(); + + self::assertArrayHasKey('member', $data); + self::assertCount(3, $data['member']); + } + + /** + * Anti N+1 (§ 2.12) : le nombre de requetes SQL de la liste ne doit PAS croitre + * avec le nombre de fournisseurs. On mesure pour N=2 puis N=4 (memes relations + * embarquees : categories + addresses.sites) et on exige un compte IDENTIQUE — + * preuve que l'hydratation est batchee (WHERE IN) et non par ligne. + */ + public function testListQueryCountDoesNotGrowWithRowCount(): void + { + $this->skipIfSitesModuleDisabled(); + $token = $this->token(); + + // Premiere mesure : 2 fournisseurs complets (avec adresses/sites/categories). + $this->seedCompleteSupplier($token.' A'); + $this->seedCompleteSupplier($token.' B'); + $countFor2 = $this->countListQueries($token); + + // Seconde mesure : 2 de plus (4 au total, tous sur la meme page). + $this->seedCompleteSupplier($token.' C'); + $this->seedCompleteSupplier($token.' D'); + $countFor4 = $this->countListQueries($token); + + self::assertSame( + $countFor2, + $countFor4, + sprintf('Anti N+1 : le nombre de requetes liste doit etre constant (%d pour 2, %d pour 4).', $countFor2, $countFor4), + ); + } + + /** + * Compte les requetes SQL emises par UN GET liste filtre, via le data holder de + * debug Doctrine (actif car kernel.debug=true en test). Le holder est remis a + * zero juste avant la requete pour isoler ses requetes (hors login). + */ + private function countListQueries(string $token): int + { + $http = $this->createAdminClient(); + $holder = self::getContainer()->get('doctrine.debug_data_holder'); + $holder->reset(); + + $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]]); + + $data = $holder->getData(); + + return count($data['default'] ?? []); + } + + private function token(): string + { + return 'List'.substr(bin2hex(random_bytes(4)), 0, 8); + } +} diff --git a/tests/Module/Commercial/Api/SupplierMigrationTest.php b/tests/Module/Commercial/Api/SupplierMigrationTest.php new file mode 100644 index 0000000..d7ed259 --- /dev/null +++ b/tests/Module/Commercial/Api/SupplierMigrationTest.php @@ -0,0 +1,68 @@ +supplierIndexes(); + + $companyNameIndexes = array_filter( + $rows, + static fn (array $r): bool => 'uq_supplier_company_name_active' === $r['indexname'], + ); + + self::assertCount(1, $companyNameIndexes, 'Il doit exister exactement UN index uq_supplier_company_name_active.'); + + $def = strtolower((string) array_values($companyNameIndexes)[0]['indexdef']); + self::assertStringContainsString('unique', $def); + self::assertStringContainsString('lower', $def); + self::assertStringContainsString('company_name', $def); + self::assertStringContainsString('where', $def, 'L\'index doit etre partiel (clause WHERE sur les actifs).'); + } + + public function testNoSirenOrEmailUniqueIndexOnSupplier(): void + { + $names = array_map(static fn (array $r): string => $r['indexname'], $this->supplierIndexes()); + + // § 2.6 : SIREN et email NON uniques sur le fournisseur. + self::assertNotContains('uq_supplier_siren_active', $names); + self::assertNotContains('uq_supplier_email_active', $names); + } + + public function testFournisseurCategoryTypeExists(): void + { + self::bootKernel(); + + $count = (int) $this->getEm()->getConnection()->fetchOne( + "SELECT COUNT(*) FROM category_type WHERE code = 'FOURNISSEUR'", + ); + + self::assertSame(1, $count, 'Le type de categorie FOURNISSEUR doit etre present (migration + fixture).'); + } + + /** + * @return list + */ + private function supplierIndexes(): array + { + self::bootKernel(); + + /** @var list $rows */ + return $this->getEm()->getConnection()->fetchAllAssociative( + "SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = 'supplier'", + ); + } +} diff --git a/tests/Module/Commercial/Api/SupplierPatchStrictTest.php b/tests/Module/Commercial/Api/SupplierPatchStrictTest.php new file mode 100644 index 0000000..7b4e8db --- /dev/null +++ b/tests/Module/Commercial/Api/SupplierPatchStrictTest.php @@ -0,0 +1,45 @@ +seedSupplier('Strict Mix'); + $credentials = $this->createUserWithPermission('commercial.suppliers.manage'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + + $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => [ + 'companyName' => 'Renamed Strict', + 'siren' => '123456789', + ], + ]); + + // RG-2.16 : 403 strict (le champ comptable siren exige accounting.manage). + self::assertResponseStatusCodeSame(403); + + // Aucun champ applique : le companyName d'origine est intact. + $em = $this->getEm(); + $em->clear(); + $reloaded = $em->getRepository(Supplier::class)->find($seed->getId()); + self::assertNotNull($reloaded); + self::assertSame('STRICT MIX', $reloaded->getCompanyName()); + } +} diff --git a/tests/Module/Commercial/Api/SupplierRBACMatrixTest.php b/tests/Module/Commercial/Api/SupplierRBACMatrixTest.php new file mode 100644 index 0000000..9d5b3dd --- /dev/null +++ b/tests/Module/Commercial/Api/SupplierRBACMatrixTest.php @@ -0,0 +1,303 @@ +setAutoExit(false); + $exit = $application->run( + new ArrayInput([ + 'command' => 'app:seed-rbac', + '--with-demo-users' => true, + '--password' => self::PWD, + ]), + new NullOutput(), + ); + self::assertSame( + 0, + $exit, + 'app:seed-rbac a echoue : les permissions commercial.suppliers.* sont-elles synchronisees (app:sync-permissions) ?', + ); + + self::ensureKernelShutdown(); + } + + public function testUsineIsForbiddenEverywhere(): void + { + $seed = $this->seedSupplier('Usine Target'); + $client = $this->authAs('usine'); + + $client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(403); + + $client->request('GET', '/api/suppliers/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(403); + + $client->request('POST', '/api/suppliers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Usine Post'), + ]); + self::assertResponseStatusCodeSame(403); + + $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['companyName' => 'Renamed By Usine'], + ]); + self::assertResponseStatusCodeSame(403); + } + + public function testBureauHasViewAndManageButNoAccountingNoArchive(): void + { + $seed = $this->seedSupplier('Bureau Target'); + $cat = $this->supplierCategory('NEGOCIANT'); + $client = $this->authAs('bureau'); + + // view + $client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + // manage : creation OK (bureau n'est pas gate par RG-2.03) + $client->request('POST', '/api/suppliers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Bureau Created', $cat->getId()), + ]); + self::assertResponseStatusCodeSame(201); + + // manage : edition onglet principal OK + $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['companyName' => 'Bureau Renamed'], + ]); + self::assertResponseStatusCodeSame(200); + + // PAS accounting : edition onglet Comptabilite refusee + $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['siren' => '123456789'], + ]); + self::assertResponseStatusCodeSame(403); + + // PAS archive : archivage refuse + $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true], + ]); + self::assertResponseStatusCodeSame(403); + } + + public function testBureauDetailHasNoAccountingFields(): void + { + // Bureau a view mais PAS accounting.view : les champs comptables sont + // ABSENTS du JSON (gating par omission, pas null). + $supplier = $this->seedCompleteSupplier('Bureau Gating Co'); + $client = $this->authAs('bureau'); + + $data = $client->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); + + // Gating par omission sur l'ensemble des champs comptables (pas seulement + // siren/ribs) : une regression reintroduisant accountNumber/nTva/tvaMode/ + // paymentType dans le groupe bureau serait sinon invisible. + self::assertArrayNotHasKey('siren', $data); + self::assertArrayNotHasKey('accountNumber', $data); + self::assertArrayNotHasKey('nTva', $data); + self::assertArrayNotHasKey('tvaMode', $data); + self::assertArrayNotHasKey('paymentType', $data); + self::assertArrayNotHasKey('ribs', $data); + } + + public function testComptaCanEditAccountingOnly(): void + { + $seed = $this->seedSupplier('Compta Target'); + $client = $this->authAs('compta'); + + // view + $client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + // PAS manage : creation refusee + $client->request('POST', '/api/suppliers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Compta Post'), + ]); + self::assertResponseStatusCodeSame(403); + + // accounting.manage : edition onglet Comptabilite OK + $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['siren' => '123456789'], + ]); + self::assertResponseStatusCodeSame(200); + + // PAS manage : edition onglet principal refusee (guardManage) + $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['companyName' => 'Compta Renamed'], + ]); + self::assertResponseStatusCodeSame(403); + + // PAS manage : edition onglet Information refusee (guardManage) + $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['description' => 'Une description'], + ]); + self::assertResponseStatusCodeSame(403); + + // PAS archive : archivage refuse + $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true], + ]); + self::assertResponseStatusCodeSame(403); + } + + public function testComptaDetailHasAccountingFields(): void + { + // Compta a accounting.view : siren + ribs presents dans le JSON. + $supplier = $this->seedCompleteSupplier('Compta View Co'); + $client = $this->authAs('compta'); + + $data = $client->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); + + self::assertArrayHasKey('siren', $data); + self::assertSame('123456789', $data['siren']); + self::assertArrayHasKey('ribs', $data); + self::assertNotEmpty($data['ribs']); + } + + public function testCommercialeHasViewAndManageButNoAccountingNoArchive(): void + { + $seed = $this->seedSupplier('Commerciale Target'); + $client = $this->authAs('commerciale'); + + // view + $client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + // manage : la creation passe la security d'operation (pas un 403 comme + // Compta) mais bute sur RG-2.03 (onglet Information incomplet) -> 422. + $response = $client->request('POST', '/api/suppliers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Commerciale Post'), + ]); + self::assertResponseStatusCodeSame(422); + // Le 422 doit bien etre celui de RG-2.03 (onglet Information) et non un + // 422 orthogonal : on exige une violation sur un champ de completude. + self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false))); + + // PAS accounting : edition onglet Comptabilite refusee + $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['siren' => '123456789'], + ]); + self::assertResponseStatusCodeSame(403); + + // PAS archive : archivage refuse + $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true], + ]); + self::assertResponseStatusCodeSame(403); + } + + public function testCommercialeDetailHasNoAccountingFields(): void + { + $supplier = $this->seedCompleteSupplier('Commerciale Gating Co'); + $client = $this->authAs('commerciale'); + + $data = $client->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); + + self::assertArrayNotHasKey('siren', $data); + self::assertArrayNotHasKey('accountNumber', $data); + self::assertArrayNotHasKey('nTva', $data); + self::assertArrayNotHasKey('tvaMode', $data); + self::assertArrayNotHasKey('paymentType', $data); + self::assertArrayNotHasKey('ribs', $data); + } + + public function testRG203CommercialePostIncompleteIs422AdminIs201(): void + { + $cat = $this->supplierCategory('NEGOCIANT'); + + // RG-2.03 : Commerciale POST sans onglet Information complet -> 422. + $commerciale = $this->authAs('commerciale'); + $response = $commerciale->request('POST', '/api/suppliers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('RG203 Commerciale', $cat->getId()), + ]); + self::assertResponseStatusCodeSame(422); + self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false))); + + // Meme payload par un Admin (non gate par RG-2.03) -> 201. + $admin = $this->createAdminClient(); + $admin->request('POST', '/api/suppliers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('RG203 Admin', $cat->getId()), + ]); + self::assertResponseStatusCodeSame(201); + } + + public function testRG203CommercialePatchIncompleteIs422(): void + { + // RG-2.03 : tout PATCH par une Commerciale exige l'Information complete. + // Le fournisseur seede a une Information vide -> meme un PATCH du nom -> 422. + $seed = $this->seedSupplier('Commerciale Patch Incomplete'); + $commerciale = $this->authAs('commerciale'); + + $response = $commerciale->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['companyName' => 'Commerciale Renamed'], + ]); + self::assertResponseStatusCodeSame(422); + self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false))); + + // Le meme PATCH par un Admin passe (non gate par RG-2.03) -> 200. + $admin = $this->createAdminClient(); + $admin->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['companyName' => 'Admin Renamed'], + ]); + self::assertResponseStatusCodeSame(200); + } + + private function authAs(string $role): Client + { + return $this->authenticatedClient($role, self::PWD); + } +} diff --git a/tests/Module/Commercial/Api/SupplierSerializationContractTest.php b/tests/Module/Commercial/Api/SupplierSerializationContractTest.php new file mode 100644 index 0000000..8f0611b --- /dev/null +++ b/tests/Module/Commercial/Api/SupplierSerializationContractTest.php @@ -0,0 +1,371 @@ + clé `ribs` + * ABSENTE pour la Commerciale. + * - #3 : booleens droppes (Groups sur la propriete `isX`, getter derivant `x`) + * -> triageProvider (adresse) et isArchived (fournisseur) presents. + * - #1 : categories embarquees sans code/name -> code + name presents en LISTE + * ET DETAIL. + * - #2 : sites embarques en IRI nu -> name + postalCode presents en LISTE + * (via getSites()) ET DETAIL (addresses[].sites[]). + * Plus l'enveloppe AP4 (member/totalItems/view sans prefixe hydra:, archives + * exclus) et la suppression du contact inline (refonte-contact V0.2). + * + * REGLE D'OR : ces tests assertent sur le CORPS JSON reel, jamais sur les + * annotations. Toute regression de groupe de serialisation casse ici. + * + * @internal + */ +final class SupplierSerializationContractTest extends AbstractSupplierApiTestCase +{ + // === #4 — Gating des RIB par accounting.view === + + public function testRibsPresentForAdminWithAccountingView(): void + { + $this->skipIfSitesModuleDisabled(); + + $supplier = $this->seedCompleteSupplier('Rib Admin Co'); + + $http = $this->createAdminClient(); + $data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); + + // Admin bypass RBAC -> accounting.view -> RIB embarques (label/bic/iban). + self::assertArrayHasKey('ribs', $data); + self::assertNotEmpty($data['ribs']); + self::assertSame('Compte principal', $data['ribs'][0]['label']); + self::assertSame(self::VALID_IBAN, $data['ribs'][0]['iban']); + self::assertSame(self::VALID_BIC, $data['ribs'][0]['bic']); + } + + public function testRibsAbsentForCommercialeWithoutAccountingView(): void + { + $this->skipIfSitesModuleDisabled(); + + $supplier = $this->seedCompleteSupplier('Rib Commerciale Co'); + + // Commerciale : commercial.suppliers.view SANS accounting.view. + $creds = $this->createUserWithPermission('commercial.suppliers.view'); + $http = $this->authenticatedClient($creds['username'], $creds['password']); + + $data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); + + // La clé `ribs` est ABSENTE (pas null) : le groupe supplier:read:accounting + // n'est pas ajoute au contexte -> getRibs() jamais serialise. Fin de la + // fuite IBAN/BIC (piege n°4 du M1). + self::assertArrayNotHasKey('ribs', $data); + } + + // === #4.bis — Gating par OMISSION des scalaires comptables === + + public function testAccountingScalarsGatedByOmission(): void + { + $this->skipIfSitesModuleDisabled(); + + $supplier = $this->seedCompleteSupplier('Compta Gating Co'); + $id = $supplier->getId(); + + // Admin : scalaires comptables presents. + $admin = $this->createAdminClient(); + $adminData = $admin->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray(); + self::assertArrayHasKey('siren', $adminData); + self::assertSame('123456789', $adminData['siren']); + self::assertArrayHasKey('accountNumber', $adminData); + self::assertArrayHasKey('paymentType', $adminData); + + // Commerciale : scalaires comptables ABSENTS (omission, pas null). + $creds = $this->createUserWithPermission('commercial.suppliers.view'); + $http = $this->authenticatedClient($creds['username'], $creds['password']); + $data = $http->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray(); + + self::assertArrayNotHasKey('siren', $data); + self::assertArrayNotHasKey('accountNumber', $data); + self::assertArrayNotHasKey('nTva', $data); + self::assertArrayNotHasKey('tvaMode', $data); + self::assertArrayNotHasKey('paymentType', $data); + self::assertArrayNotHasKey('ribs', $data); + } + + // === Refs comptables embarquees {id,label} et non IRI nu (ERP-92) === + + public function testAccountingReferentialsEmbedIdAndLabel(): void + { + $this->skipIfSitesModuleDisabled(); + + // Reglement Virement -> banque renseignee : on couvre les 4 referentiels. + $supplier = $this->seedCompleteSupplier('Refs Embed Co', 'VIREMENT'); + + $http = $this->createAdminClient(); + $data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); + + // Avant fix ERP-92 : ces refs sortaient en IRI nu ("/api/tva_modes/30") + // car les entites partagees ne portaient que `client:read:accounting` (M1), + // pas `supplier:read:accounting`. Apres fix : objet {id, label} embarque + // (le front consultation/edition affiche le libelle sans fetch — § 4.0). + foreach (['tvaMode', 'paymentDelay', 'paymentType', 'bank'] as $ref) { + self::assertArrayHasKey($ref, $data, sprintf('Le ref comptable "%s" doit etre present.', $ref)); + self::assertIsArray($data[$ref], sprintf('Le ref "%s" doit etre un objet embarque, pas un IRI nu.', $ref)); + self::assertArrayHasKey('id', $data[$ref]); + self::assertArrayHasKey('label', $data[$ref]); + self::assertNotSame('', (string) $data[$ref]['label']); + } + + // paymentType embarque aussi son code (logique front VIREMENT/LCR). + self::assertArrayHasKey('code', $data['paymentType']); + self::assertSame('VIREMENT', $data['paymentType']['code']); + } + + // === #3 — Booleens presents dans le JSON (triageProvider + isArchived) === + + public function testAddressTriageProviderBooleanIsPresentInDetail(): void + { + $this->skipIfSitesModuleDisabled(); + + $supplier = $this->seedCompleteSupplier('Bool Addr Co'); + + $http = $this->createAdminClient(); + $data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); + + self::assertArrayHasKey('addresses', $data); + self::assertNotEmpty($data['addresses']); + $address = $data['addresses'][0]; + + // Le bug M1 droppait TOTALEMENT la cle (Groups sur la propriete `triageProvider`, + // getter derivant `triage`). Apres parade (Groups + SerializedName sur le + // getter isTriageProvider), la cle est presente ET typee bool `true`. + self::assertArrayHasKey('triageProvider', $address); + self::assertTrue($address['triageProvider']); + } + + public function testSupplierIsArchivedBooleanIsPresentInDetail(): void + { + $this->skipIfSitesModuleDisabled(); + + $supplier = $this->seedCompleteSupplier('Bool Archived Co'); + + $http = $this->createAdminClient(); + $data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); + + // isArchived expose via Groups + SerializedName('isArchived') sur le getter : + // sans cela Symfony exposerait la cle "archived" et la droppait (piege n°3 M1). + self::assertArrayHasKey('isArchived', $data); + self::assertFalse($data['isArchived']); + } + + // === #1 — Embed code/name des Category (liste ET detail) === + + public function testCategoriesEmbedCodeAndNameInDetail(): void + { + $this->skipIfSitesModuleDisabled(); + + $supplier = $this->seedCompleteSupplier('Embed Cat Detail Co'); + + $http = $this->createAdminClient(); + $data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); + + self::assertNotEmpty($data['categories']); + $category = $data['categories'][0]; + // Avant correctif M1 : seuls @id/@type (category:read absent du contexte). + // Apres : code + name embarques. + self::assertArrayHasKey('code', $category); + self::assertArrayHasKey('name', $category); + self::assertSame('NEGOCIANT', $category['code']); + + // Categories d'adresse aussi (category:read dans le contexte du detail). + self::assertArrayHasKey('categories', $data['addresses'][0]); + self::assertNotEmpty($data['addresses'][0]['categories']); + self::assertArrayHasKey('code', $data['addresses'][0]['categories'][0]); + } + + public function testCategoriesEmbedCodeAndNameInList(): void + { + $this->skipIfSitesModuleDisabled(); + + $token = 'CatList'.substr(bin2hex(random_bytes(3)), 0, 6); + $supplier = $this->seedCompleteSupplier($token); + + $http = $this->createAdminClient(); + $list = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray(); + + $row = $this->memberById($list, (int) $supplier->getId()); + self::assertNotNull($row, 'Le fournisseur seede doit apparaitre dans la liste filtree.'); + self::assertNotEmpty($row['categories']); + self::assertArrayHasKey('code', $row['categories'][0]); + self::assertArrayHasKey('name', $row['categories'][0]); + self::assertSame('NEGOCIANT', $row['categories'][0]['code']); + } + + // === #2 — Embed name/postalCode des Site (liste via getSites + detail) === + + public function testSitesEmbedNameAndPostalCodeInList(): void + { + $this->skipIfSitesModuleDisabled(); + + $token = 'SiteList'.substr(bin2hex(random_bytes(3)), 0, 6); + $supplier = $this->seedCompleteSupplier($token); + + $http = $this->createAdminClient(); + $list = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray(); + + $row = $this->memberById($list, (int) $supplier->getId()); + self::assertNotNull($row); + // sites agreges depuis les adresses via getSites() : objet Site entier + // (name + postalCode), pas un IRI nu (piege n°2 M1). Multi-sites (>= 2). + self::assertArrayHasKey('sites', $row); + self::assertGreaterThanOrEqual(2, count($row['sites'])); + self::assertArrayHasKey('name', $row['sites'][0]); + self::assertArrayHasKey('postalCode', $row['sites'][0]); + self::assertNotSame('', (string) $row['sites'][0]['name']); + } + + public function testSitesEmbedNameAndPostalCodeInDetail(): void + { + $this->skipIfSitesModuleDisabled(); + + $supplier = $this->seedCompleteSupplier('Site Detail Co'); + + $http = $this->createAdminClient(); + $data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); + $address = $data['addresses'][0]; + + self::assertArrayHasKey('sites', $address); + self::assertGreaterThanOrEqual(2, count($address['sites']), 'L\'adresse seedee est multi-sites.'); + self::assertArrayHasKey('name', $address['sites'][0]); + self::assertArrayHasKey('postalCode', $address['sites'][0]); + self::assertNotSame('', (string) $address['sites'][0]['name']); + } + + // === Detail : sous-collections embarquees === + + public function testDetailEmbedsContactsAddressesRibs(): void + { + $this->skipIfSitesModuleDisabled(); + + $supplier = $this->seedCompleteSupplier('Embed Subres Co'); + + $http = $this->createAdminClient(); + $data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); + + self::assertNotEmpty($data['contacts']); + self::assertSame('Marie', $data['contacts'][0]['firstName']); + self::assertSame('Martin', $data['contacts'][0]['lastName']); + self::assertArrayHasKey('email', $data['contacts'][0]); + + self::assertNotEmpty($data['addresses']); + self::assertSame('DEPART', $data['addresses'][0]['addressType']); + + self::assertNotEmpty($data['ribs']); + } + + // === refonte-contact V0.2 : plus de contact inline sur le fournisseur === + + public function testSupplierHasNoInlineContactFields(): void + { + $this->skipIfSitesModuleDisabled(); + + $supplier = $this->seedCompleteSupplier('No Inline Contact Co'); + + $http = $this->createAdminClient(); + $data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); + + // Les champs de contact vivent UNIQUEMENT sous contacts[] (refonte-contact). + foreach (['firstName', 'lastName', 'phonePrimary', 'phoneSecondary', 'email'] as $key) { + self::assertArrayNotHasKey($key, $data, sprintf('Le champ inline "%s" ne doit plus exister au niveau du fournisseur.', $key)); + } + } + + // === Enveloppe AP4 (sans prefixe hydra:) + exclusion des archives === + + public function testCollectionEnvelopeShapeAndArchivedExcluded(): void + { + $this->skipIfSitesModuleDisabled(); + + $http = $this->createAdminClient(); + $token = 'EnvCheck'.substr(bin2hex(random_bytes(3)), 0, 6); + + $this->seedSupplier($token.' Active'); + $this->seedSupplier($token.' Archived', true); + + // Liste par defaut filtree sur le token : enveloppe member/totalItems sans + // prefixe hydra:, archive EXCLU du totalItems (RG-2.17). + $default = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray(); + + self::assertArrayHasKey('member', $default); + self::assertArrayHasKey('totalItems', $default); + self::assertArrayNotHasKey('hydra:member', $default); + self::assertArrayNotHasKey('hydra:totalItems', $default); + self::assertSame(1, $default['totalItems'], 'Archive exclu du totalItems par defaut.'); + + // includeArchived : l'archive reintegre le total. + $all = $http->request('GET', '/api/suppliers?search='.$token.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray(); + self::assertSame(2, $all['totalItems']); + + // `view` (PartialCollectionView) sans prefixe hydra:. + $paged = $http->request('GET', '/api/suppliers?search='.$token.'&includeArchived=true&itemsPerPage=1', ['headers' => ['Accept' => self::LD]])->toArray(); + self::assertArrayHasKey('view', $paged); + self::assertArrayNotHasKey('hydra:view', $paged); + } + + /** + * DoD (§ 4.0.bis) : capture des reponses JSON REELLES (liste + detail admin + + * detail commerciale) pour les coller dans la spec avant de lancer les tickets + * front. Le test asserte la forme ; si la variable d'env SUPPLIER_DOD_DUMP est + * positionnee, il ecrit aussi les 3 corps formates sous /tmp pour copie. + */ + public function testDodReferenceJsonShape(): void + { + $this->skipIfSitesModuleDisabled(); + + $token = 'DoD'.substr(bin2hex(random_bytes(3)), 0, 6); + $supplier = $this->seedCompleteSupplier($token); + $id = (int) $supplier->getId(); + + $admin = $this->createAdminClient(); + $list = $admin->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray(); + $detailAdmin = $admin->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray(); + + $creds = $this->createUserWithPermission('commercial.suppliers.view'); + $commerciale = $this->authenticatedClient($creds['username'], $creds['password']); + $detailCommerciale = $commerciale->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray(); + + // Forme minimale attendue (la DoD valide que tout champ front est present). + self::assertArrayHasKey('member', $list); + self::assertArrayHasKey('siren', $detailAdmin); + self::assertArrayHasKey('ribs', $detailAdmin); + self::assertArrayNotHasKey('siren', $detailCommerciale); + self::assertArrayNotHasKey('ribs', $detailCommerciale); + + if (false !== getenv('SUPPLIER_DOD_DUMP')) { + $flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; + file_put_contents('/tmp/supplier-dod-list.json', json_encode($list, $flags)); + file_put_contents('/tmp/supplier-dod-detail-admin.json', json_encode($detailAdmin, $flags)); + file_put_contents('/tmp/supplier-dod-detail-commerciale.json', json_encode($detailCommerciale, $flags)); + } + } + + /** + * Retrouve un membre de la collection par son id (liste filtree). + * + * @param array $collection + * + * @return array|null + */ + private function memberById(array $collection, int $id): ?array + { + foreach ($collection['member'] ?? [] as $member) { + if (($member['id'] ?? null) === $id) { + return $member; + } + } + + return null; + } +} diff --git a/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php b/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php new file mode 100644 index 0000000..9a4aa25 --- /dev/null +++ b/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php @@ -0,0 +1,357 @@ += 1 site), RG-2.09 (enum addressType), + * RG-2.10 (categorie FOURNISSEUR sur adresse), RG-2.08 (DELETE dernier RIB sous + * LCR -> 409), DELETE contact libre au M2 (pas de garde « dernier contact ») et le + * gating comptable des RIB (manage seul -> 403). Jumeau de ClientSubResourceApiTest. + * + * @internal + */ +final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase +{ + // === Contacts === + + public function testPostContactNormalizesFields(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Contact Host'); + + $data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'firstName' => 'JEAN', + 'lastName' => 'dupont', + 'phonePrimary' => '06.12.34.56.78', + 'email' => 'Jean.DUPONT@ACME.FR', + ], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + // RG-2.12 : prenom/nom Title Case, telephone chiffres seuls, email lowercase. + self::assertSame('Jean', $data['firstName']); + self::assertSame('Dupont', $data['lastName']); + self::assertSame('0612345678', $data['phonePrimary']); + self::assertSame('jean.dupont@acme.fr', $data['email']); + } + + public function testPostContactWithoutNameReturns422OnFirstNamePath(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Contact No Name'); + + $response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => ['jobTitle' => 'Directeur'], + ]); + + // RG-2.04 (prenom OU nom obligatoire) -> 422 rattachee a firstName. + self::assertResponseStatusCodeSame(422); + $byPath = $this->violationsByPath($response->toArray(false)); + self::assertArrayHasKey('firstName', $byPath); + } + + public function testPostContactOnMissingSupplierReturns404(): void + { + $client = $this->createAdminClient(); + + $client->request('POST', '/api/suppliers/999999/contacts', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => ['firstName' => 'Orphan'], + ]); + + self::assertResponseStatusCodeSame(404); + } + + public function testDeleteLastContactReturns204(): void + { + // M2 : pas de garde « dernier contact » (RG-2.13 front-driven) — la + // suppression du dernier contact est libre (204), contrairement au M1. + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Contact Solo'); + $contact = $this->addContact($seed, 'Unique', 'Contact'); + + $client->request('DELETE', '/api/supplier_contacts/'.$contact->getId()); + + self::assertResponseStatusCodeSame(204); + } + + public function testContactWriteWithoutManageReturns403(): void + { + // Un user sans aucune permission suppliers -> 403 sur la sous-ressource. + $seed = $this->seedSupplier('Contact Forbidden'); + $creds = $this->createUserWithPermission('core.users.view'); + $http = $this->authenticatedClient($creds['username'], $creds['password']); + + $http->request('POST', '/api/suppliers/'.$seed->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['firstName' => 'Nope'], + ]); + self::assertResponseStatusCodeSame(403); + } + + // === Adresses === + + public function testPostAddressWithValidPayloadReturns201(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Address Host'); + $category = $this->supplierCategory('NEGOCIANT'); + + $data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'addressType' => 'DEPART', + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$this->firstSiteIri()], + 'categories' => ['/api/categories/'.$category->getId()], + ], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + self::assertSame('DEPART', $data['addressType']); + } + + public function testPostAddressWithoutSiteReturns422(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Address No Site'); + + $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'addressType' => 'DEPART', + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [], + ], + ]); + + // RG-2.06 (Assert\Count min 1 sur sites). + self::assertResponseStatusCodeSame(422); + } + + public function testPostAddressWithInvalidPostalCodeReturns422(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Address Bad CP'); + + $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'addressType' => 'DEPART', + 'postalCode' => '123', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$this->firstSiteIri()], + ], + ]); + + // RG-2.05 (Assert\Regex ^[0-9]{4,5}$). + self::assertResponseStatusCodeSame(422); + } + + public function testPostAddressWithIncoherentCityAndPostalCodeReturns201(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Address Incoherent'); + + // RG-2.05 : pas de controle strict de coherence CP/ville cote serveur. + $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'addressType' => 'DEPART', + 'postalCode' => '86100', + 'city' => 'Marseille', + 'street' => '1 rue du Test', + 'sites' => [$this->firstSiteIri()], + ], + ]); + + self::assertResponseStatusCodeSame(201); + } + + public function testPostAddressWithInvalidTypeReturns422(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Address Bad Type'); + + $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'addressType' => 'INVALID', + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$this->firstSiteIri()], + ], + ]); + + // RG-2.09 (Assert\Choice PROSPECT|DEPART|RENDU). + self::assertResponseStatusCodeSame(422); + } + + /** + * RG-2.09 : les 3 valeurs valides de addressType sont acceptees. + */ + public function testPostAddressWithEachValidTypeReturns201(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Address Types'); + $siteIri = $this->firstSiteIri(); + + foreach (['PROSPECT', 'DEPART', 'RENDU'] as $type) { + $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'addressType' => $type, + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$siteIri], + ], + ]); + self::assertResponseStatusCodeSame(201, sprintf('addressType=%s doit etre accepte.', $type)); + } + } + + public function testPostAddressWithNonFournisseurCategoryReturns422(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Address Bad Cat'); + // categorie de type CLIENT -> interdite sur une adresse fournisseur. + $clientTypedCategory = $this->createCategory('SECTEUR'); + + $response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => [ + 'addressType' => 'DEPART', + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$this->firstSiteIri()], + 'categories' => ['/api/categories/'.$clientTypedCategory->getId()], + ], + ]); + + // RG-2.10 -> 422 rattachee a categories. + self::assertResponseStatusCodeSame(422); + self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false))); + } + + // === RIBs === + + public function testPostRibByAdminReturns201(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Rib Host'); + + $data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'label' => 'Compte principal', + 'bic' => self::VALID_BIC, + 'iban' => self::VALID_IBAN, + ], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + self::assertSame('Compte principal', $data['label']); + } + + public function testPostRibWithInvalidIbanReturns422(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Rib Bad Iban'); + + $client->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['label' => 'Compte invalide', 'bic' => self::VALID_BIC, 'iban' => 'INVALID-IBAN'], + ]); + + self::assertResponseStatusCodeSame(422); + } + + public function testDeleteRibNonLcrReturns204(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Rib Non LCR'); + $rib = $this->addRib($seed); + + $client->request('DELETE', '/api/supplier_ribs/'.$rib->getId()); + + self::assertResponseStatusCodeSame(204); + } + + public function testDeleteLastRibUnderLcrReturns409(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Rib LCR Solo'); + $rib = $this->addRib($seed); + // Passe le fournisseur en LCR (seed direct). + $em = $this->getEm(); + $managed = $em->getRepository(Supplier::class)->find($seed->getId()); + $managed->setPaymentType($this->paymentType('LCR')); + $em->flush(); + + $client->request('DELETE', '/api/supplier_ribs/'.$rib->getId()); + + // RG-2.08 : LCR exige >= 1 RIB -> suppression du dernier refusee. + self::assertResponseStatusCodeSame(409); + } + + public function testRibWriteWithoutAccountingManageReturns403(): void + { + // Un user portant seulement suppliers.manage (sans accounting.manage) ne + // peut ni creer, ni modifier, ni supprimer un RIB (gating renforce § 4.5). + $seed = $this->seedSupplier('Rib Forbidden'); + $rib = $this->addRib($seed); + $creds = $this->createUserWithPermission('commercial.suppliers.manage'); + $http = $this->authenticatedClient($creds['username'], $creds['password']); + + $http->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['label' => 'X', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN], + ]); + self::assertResponseStatusCodeSame(403); + + $http->request('PATCH', '/api/supplier_ribs/'.$rib->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['label' => 'Y'], + ]); + self::assertResponseStatusCodeSame(403); + + $http->request('DELETE', '/api/supplier_ribs/'.$rib->getId()); + self::assertResponseStatusCodeSame(403); + } + + // === Helpers === + + // violationsByPath() : helper mutualise dans AbstractSupplierApiTestCase. + + private function firstSiteIri(): string + { + $site = $this->getEm()->getRepository(Site::class)->findOneBy([]); + self::assertNotNull($site, 'Aucun site seede : impossible de tester les adresses.'); + + return '/api/sites/'.$site->getId(); + } +} diff --git a/tests/Module/Commercial/Api/SupplierUniquenessTest.php b/tests/Module/Commercial/Api/SupplierUniquenessTest.php new file mode 100644 index 0000000..683c2d1 --- /dev/null +++ b/tests/Module/Commercial/Api/SupplierUniquenessTest.php @@ -0,0 +1,36 @@ +getEm(); + + $one = $this->seedSupplier('Siren Share One'); + $two = $this->seedSupplier('Siren Share Two'); + + // Le SIREN n'est pas ecrivable au POST (groupe accounting) : seed direct. + $one->setSiren('123456789'); + $two->setSiren('123456789'); + $em->flush(); + + // Aucune exception : pas d'index unique sur siren (§ 2.6). + self::assertSame('123456789', $em->getRepository(Supplier::class)->find($one->getId())?->getSiren()); + self::assertSame('123456789', $em->getRepository(Supplier::class)->find($two->getId())?->getSiren()); + } +} From b35deed8fe2f7207a7f36892d17ee3a0455098ae Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Mon, 8 Jun 2026 07:49:28 +0000 Subject: [PATCH 15/21] =?UTF-8?q?feat(commercial)=20:=20fixtures=20Doctrin?= =?UTF-8?q?e=20fournisseurs=20(=E2=89=8813=20suppliers=20complets=20+=20so?= =?UTF-8?q?us-collections)=20(ERP-112)=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ERP-112 — Fixtures Doctrine fournisseurs (M2) `SupplierFixtures` (calquée sur `ClientFixtures` / ERP-68) : ~13 fournisseurs de démonstration couvrant les cas pivots du répertoire fournisseurs (M2), chargés par `make db-reset`. ### Contenu - **13 fournisseurs** (dont **2 archivés** — RG-2.17), `companyName` variés (UPPERCASE serveur), mono et multi-catégories de type FOURNISSEUR (RG-2.10). - **19 contacts** (1 à 3 par fournisseur, dont un avec téléphone secondaire et un nommé par le seul nom — RG-2.04). - **15 adresses** multi-types PROSPECT / DEPART / RENDU (RG-2.09) et multi-sites 86/17/82 (RG-2.06), avec `bennes` et `triageProvider`. - **3 RIB**, compta complète sur une partie (siren, tvaMode, paymentDelay, paymentType). ### Cas pivots - VIREMENT → banque renseignée (RG-2.07) ; LCR → 1 puis 2 RIB (RG-2.08) ; CHEQUE et NON_SOUMISE sans RIB. - Onglet Information complet (dont `volumeForecast`, spécifique fournisseur). - Cohérence gating comptable (un rôle sans `accounting.view` ne voit pas la compta) — support des tests ERP-92 et du golden path front. ### Notes - **Idempotent** (lookup par companyName normalisé, aligné sur `uq_supplier_company_name_active`) ; rejouable sans doublon même purger désactivé. - Référentiels comptables **réutilisés de M1** (tva_modes / payment_delays / payment_types / banks) — aucune nouvelle table. - Données de démonstration **dev uniquement** : early return en env `test` (les tests seedent leurs propres données). ### Vérifications - `make db-reset` : 13 fournisseurs (2 archivés), 19 contacts, 15 adresses, 3 RIB chargés sans erreur. - Idempotence `--append` : compteurs inchangés. - `make php-cs-fixer-allow-risky` : 0 fichier à corriger. - `make test` : 574 tests OK. Base : `feature/ERP-92-tests-phpunit-m2` (sommet de la pile M2). --------- Co-authored-by: Matthieu Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/72 Co-authored-by: THOLOT DECHENE Matthieu Co-committed-by: THOLOT DECHENE Matthieu --- .../DataFixtures/SupplierFixtures.php | 510 ++++++++++++++++++ 1 file changed, 510 insertions(+) create mode 100644 src/Module/Commercial/Infrastructure/DataFixtures/SupplierFixtures.php diff --git a/src/Module/Commercial/Infrastructure/DataFixtures/SupplierFixtures.php b/src/Module/Commercial/Infrastructure/DataFixtures/SupplierFixtures.php new file mode 100644 index 0000000..ac89f22 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/DataFixtures/SupplierFixtures.php @@ -0,0 +1,510 @@ + Category) ; + * - sites resolus via le contrat Shared SiteProviderInterface. + * + * Normalisation : les valeurs sont fournies BRUTES (casse libre, telephones + * formates) et normalisees par SupplierFieldNormalizer avant persist, exactement + * comme le ferait le SupplierProcessor via l'API (companyName UPPERCASE, + * first/last Capitalize, telephones chiffres seuls, emails lowercase). + * + * Coherence gating comptable (RG-2.16) : les scalaires comptables (siren, + * tvaMode, paymentType, bank...) et les RIB ne sont visibles qu'avec + * accounting.view. Les donnees sont posees pour que les roles SANS cette + * permission (ex. Commerciale) ne voient pas de compta — support des tests + * ERP-92 et du golden path front. + * + * Idempotence : lookup par companyName normalise (coherent avec l'index unique + * partiel uq_supplier_company_name_active). Un fournisseur deja present n'est pas + * reconstruit (ses sous-collections ne sont pas redupliquees). 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. Les donnees respectent + * les CHECK BDD (chk_supplier_contact_name : firstName OU lastName ; + * chk_supplier_address_type : PROSPECT | DEPART | RENDU) ET la coherence des + * validators d'entite (RG-2.07/2.08 : VIREMENT => banque, LCR => >= 1 RIB). + * + * Depend de CategoryFixtures (categories FOURNISSEUR), SitesFixtures (sites) et + * CommercialReferentialFixtures (referentiels comptables — REUTILISES de M1, + * aucune nouvelle table). + * + * Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`, + * la fixture ne charge rien : les tests seedent et nettoient leurs propres + * fournisseurs et comptent sur une table `supplier` vierge — y injecter 13 + * fournisseurs de demo casserait les comptages de liste et les cleanups. Meme + * garde-fou que ClientFixtures / CategoryFixtures. + */ +class SupplierFixtures extends Fixture implements DependentFixtureInterface +{ + /** Cache des categories resolues par nom (evite des requetes repetees). */ + private array $categoryCache = []; + + /** Cache des sites resolus par nom. */ + private array $siteCache = []; + + /** ObjectManager courant, capture en debut de load (resolution categories). */ + private ObjectManager $manager; + + public function __construct( + private readonly SupplierFieldNormalizer $normalizer, + private readonly SiteProviderInterface $siteProvider, + #[Autowire('%kernel.environment%')] + private readonly string $environment, + ) {} + + /** + * @return array + */ + public function getDependencies(): array + { + return [ + CategoryFixtures::class, + SitesFixtures::class, + CommercialReferentialFixtures::class, + ]; + } + + public function load(ObjectManager $manager): void + { + // Donnees de demo : dev uniquement. En test, on laisse la table vierge. + if ('test' === $this->environment) { + return; + } + + $this->manager = $manager; + + // === Fournisseur basique — VIREMENT + banque (RG-2.07), compta complete === + [$negoce, $isNew] = $this->ensureSupplier($manager, 'Négoce Métaux Atlantique', ['Négociant']); + if ($isNew) { + $negoce->setSiren('841611054'); + $negoce->setAccountNumber('F0001'); + $negoce->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES')); + $negoce->setNTva('FR12841611054'); + $negoce->setPaymentDelay($this->paymentDelay($manager, 'J30')); + $negoce->setPaymentType($this->paymentType($manager, 'VIREMENT')); + $negoce->setBank($this->bank($manager, 'SG')); + $this->addContact($negoce, 'Jean', 'Dubois', 'Responsable achats', '05 49 00 00 01', null, 'jean.dubois@negoce-metaux.fr'); + $this->addAddress($negoce, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '12 rue de la Ferraille', bennes: 4, triageProvider: true, categoryNames: ['Négociant']); + } + + // === LCR avec 1 RIB (RG-2.08) + 2 contacts === + [$coop, $isNew] = $this->ensureSupplier($manager, 'Coopérative Agricole du Sud-Ouest', ['Coopérative']); + if ($isNew) { + $coop->setSiren('775680459'); + $coop->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES')); + $coop->setPaymentDelay($this->paymentDelay($manager, 'J15')); + $coop->setPaymentType($this->paymentType($manager, 'LCR')); + $this->addContact($coop, 'Sophie', 'Marchand', 'Directrice', '05 56 10 20 30', '06 11 22 33 44', 'sophie.marchand@coop-so.fr', 0); + $this->addContact($coop, 'Marc', 'Girard', 'Acheteur', '05 56 10 20 31', null, 'marc.girard@coop-so.fr', 1); + $this->addAddress($coop, 'RENDU', ['Pommevic'], '82400', 'Pommevic', '8 route des Cooperateurs', bennes: 2); + $this->addRib($coop, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0); + } + + // === Prospect seul (adresse PROSPECT), compta minimale === + [$producteur, $isNew] = $this->ensureSupplier($manager, 'Producteur Bio Charente', ['Producteur']); + if ($isNew) { + $this->addContact($producteur, 'Claire', 'Moreau', 'Gérante', '05 49 21 22 23', null, 'claire.moreau@bio-charente.fr'); + $this->addAddress($producteur, 'PROSPECT', ['Saint-Jean'], '17400', 'Fontenet', '1 chemin des Producteurs'); + } + + // === Multi-categories M2M + LCR avec 2 RIB + 3 contacts === + [$grossiste, $isNew] = $this->ensureSupplier($manager, 'Grossiste Multi-Métaux', ['Grossiste', 'Négociant']); + if ($isNew) { + $grossiste->setSiren('552081317'); + $grossiste->setAccountNumber('F0004'); + $grossiste->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES')); + $grossiste->setNTva('FR45552081317'); + $grossiste->setPaymentDelay($this->paymentDelay($manager, 'J30')); + $grossiste->setPaymentType($this->paymentType($manager, 'LCR')); + $this->addContact($grossiste, 'Thomas', 'Petit', 'Directeur des achats', '05 56 31 32 33', '06 01 02 03 04', 'thomas.petit@grossiste-mm.fr', 0); + $this->addContact($grossiste, 'Julie', 'Roux', 'Assistante commerciale', '05 56 31 32 34', null, 'julie.roux@grossiste-mm.fr', 1); + $this->addContact($grossiste, 'Hélène', 'Faure', 'Logistique', '05 56 31 32 35', null, 'helene.faure@grossiste-mm.fr', 2); + $this->addAddress($grossiste, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '20 zone des Activités', streetComplement: 'Bâtiment C', bennes: 6, triageProvider: true, categoryNames: ['Grossiste', 'Négociant']); + $this->addRib($grossiste, 'Compte principal', 'BNPAFRPPXXX', 'FR7630006000011234567890189', 0); + $this->addRib($grossiste, 'Compte secondaire', 'SOGEFRPPXXX', 'FR7630001007941234567890185', 1); + } + + // === VIREMENT + banque, TVA intracom (importateur), multi-sites sur l'adresse === + [$import, $isNew] = $this->ensureSupplier($manager, 'Import Recyclage International', ['Importateur']); + if ($isNew) { + $import->setSiren('409512012'); + $import->setTvaMode($this->tvaMode($manager, 'INTRACOM_VENTES')); + $import->setNTva('FR90409512012'); + $import->setPaymentDelay($this->paymentDelay($manager, 'J30')); + $import->setPaymentType($this->paymentType($manager, 'VIREMENT')); + $import->setBank($this->bank($manager, 'CIC')); + $this->addContact($import, 'Paul', 'Garnier', 'Import manager', '05 56 44 55 66', null, 'paul.garnier@import-recyclage.fr', 0); + $this->addContact($import, null, 'Bernard', 'Douanes', '05 56 44 55 67', null, 'douanes@import-recyclage.fr', 1); + $this->addAddress($import, 'RENDU', ['Pommevic', 'Saint-Jean'], '82400', 'Pommevic', '3 quai des Importateurs', bennes: 8); + } + + // === Multi-adresses PROSPECT / DEPART / RENDU (RG-2.09) + VIREMENT/banque === + [$ferrailleur, $isNew] = $this->ensureSupplier($manager, 'Ferrailleur Grand Ouest', ['Négociant']); + if ($isNew) { + $ferrailleur->setSiren('732829320'); + $ferrailleur->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES')); + $ferrailleur->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION')); + $ferrailleur->setPaymentType($this->paymentType($manager, 'VIREMENT')); + $ferrailleur->setBank($this->bank($manager, 'CA')); + $this->addContact($ferrailleur, 'Olivier', 'Renard', 'Responsable site', '05 49 61 62 63', null, 'olivier.renard@ferrailleur-go.fr', 0); + $this->addContact($ferrailleur, 'Nadia', 'Benali', 'Pesée', '05 49 61 62 64', null, 'nadia.benali@ferrailleur-go.fr', 1); + // Prospect (site en cours de demarchage). + $this->addAddress($ferrailleur, 'PROSPECT', ['Chatellerault'], '86100', 'Châtellerault', '5 avenue de la Prospection', position: 0); + // Depart (collecte) multi-sites avec bennes + triage. + $this->addAddress($ferrailleur, 'DEPART', ['Saint-Jean', 'Pommevic'], '17400', 'Fontenet', '4 rue de la Collecte', bennes: 5, triageProvider: true, categoryNames: ['Négociant'], position: 1); + // Rendu (livraison). + $this->addAddress($ferrailleur, 'RENDU', ['Pommevic'], '82400', 'Pommevic', '7 boulevard du Rendu', bennes: 3, position: 2); + } + + // === Onglet Information complet (dont volumeForecast) + VIREMENT/banque === + [$holding, $isNew] = $this->ensureSupplier($manager, 'Holding Recyclage Premium', ['Importateur']); + if ($isNew) { + $holding->setDescription('Holding de recyclage diversifiée, présente sur le Grand Sud-Ouest.'); + $holding->setCompetitors('Groupe Atlantique Recyclage, Sud Métaux'); + $holding->setFoundedAt(new DateTimeImmutable('2008-09-01')); + $holding->setEmployeesCount(180); + $holding->setRevenueAmount('24500000.00'); + $holding->setDirectorName('Antoine Lefèvre'); + $holding->setProfitAmount('1850000.00'); + $holding->setVolumeForecast(120000); + $holding->setSiren('318471925'); + $holding->setAccountNumber('F0007'); + $holding->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES')); + $holding->setNTva('FR33318471925'); + $holding->setPaymentDelay($this->paymentDelay($manager, 'J30')); + $holding->setPaymentType($this->paymentType($manager, 'VIREMENT')); + $holding->setBank($this->bank($manager, 'SG')); + $this->addContact($holding, 'Antoine', 'Lefèvre', 'PDG', '05 56 51 52 53', null, 'antoine.lefevre@holding-recyclage.fr'); + $this->addAddress($holding, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '1 allée des Investisseurs', bennes: 5, triageProvider: true, categoryNames: ['Importateur']); + } + + // === Coop minimale — contact par le seul nom (RG-2.04), sans compta === + [$coopMin, $isNew] = $this->ensureSupplier($manager, 'Coop Métaux Réunis', ['Coopérative']); + if ($isNew) { + $this->addContact($coopMin, null, 'Caron', 'Président', '05 49 81 82 83', null, 'president@coop-metaux-reunis.fr'); + $this->addAddress($coopMin, 'DEPART', ['Saint-Jean'], '17400', 'Fontenet', '6 chemin du Village'); + } + + // === Reglement CHEQUE (sans banque ni RIB requis) === + [$petit, $isNew] = $this->ensureSupplier($manager, 'Petit Négoce Local', ['Négociant']); + if ($isNew) { + $petit->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES')); + $petit->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION')); + $petit->setPaymentType($this->paymentType($manager, 'CHEQUE')); + $this->addContact($petit, 'Luc', 'Martin', 'Gérant', '05 56 71 72 73', null, 'luc.martin@petit-negoce.fr'); + $this->addAddress($petit, 'RENDU', ['Chatellerault'], '86100', 'Châtellerault', '15 rue du Commerce'); + } + + // === Reglement NON_SOUMISE + adresse multi-sites avec triage === + [$recup, $isNew] = $this->ensureSupplier($manager, 'Récupération Métaux Express', ['Grossiste']); + if ($isNew) { + $recup->setSiren('490212019'); + $recup->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES')); + $recup->setPaymentDelay($this->paymentDelay($manager, 'J15')); + $recup->setPaymentType($this->paymentType($manager, 'NON_SOUMISE')); + $this->addContact($recup, 'Marie', 'Lemoine', 'Responsable', '05 49 77 88 99', null, 'marie.lemoine@recup-express.fr', 0); + $this->addContact($recup, 'Pierre', 'Durand', 'Chauffeur', '05 49 77 88 98', null, 'pierre.durand@recup-express.fr', 1); + $this->addAddress($recup, 'DEPART', ['Saint-Jean', 'Chatellerault'], '17400', 'Fontenet', '10 zone industrielle', bennes: 7, triageProvider: true, categoryNames: ['Grossiste']); + } + + // === Centre de tri — focus bennes/triage + multi-categories === + [$centre, $isNew] = $this->ensureSupplier($manager, 'Centre de Tri Sud', ['Producteur', 'Coopérative']); + if ($isNew) { + $centre->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION')); + $this->addContact($centre, 'Camille', 'Faure', 'Chef de centre', '05 56 91 92 93', null, 'camille.faure@centre-tri-sud.fr'); + $this->addAddress($centre, 'DEPART', ['Pommevic'], '82400', 'Pommevic', '2 route du Tri', bennes: 12, triageProvider: true, categoryNames: ['Producteur']); + } + + // === Fournisseur archive #1 (RG-2.17) === + [$ancien, $isNew] = $this->ensureSupplier($manager, 'Ancien Fournisseur Fermé', ['Producteur'], isArchived: true); + if ($isNew) { + $this->addContact($ancien, null, 'Lambert', 'Ancien contact', '05 49 99 99 99', null, 'contact@ancien-fournisseur.fr'); + $this->addAddress($ancien, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '99 rue Fermée'); + } + + // === Fournisseur archive #2 (RG-2.17) === + [$disparu, $isNew] = $this->ensureSupplier($manager, 'Négoce Disparu', ['Grossiste'], isArchived: true); + if ($isNew) { + $this->addContact($disparu, 'Gérard', 'Blanc', 'Ex-gérant', '05 56 00 00 00', null, 'gerard.blanc@negoce-disparu.fr'); + $this->addAddress($disparu, 'RENDU', ['Saint-Jean'], '17400', 'Fontenet', '0 impasse Oubliée'); + } + + $manager->flush(); + } + + /** + * Cree un fournisseur (base normalisee + categories de type FOURNISSEUR) + * s'il n'existe pas encore, sinon retourne l'existant. Retourne + * [Supplier, isNew] : isNew=false bloque la reconstruction des + * sous-collections (idempotence sans doublon). + * + * @param list $categoryNames categories de type FOURNISSEUR (RG-2.10) + * + * @return array{0: Supplier, 1: bool} + */ + private function ensureSupplier( + ObjectManager $manager, + string $companyName, + array $categoryNames, + bool $isArchived = false, + ): array { + $normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName); + + $existing = $manager->getRepository(Supplier::class)->findOneBy(['companyName' => $normalizedName]); + if ($existing instanceof Supplier) { + return [$existing, false]; + } + + $supplier = new Supplier(); + $supplier->setCompanyName($normalizedName); + + foreach ($categoryNames as $categoryName) { + $supplier->addCategory($this->category($manager, $categoryName)); + } + + if ($isArchived) { + $supplier->setIsArchived(true); + $supplier->setArchivedAt(new DateTimeImmutable()); + } + + $manager->persist($supplier); + + return [$supplier, true]; + } + + /** + * Ajoute un contact normalise au fournisseur (cascade persist via + * Supplier.contacts). Au moins firstName OU lastName est toujours fourni + * (RG-2.04, chk_supplier_contact_name). + */ + private function addContact( + Supplier $supplier, + ?string $firstName, + ?string $lastName, + ?string $jobTitle, + ?string $phonePrimary, + ?string $phoneSecondary, + ?string $email, + int $position = 0, + ): void { + $contact = new SupplierContact(); + $contact->setFirstName($this->normalizer->normalizePersonName($firstName)); + $contact->setLastName($this->normalizer->normalizePersonName($lastName)); + $contact->setJobTitle($jobTitle); + $contact->setPhonePrimary($this->normalizer->normalizePhone($phonePrimary)); + $contact->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary)); + $contact->setEmail($this->normalizer->normalizeEmail($email)); + $contact->setPosition($position); + + $supplier->addContact($contact); + } + + /** + * Ajoute une adresse au fournisseur (cascade persist via Supplier.addresses). + * Le type d'adresse est exclusif (PROSPECT | DEPART | RENDU — RG-2.09, + * chk_supplier_address_type) ; au moins un site est rattache (RG-2.06) ; les + * categories d'adresse sont de type FOURNISSEUR (RG-2.10). + * + * @param list $siteNames au moins un site (RG-2.06) + * @param list $categoryNames categories de type FOURNISSEUR (RG-2.10) + */ + private function addAddress( + Supplier $supplier, + string $addressType, + array $siteNames, + string $postalCode, + string $city, + string $street, + ?string $streetComplement = null, + ?int $bennes = null, + bool $triageProvider = false, + array $categoryNames = [], + int $position = 0, + ): void { + $address = new SupplierAddress(); + $address->setAddressType($addressType); + $address->setPostalCode($postalCode); + $address->setCity($city); + $address->setStreet($street); + $address->setStreetComplement($streetComplement); + $address->setBennes($bennes); + $address->setTriageProvider($triageProvider); + $address->setPosition($position); + + foreach ($siteNames as $siteName) { + $address->addSite($this->site($siteName)); + } + + foreach ($categoryNames as $categoryName) { + $address->addCategory($this->category($this->manager, $categoryName)); + } + + $supplier->addAddress($address); + } + + /** + * Ajoute un RIB au fournisseur (cascade persist via Supplier.ribs). IBAN/BIC + * valides (Assert\Iban/Bic non rejouee sur persist direct mais donnees + * coherentes pour le golden path / les tests). + */ + private function addRib(Supplier $supplier, string $label, string $bic, string $iban, int $position = 0): void + { + $rib = new SupplierRib(); + $rib->setLabel($label); + $rib->setBic($bic); + $rib->setIban($iban); + $rib->setPosition($position); + + $supplier->addRib($rib); + } + + /** + * Resout une categorie par son nom via le contrat Shared CategoryInterface + * (resolve_target_entities -> Category), sans importer le module Catalog + * (regle n°1). Mise en cache par nom. + */ + private function category(ObjectManager $manager, string $name): CategoryInterface + { + if (isset($this->categoryCache[$name])) { + return $this->categoryCache[$name]; + } + + $category = $manager->getRepository(CategoryInterface::class)->findOneBy([ + 'name' => $name, + 'deletedAt' => null, + ]); + + if (!$category instanceof CategoryInterface) { + throw new RuntimeException(sprintf( + 'Categorie "%s" introuvable : CategoryFixtures doit tourner avant SupplierFixtures.', + $name, + )); + } + + return $this->categoryCache[$name] = $category; + } + + /** + * Resout un site par son nom via le contrat Shared SiteProviderInterface, + * sans importer le module Sites (regle n°1). Mise en cache par nom. + */ + private function site(string $name): SiteInterface + { + if (isset($this->siteCache[$name])) { + return $this->siteCache[$name]; + } + + $site = $this->siteProvider->findByName($name); + + if (!$site instanceof SiteInterface) { + throw new RuntimeException(sprintf( + 'Site "%s" introuvable : SitesFixtures doit tourner avant SupplierFixtures.', + $name, + )); + } + + return $this->siteCache[$name] = $site; + } + + private function tvaMode(ObjectManager $manager, string $code): TvaMode + { + $mode = $manager->getRepository(TvaMode::class)->findOneBy(['code' => $code]); + + if (!$mode instanceof TvaMode) { + throw new RuntimeException(sprintf( + 'TvaMode "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.', + $code, + )); + } + + return $mode; + } + + private function paymentDelay(ObjectManager $manager, string $code): PaymentDelay + { + $delay = $manager->getRepository(PaymentDelay::class)->findOneBy(['code' => $code]); + + if (!$delay instanceof PaymentDelay) { + throw new RuntimeException(sprintf( + 'PaymentDelay "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.', + $code, + )); + } + + return $delay; + } + + private function paymentType(ObjectManager $manager, string $code): PaymentType + { + $type = $manager->getRepository(PaymentType::class)->findOneBy(['code' => $code]); + + if (!$type instanceof PaymentType) { + throw new RuntimeException(sprintf( + 'PaymentType "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.', + $code, + )); + } + + return $type; + } + + private function bank(ObjectManager $manager, string $code): Bank + { + $bank = $manager->getRepository(Bank::class)->findOneBy(['code' => $code]); + + if (!$bank instanceof Bank) { + throw new RuntimeException(sprintf( + 'Bank "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.', + $code, + )); + } + + return $bank; + } +} From e050a7b9106612bddf9ab2abd615311409a7935c Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Mon, 8 Jun 2026 07:49:48 +0000 Subject: [PATCH 16/21] =?UTF-8?q?test(commercial)=20:=20SupplierExportCont?= =?UTF-8?q?rollerTest=20sur=20base=20fournisseurs=20(cat=C3=A9gories=20FOU?= =?UTF-8?q?RNISSEUR,=20d=C3=A9dup=20F3)=20(ERP-113)=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suivi du finding F3 de la review ERP-92. **Test uniquement** — aucune modif de code applicatif (le controller d'export ERP-91 est correct). ### Problème (F3) `SupplierExportControllerTest` étendait `AbstractCommercialApiTestCase` et redéfinissait un `seedSupplier()` privé appelant `createCategory()` du parent → catégorie de **type CLIENT**, ce qui viole RG-2.10 dans les données de test (latent : l'export ne filtre pas par type de catégorie, mais le contrat de test était faux). ### Changements - Bascule de base : `extends AbstractSupplierApiTestCase` (helpers `seedSupplier`/`addContact`/`supplierCategory` sur type **FOURNISSEUR**). - Suppression du `seedSupplier()` privé (type CLIENT) et du `tearDown()` redondant — dédup F3. - `testExportUsesPrincipalContactColumns` : utilise `addContact()` de la base ; le téléphone secondaire (non porté par ce helper) est posé via le setter sur le contact retourné. - `testExportPopulatesCategoryAndSiteColumns` : l'assertion de la colonne « Catégories » dérive le libellé de `supplierCategory('NEGOCIANT')->getName()` au lieu de hardcoder le préfixe de nom de test (la base nomme `test_cli_cat_fr_negociant`). - Imports `Supplier` / `SupplierContact` / `DateTimeImmutable` retirés (inutilisés). ### Vérifications - `SupplierExportControllerTest` : 9 tests, 48 assertions — vert sous APP_DEBUG=0. - Suite complète `make test` : 574 tests, 2448 assertions — OK sous APP_DEBUG=0. - `make php-cs-fixer-allow-risky` : 0 correction. > MR stackée sur `feature/ERP-112-fixtures-fournisseurs`. --------- Co-authored-by: Matthieu Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/73 Co-authored-by: THOLOT DECHENE Matthieu Co-committed-by: THOLOT DECHENE Matthieu --- .../Api/SupplierExportControllerTest.php | 78 +++---------------- 1 file changed, 12 insertions(+), 66 deletions(-) diff --git a/tests/Module/Commercial/Api/SupplierExportControllerTest.php b/tests/Module/Commercial/Api/SupplierExportControllerTest.php index bbcd763..ca00e63 100644 --- a/tests/Module/Commercial/Api/SupplierExportControllerTest.php +++ b/tests/Module/Commercial/Api/SupplierExportControllerTest.php @@ -4,11 +4,8 @@ declare(strict_types=1); namespace App\Tests\Module\Commercial\Api; -use App\Module\Commercial\Domain\Entity\Supplier; use App\Module\Commercial\Domain\Entity\SupplierAddress; -use App\Module\Commercial\Domain\Entity\SupplierContact; use App\Module\Sites\Domain\Entity\Site; -use DateTimeImmutable; use PhpOffice\PhpSpreadsheet\IOFactory; /** @@ -23,24 +20,11 @@ use PhpOffice\PhpSpreadsheet\IOFactory; * * @internal */ -final class SupplierExportControllerTest extends AbstractCommercialApiTestCase +final class SupplierExportControllerTest extends AbstractSupplierApiTestCase { private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; private const string EXPORT_URL = '/api/suppliers/export.xlsx'; - /** - * Les fournisseurs doivent etre purges AVANT les categories de test (le parent - * supprime les categories `test_cli_cat_*`) : la jointure supplier_category est - * en ON DELETE CASCADE cote supplier mais RESTRICT cote category. Le DELETE DQL - * sur Supplier declenche le cascade BDD sur supplier_category / _contact / - * _address (et leurs sous-jointures), liberant les categories pour le parent. - */ - protected function tearDown(): void - { - $this->getEm()->createQuery('DELETE FROM '.Supplier::class)->execute(); - parent::tearDown(); - } - public function testExportReturnsXlsxResponseWithAttachmentFilename(): void { $client = $this->createAdminClient(); @@ -110,9 +94,13 @@ final class SupplierExportControllerTest extends AbstractCommercialApiTestCase $supplier = $this->seedSupplier('Contact Co'); // position 1 (secondaire) insere en premier... - $this->addContact($supplier, 'Secondaire', 'Bob', 1, '0600000001', '0600000002', 'bob@contact.co'); + $this->addContact($supplier, 'Bob', 'Secondaire', '0600000001', 'bob@contact.co', 1); // ...position 0 (principal) insere ensuite : c'est lui qui doit gagner. - $this->addContact($supplier, 'Principal', 'Alice', 0, '0612345678', '0698765432', 'alice@contact.co'); + $principal = $this->addContact($supplier, 'Alice', 'Principal', '0612345678', 'alice@contact.co', 0); + // Le telephone secondaire n'est pas porte par le helper de base : on le pose + // directement sur le contact principal pour alimenter la colonne dediee. + $principal->setPhoneSecondary('0698765432'); + $this->getEm()->flush(); $row = $this->rowFor($client->request('GET', self::EXPORT_URL)->getContent(), 'CONTACT CO'); @@ -149,8 +137,10 @@ final class SupplierExportControllerTest extends AbstractCommercialApiTestCase $flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent())); - // Colonne « Catégories » : libelle de la categorie du fournisseur (getName()). - self::assertStringContainsString('test_cli_cat_negociant', $flat); + // Colonne « Catégories » : libelle de la categorie FOURNISSEUR du fournisseur + // (getName()). On le derive du helper de base (idempotent) plutot que de + // hardcoder le prefixe de nom de test. + self::assertStringContainsString((string) $this->supplierCategory('NEGOCIANT')->getName(), $flat); // Colonne « Sites » : site agrege depuis l'adresse (RG-2.06). self::assertStringContainsString((string) $site->getName(), $flat); } @@ -206,50 +196,6 @@ final class SupplierExportControllerTest extends AbstractCommercialApiTestCase self::assertResponseStatusCodeSame(401); } - /** - * Seede directement un Supplier en base (sans passer par l'API), pour les - * tests de liste / archivage. Stocke le nom en MAJUSCULES pour refleter l'etat - * normalise (RG-2.12) qu'aurait produit le SupplierProcessor via l'API. - */ - private function seedSupplier(string $companyName, bool $isArchived = false, string $categoryCode = 'SECTEUR'): Supplier - { - $em = $this->getEm(); - $supplier = new Supplier(); - $supplier->setCompanyName(mb_strtoupper($companyName, 'UTF-8')); - $supplier->addCategory($this->createCategory($categoryCode)); - $supplier->setIsArchived($isArchived); - if ($isArchived) { - $supplier->setArchivedAt(new DateTimeImmutable()); - } - $em->persist($supplier); - $em->flush(); - - return $supplier; - } - - private function addContact( - Supplier $supplier, - string $lastName, - string $firstName, - int $position, - ?string $phonePrimary = null, - ?string $phoneSecondary = null, - ?string $email = null, - ): void { - $contact = new SupplierContact(); - $contact->setSupplier($supplier); - $contact->setLastName($lastName); - $contact->setFirstName($firstName); - $contact->setPosition($position); - $contact->setPhonePrimary($phonePrimary); - $contact->setPhoneSecondary($phoneSecondary); - $contact->setEmail($email); - - $supplier->addContact($contact); - $this->getEm()->persist($contact); - $this->getEm()->flush(); - } - /** * Relit le binaire XLSX d'une reponse et renvoie la grille de cellules. * @@ -284,7 +230,7 @@ final class SupplierExportControllerTest extends AbstractCommercialApiTestCase /** * Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName. * - * @return array|null + * @return null|array */ private function rowFor(string $binary, string $companyName): ?array { From f031c703931c2e7a07c0f0a302c923bd044a0804 Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Mon, 8 Jun 2026 08:04:20 +0000 Subject: [PATCH 17/21] chore: bump version to v0.1.94 --- config/version.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/version.yaml b/config/version.yaml index 27d2c4d..e8386b7 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.89' + app.version: '0.1.94' From 9cda225bdf078d97f15027f9a2c9a04fe6a981bc Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Mon, 8 Jun 2026 08:47:43 +0000 Subject: [PATCH 18/21] Correctifs post-review M2 fournisseurs (P1 + P2/P3 + alignement M1) (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctifs issus de la review lead du stack M2 fournisseurs (ERP-84→113), répartis en priorités. Base : `develop`. Suite verte : `make test` 577 tests / 2475 assertions, `php-cs-fixer` 0 correction. ## P1 — défauts bloquants - **ERP-89** — Le message de complétude Information ne fuit plus le nom de champ technique (`(champ "%s")` retiré). Correction miroir appliquée aux deux validators (Supplier + Client), accent uniformisé. Le `propertyPath` est conservé pour le mapping inline front. - **ERP-112** — La fixture fournisseurs résout désormais la catégorie en filtrant sur le type `FOURNISSEUR` (via `CategoryInterface::getCategoryTypeCode()`), évitant de rattacher une catégorie homonyme d'un autre type (RG-2.10). - **ERP-113** — Tests d'export complétés : dédup F3 (fournisseur multi-catégories rendu sur une seule ligne) ; gating SIREN prouvé via un utilisateur minimal non-admin portant `suppliers.view` + `suppliers.accounting.view` (nouveau helper `createUserWithPermissions`). ## P2 / P3 - **ERP-86** — `maxMessage` explicite sur `competitors` (Supplier). - **ERP-92** — Garde `skipIfSitesModuleDisabled()` sur le test POST adresse sans site (évite un faux positif si le module Sites est désactivé). - **ERP-89 bis** — Nouveau test : Admin authentifié non-Commerciale + Information incomplète → 200 (distinct du cas `user=null`). - **ERP-85** — `down()` de la migration fournisseurs en `DROP TABLE IF EXISTS`. - **ERP-87** — Reset de la mémoïsation payload en début de `process()` du SupplierProcessor + documentation du filtre `?archivedOnly` de l'export (parité avec le provider liste). - **spec-back.md (M2)** — Alignée sur le code (le code fait foi) : security PATCH `manage or accounting.manage`, gating accounting par ajout de groupe (`SupplierReadGroupContextBuilder`), anti-N+1 via `hydrateListCollections` (pas de fetch-join), types de colonnes réels (`IDENTITY` / `TIMESTAMP(0)`). ## Alignement M1 ↔ M2 - **ERP-86/87 (Client)** — Mêmes corrections appliquées aux jumeaux M1 : message `competitors` explicite + reset mémoïsation `ClientProcessor`. ## Décision actée - **RG-2.10 (catégorie)** : court-circuit conservé (une seule violation sur `categories`). Les violations partageant path + message sont fusionnées côté front ; ERP-101 (toutes les erreurs en un aller-retour) est déjà respecté car le Callback n'interrompt pas la validation des autres champs. --------- Co-authored-by: admin malio Co-authored-by: Matthieu Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/74 Co-authored-by: THOLOT DECHENE Matthieu Co-committed-by: THOLOT DECHENE Matthieu --- docs/specs/M2-suppliers/spec-back.md | 34 +++++++---- migrations/Version20260605130000.php | 16 ++--- ...ClientInformationCompletenessValidator.php | 5 +- ...pplierInformationCompletenessValidator.php | 5 +- .../Commercial/Domain/Entity/Client.php | 2 +- .../Commercial/Domain/Entity/Supplier.php | 2 +- .../State/Processor/ClientProcessor.php | 6 ++ .../State/Processor/SupplierProcessor.php | 6 ++ .../Controller/SupplierExportController.php | 5 ++ .../DataFixtures/SupplierFixtures.php | 28 ++++++--- .../Api/SupplierExportControllerTest.php | 59 ++++++++++++++++++- .../Api/SupplierSubResourceApiTest.php | 4 ++ .../Commercial/Unit/SupplierProcessorTest.php | 45 ++++++++++++++ tests/Module/Core/Api/AbstractApiTestCase.php | 48 +++++++++++---- 14 files changed, 220 insertions(+), 45 deletions(-) diff --git a/docs/specs/M2-suppliers/spec-back.md b/docs/specs/M2-suppliers/spec-back.md index 6906ef3..5d3e542 100644 --- a/docs/specs/M2-suppliers/spec-back.md +++ b/docs/specs/M2-suppliers/spec-back.md @@ -126,7 +126,7 @@ Toutes les entités métier nouvelles implémentent `TimestampableInterface` + ` Notes (miroir M1) : - **Compta édite uniquement l'onglet Comptabilité** (`accounting.manage`) d'un fournisseur existant. Compta ne peut pas **créer** un fournisseur (pas de `manage` global). -- **Commerciale** a `view` + `manage` mais **pas** `accounting.view` → l'onglet Comptabilité est masqué (front) et filtré (back, 2 niveaux : `security` API Platform + `SupplierProvider`). +- **Commerciale** a `view` + `manage` mais **pas** `accounting.view` → l'onglet Comptabilité est masqué (front) et filtré (back). Mécanisme réel (le code fait foi) : le groupe de lecture `supplier:read:accounting` n'est **pas** dans le contexte de sérialisation par défaut ; le `SupplierReadGroupContextBuilder` ne l'**ajoute** dynamiquement que si l'utilisateur porte `commercial.suppliers.accounting.view` (gating **par ajout** de groupe, jamais par retrait). Sans la permission, les champs comptables (et les RIB) ne sont donc jamais sérialisés. La colonne SIREN de l'export XLSX suit la même règle (`accounting.view`). - **Bureau** : `view` + `manage` (tout sauf Comptabilité). - **Usine** : aucune permission → item sidebar invisible, accès direct 403. @@ -159,9 +159,11 @@ final class SupplierFieldNormalizer Le formatage `XX XX XX XX XX` est fait à l'affichage côté front. Le back stocke `0612345678` (chiffres seuls). -### 2.12 Liste : embed catégories + sites + fetch-joins (cohérence M1/ERP-62) +### 2.12 Liste : embed catégories + sites + hydratation anti-N+1 (cohérence M1/ERP-62) -Décision d'alignement (02/06/2026) : la **liste** `GET /api/suppliers` **embarque** les `categories[]` (avec `code`/`name`) et les `sites[]` (avec `name`/`postalCode` — pas de `code`), comme la liste Clients après ERP-62 — et **non** des champs dérivés aplatis. Conséquence performance : le `DoctrineSupplierRepository` **DOIT** poser des **fetch-joins** (`leftJoin`+`addSelect`) sur `categories` et `addresses.sites` dans la requête de liste pour éviter le N+1. Les `sites` de la liste sont agrégés/dédoublonnés via `Supplier::getSites()` (cf. § 3.3). Le contrat de sérialisation (groupes `category:read` / `site:read` dans le contexte) est posé **une seule fois** sur l'entité — source de vérité unique, le front ne le redéfinit pas. +Décision d'alignement (02/06/2026) : la **liste** `GET /api/suppliers` **embarque** les `categories[]` (avec `code`/`name`) et les `sites[]` (avec `name`/`postalCode` — pas de `code`), comme la liste Clients après ERP-62 — et **non** des champs dérivés aplatis. + +Conséquence performance — **implémentation réelle (le code fait foi)** : le `DoctrineSupplierRepository` **ne fetch-joine PAS** les to-many dans la requête de sélection (`createListQueryBuilder` ne fait que filtres + tri). L'anti-N+1 passe par `hydrateListCollections()` (puis `hydrateContacts()`) : une fois le jeu de fournisseurs borné (page ou export), des requêtes **`IN` bornées séparées** remplissent `categories`, puis `addresses.sites`, puis `contacts` sur les **mêmes** instances `Supplier` (identity map). Ce découpage évite le **produit cartésien** qu'un fetch-join combiné `categories × addresses.sites` imposerait aux chemins non paginés (export, `?pagination=false`). Les `sites` de la liste sont agrégés/dédoublonnés via `Supplier::getSites()` (cf. § 3.3). Le contrat de sérialisation (groupes `category:read` / `site:read` dans le contexte) est posé **une seule fois** sur l'entité — source de vérité unique, le front ne le redéfinit pas. > Dépendance confirmée sur le JSON réel (#82 mergé) : `Category` expose `code`/`name` sous `category:read` ; `Site` expose `name`/`postalCode`/`city`/`color` sous `site:read` (**pas de `code`**). L'embed est pleinement matérialisé. @@ -213,6 +215,8 @@ Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrati > **Rappel règle ABSOLUE n°12** : chaque colonne créée ci-dessous DOIT recevoir son `COMMENT ON COLUMN`. Les 4 colonnes Timestampable/Blamable passent par le helper `addStandardTimestampableBlamableComments($schema, '')`. Le SQL ci-dessous montre la structure ; les `COMMENT ON COLUMN` (un par colonne métier) sont à écrire dans la migration (exemples §3.2.bis). +> **Types réels de la migration (le code fait foi)** : le SQL ci-dessous est *illustratif*. La migration mergée (`Version20260605130000`) utilise le **style aligné M1** : clés primaires en `INT GENERATED BY DEFAULT AS IDENTITY` (et **non** `SERIAL`) et horodatages en `TIMESTAMP(0) WITHOUT TIME ZONE` (et **non** `TIMESTAMPTZ`, car le `TimestampableBlamableTrait` mappe `datetime_immutable`). Garantit que `schema:update` reste un no-op une fois les entités mappées. + ```sql -- ===================================================================== -- Seed taxonomie : nouveau type FOURNISSEUR (référentiels comptables = M1, non recréés) @@ -422,8 +426,10 @@ use Symfony\Component\Validator\Constraints as Assert; // Cohérence M1/ERP-62 : la LISTE embarque catégories + sites (pas de // champ dérivé aplati). Maillon (c) : category:read + site:read dans // le contexte pour exposer Category(code/name) + Site(name/postalCode). - // ⚠ Le SupplierRepository DOIT fetch-join categories + addresses.sites - // pour éviter le N+1 sur la liste (cf. § 2.12). + // ⚠ Anti-N+1 : pas de fetch-join dans la requête de liste — le + // SupplierRepository hydrate categories/sites/contacts via des requêtes + // IN bornées séparées (hydrateListCollections), pour éviter le produit + // cartésien sur les chemins non paginés (export) — cf. § 2.12. normalizationContext: ['groups' => [ 'supplier:read', 'category:read', @@ -442,13 +448,14 @@ use Symfony\Component\Validator\Constraints as Assert; normalizationContext: ['groups' => [ 'supplier:read', 'supplier:item:read', // embed contacts / addresses - 'supplier:read:accounting', // scalaires compta + embed ribs (filtré par le Provider selon accounting.view) + // ⚠ supplier:read:accounting est volontairement ABSENT ici : il est + // AJOUTÉ dynamiquement par le SupplierReadGroupContextBuilder quand + // l'user porte accounting.view (gating par ajout, pas par retrait — + // parade bug #4 M1). Il porte les scalaires compta + l'embed ribs. 'category:read', // embed des Category (id/code/name) — relation imbriquée 'site:read', // embed des Site (id/name/postalCode/city/color, pas de code) — relation imbriquée 'default:read', ]], - // Le Provider RETIRE supplier:read:accounting du contexte si l'user - // n'a pas is_granted('commercial.suppliers.accounting.view'). provider: SupplierProvider::class, ), new Post( @@ -458,10 +465,13 @@ use Symfony\Component\Validator\Constraints as Assert; processor: SupplierProcessor::class, ), new Patch( - security: "is_granted('commercial.suppliers.manage')", - // Le SupplierProcessor inspecte les groupes envoyés pour autoriser - // onglet par onglet (cf. § 2.10 + § 5). Patch des champs comptables - // exige is_granted('commercial.suppliers.accounting.manage') ; + // Security élargie : `manage` OU `accounting.manage` — le rôle Compta + // n'a pas `manage` mais doit pouvoir éditer l'onglet Comptabilité d'un + // fournisseur existant (§ 2.9). Le SupplierProcessor re-gate ensuite + // onglet par onglet (mode strict RG-2.16) : + security: "is_granted('commercial.suppliers.manage') or is_granted('commercial.suppliers.accounting.manage')", + // Patch des champs comptables exige accounting.manage (guardAccounting) ; + // champs main/information exigent manage (guardManage) ; // patch isArchived exige is_granted('commercial.suppliers.archive'). normalizationContext: ['groups' => ['supplier:read', 'default:read']], denormalizationContext: ['groups' => [ diff --git a/migrations/Version20260605130000.php b/migrations/Version20260605130000.php index de1a1c1..70bdb48 100644 --- a/migrations/Version20260605130000.php +++ b/migrations/Version20260605130000.php @@ -82,14 +82,14 @@ final class Version20260605130000 extends AbstractMigration // Ordre inverse des dependances FK : jointures et sous-collections // d'abord, puis supplier. Les referentiels comptables et le // CategoryType FOURNISSEUR ne sont pas touches (crees ailleurs). - $this->addSql('DROP TABLE supplier_address_category'); - $this->addSql('DROP TABLE supplier_address_contact'); - $this->addSql('DROP TABLE supplier_address_site'); - $this->addSql('DROP TABLE supplier_rib'); - $this->addSql('DROP TABLE supplier_address'); - $this->addSql('DROP TABLE supplier_contact'); - $this->addSql('DROP TABLE supplier_category'); - $this->addSql('DROP TABLE supplier'); + $this->addSql('DROP TABLE IF EXISTS supplier_address_category'); + $this->addSql('DROP TABLE IF EXISTS supplier_address_contact'); + $this->addSql('DROP TABLE IF EXISTS supplier_address_site'); + $this->addSql('DROP TABLE IF EXISTS supplier_rib'); + $this->addSql('DROP TABLE IF EXISTS supplier_address'); + $this->addSql('DROP TABLE IF EXISTS supplier_contact'); + $this->addSql('DROP TABLE IF EXISTS supplier_category'); + $this->addSql('DROP TABLE IF EXISTS supplier'); } // ================================================================= diff --git a/src/Module/Commercial/Application/Validator/ClientInformationCompletenessValidator.php b/src/Module/Commercial/Application/Validator/ClientInformationCompletenessValidator.php index fe374fe..ec5c775 100644 --- a/src/Module/Commercial/Application/Validator/ClientInformationCompletenessValidator.php +++ b/src/Module/Commercial/Application/Validator/ClientInformationCompletenessValidator.php @@ -43,7 +43,10 @@ final class ClientInformationCompletenessValidator foreach ($fields as $property => $value) { if ($this->isMissing($value)) { $violations->add(new ConstraintViolation( - sprintf('Ce champ est obligatoire pour le role Commerciale (champ "%s").', $property), + // Pas de nom de champ technique dans le message : la violation est + // deja rattachee au bon champ via son propertyPath (mappe inline + // cote front par useFormErrors). + 'Ce champ est obligatoire pour le rôle Commerciale.', null, [], $client, diff --git a/src/Module/Commercial/Application/Validator/SupplierInformationCompletenessValidator.php b/src/Module/Commercial/Application/Validator/SupplierInformationCompletenessValidator.php index f5db864..21d4d84 100644 --- a/src/Module/Commercial/Application/Validator/SupplierInformationCompletenessValidator.php +++ b/src/Module/Commercial/Application/Validator/SupplierInformationCompletenessValidator.php @@ -47,7 +47,10 @@ final class SupplierInformationCompletenessValidator foreach ($fields as $property => $value) { if ($this->isMissing($value)) { $violations->add(new ConstraintViolation( - sprintf('Ce champ est obligatoire pour le rôle Commerciale (champ "%s").', $property), + // Pas de nom de champ technique dans le message : la violation est + // deja rattachee au bon champ via son propertyPath (mappe inline + // cote front par useFormErrors). + 'Ce champ est obligatoire pour le rôle Commerciale.', null, [], $supplier, diff --git a/src/Module/Commercial/Domain/Entity/Client.php b/src/Module/Commercial/Domain/Entity/Client.php index 69e6b39..387f94f 100644 --- a/src/Module/Commercial/Domain/Entity/Client.php +++ b/src/Module/Commercial/Domain/Entity/Client.php @@ -188,7 +188,7 @@ class Client implements TimestampableInterface, BlamableInterface private ?string $description = null; #[ORM\Column(length: 255, nullable: true)] - #[Assert\Length(max: 255, maxMessage: 'Ce champ ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client:read', 'client:write:information'])] private ?string $competitors = null; diff --git a/src/Module/Commercial/Domain/Entity/Supplier.php b/src/Module/Commercial/Domain/Entity/Supplier.php index 85c9b36..38d3951 100644 --- a/src/Module/Commercial/Domain/Entity/Supplier.php +++ b/src/Module/Commercial/Domain/Entity/Supplier.php @@ -181,7 +181,7 @@ class Supplier implements TimestampableInterface, BlamableInterface private ?string $description = null; #[ORM\Column(length: 255, nullable: true)] - #[Assert\Length(max: 255, maxMessage: 'Ce champ ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['supplier:read', 'supplier:write:information'])] private ?string $competitors = null; diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php index e451ee7..976ec6f 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php @@ -121,6 +121,12 @@ final class ClientProcessor implements ProcessorInterface return $this->persistProcessor->process($data, $operation, $uriVariables, $context); } + // Reinitialisation de la memoisation du payload en debut de traitement : + // le service est partage (stateful), on repart du corps de LA requete + // courante et on n'herite jamais des cles decodees d'une requete passee. + $this->decodedContent = null; + $this->decodedPayloadKeys = []; + $writableKeys = $this->writablePayloadKeys(); $isArchiveRequest = $this->guardArchive($data, $writableKeys); diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php index b2685d1..e66eb3c 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php @@ -114,6 +114,12 @@ final class SupplierProcessor implements ProcessorInterface return $this->persistProcessor->process($data, $operation, $uriVariables, $context); } + // Reinitialisation de la memoisation du payload en debut de traitement : + // le service est partage (stateful), on repart du corps de LA requete + // courante et on n'herite jamais des cles decodees d'une requete passee. + $this->decodedContent = null; + $this->decodedPayloadKeys = []; + $writableKeys = $this->writablePayloadKeys(); $isArchiveRequest = $this->guardArchive($data, $writableKeys); diff --git a/src/Module/Commercial/Infrastructure/Controller/SupplierExportController.php b/src/Module/Commercial/Infrastructure/Controller/SupplierExportController.php index 037e4dc..78f8edb 100644 --- a/src/Module/Commercial/Infrastructure/Controller/SupplierExportController.php +++ b/src/Module/Commercial/Infrastructure/Controller/SupplierExportController.php @@ -57,6 +57,11 @@ final class SupplierExportController #[IsGranted('commercial.suppliers.view')] public function __invoke(Request $request): Response { + // Memes filtres d'archivage que la vue liste (SupplierProvider) pour que + // l'export reflete exactement ce que l'utilisateur voit a l'ecran : + // - includeArchived : inclut les archives en plus des actifs ; + // - archivedOnly : restreint aux seules archives (prioritaire, cf. + // createListQueryBuilder). $includeArchived = $this->readBool($request->query->get('includeArchived')); $archivedOnly = $this->readBool($request->query->get('archivedOnly')); $search = $request->query->getString('search') ?: null; diff --git a/src/Module/Commercial/Infrastructure/DataFixtures/SupplierFixtures.php b/src/Module/Commercial/Infrastructure/DataFixtures/SupplierFixtures.php index ac89f22..6ef37fe 100644 --- a/src/Module/Commercial/Infrastructure/DataFixtures/SupplierFixtures.php +++ b/src/Module/Commercial/Infrastructure/DataFixtures/SupplierFixtures.php @@ -82,6 +82,12 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire; */ class SupplierFixtures extends Fixture implements DependentFixtureInterface { + /** + * Type de categorie exige pour un fournisseur et ses adresses (RG-2.10). + * Miroir de Supplier::REQUIRED_CATEGORY_TYPE_CODE (non importable — regle n°1). + */ + private const string SUPPLIER_CATEGORY_TYPE_CODE = 'FOURNISSEUR'; + /** Cache des categories resolues par nom (evite des requetes repetees). */ private array $categoryCache = []; @@ -415,19 +421,27 @@ class SupplierFixtures extends Fixture implements DependentFixtureInterface return $this->categoryCache[$name]; } - $category = $manager->getRepository(CategoryInterface::class)->findOneBy([ + // RG-2.10 : on filtre explicitement sur le type FOURNISSEUR. Un lookup par + // le seul `name` rattacherait une categorie homonyme d'un autre type (ex. + // futur PRESTA) — donc du MAUVAIS type — ce qui violerait « au moins une + // categorie de type FOURNISSEUR ». Le filtre type est porte cote PHP + // (findBy ne sait pas filtrer une propriete imbriquee categoryType.code). + $candidates = $manager->getRepository(CategoryInterface::class)->findBy([ 'name' => $name, 'deletedAt' => null, ]); - if (!$category instanceof CategoryInterface) { - throw new RuntimeException(sprintf( - 'Categorie "%s" introuvable : CategoryFixtures doit tourner avant SupplierFixtures.', - $name, - )); + foreach ($candidates as $candidate) { + if ($candidate instanceof CategoryInterface + && self::SUPPLIER_CATEGORY_TYPE_CODE === $candidate->getCategoryTypeCode()) { + return $this->categoryCache[$name] = $candidate; + } } - return $this->categoryCache[$name] = $category; + throw new RuntimeException(sprintf( + 'Categorie FOURNISSEUR "%s" introuvable : CategoryFixtures doit tourner avant SupplierFixtures.', + $name, + )); } /** diff --git a/tests/Module/Commercial/Api/SupplierExportControllerTest.php b/tests/Module/Commercial/Api/SupplierExportControllerTest.php index ca00e63..f39f741 100644 --- a/tests/Module/Commercial/Api/SupplierExportControllerTest.php +++ b/tests/Module/Commercial/Api/SupplierExportControllerTest.php @@ -15,8 +15,9 @@ use PhpOffice\PhpSpreadsheet\IOFactory; * Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des * archives par defaut, respect du filtre ?search, peuplement des colonnes * contact principal / categories / sites, gating de la colonne SIREN selon - * commercial.suppliers.accounting.view, 403 sans commercial.suppliers.view, - * 401 anonyme. + * commercial.suppliers.accounting.view (admin ET user minimal a permission + * explicite), dedup F3 (fournisseur multi-categories rendu sur une seule ligne), + * 403 sans commercial.suppliers.view, 401 anonyme. * * @internal */ @@ -178,6 +179,60 @@ final class SupplierExportControllerTest extends AbstractSupplierApiTestCase self::assertStringNotContainsString('987654321', $this->flatten($grid)); } + /** + * Gating SIREN prouve via une permission EXPLICITE (et non le bypass admin) : + * un user minimal portant uniquement commercial.suppliers.view + + * commercial.suppliers.accounting.view voit bien la colonne SIREN et sa + * valeur. Complement de testSirenColumnPresentWithAccountingView (admin), qui + * ne prouve pas que accounting.view SEULE suffit (l'admin bypasse le RBAC). + * Le pendant negatif (sans accounting.view -> colonne absente) est couvert par + * testSirenColumnAbsentWithoutAccountingView. + */ + public function testSirenColumnPresentForMinimalUserWithAccountingView(): void + { + // Seed via admin, puis relecture par un user non-admin a 2 permissions. + $this->createAdminClient(); + $supplier = $this->seedSupplier('Gated Siren Co'); + $em = $this->getEm(); + $supplier->setSiren('456789123'); + $em->flush(); + + $creds = $this->createUserWithPermissions([ + 'commercial.suppliers.view', + 'commercial.suppliers.accounting.view', + ]); + $viewer = $this->authenticatedClient($creds['username'], $creds['password']); + + $grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent()); + + self::assertContains('SIREN', $grid[0]); + self::assertStringContainsString('456789123', $this->flatten($grid)); + } + + /** + * Dedup F3 : un fournisseur portant >= 2 categories FOURNISSEUR est multiplie + * par la jointure (selection/hydratation des collections) ; l'export doit le + * rendre sur UNE SEULE ligne. On seede un fournisseur a 2 categories et on + * assert qu'il n'apparait qu'une fois dans la colonne « Nom fournisseur ». + */ + public function testExportDeduplicatesSupplierWithMultipleCategories(): void + { + $client = $this->createAdminClient(); + $supplier = $this->seedSupplier('Multi Cat Co', false, 'NEGOCIANT'); + // 2e categorie FOURNISSEUR sur le meme fournisseur (RG-2.10). + $supplier->addCategory($this->supplierCategory('GROSSISTE')); + $this->getEm()->flush(); + + $names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent()); + + $occurrences = count(array_filter($names, static fn (string $name): bool => 'MULTI CAT CO' === $name)); + self::assertSame( + 1, + $occurrences, + 'Un fournisseur multi-categories doit apparaitre sur une seule ligne (dedup F3).', + ); + } + public function testForbiddenWithoutSuppliersViewPermission(): void { $creds = $this->createUserWithPermission('core.users.view'); diff --git a/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php b/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php index 9a4aa25..77df0a9 100644 --- a/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php +++ b/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php @@ -126,6 +126,10 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase public function testPostAddressWithoutSiteReturns422(): void { + // Sans cette garde, un module Sites desactive renverrait 404 (route + // /addresses indisponible) et le test passerait pour la MAUVAISE raison + // au lieu de prouver RG-2.06 (Assert\Count min 1 sur sites). + $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedSupplier('Address No Site'); diff --git a/tests/Module/Commercial/Unit/SupplierProcessorTest.php b/tests/Module/Commercial/Unit/SupplierProcessorTest.php index 19b26f4..250ae0a 100644 --- a/tests/Module/Commercial/Unit/SupplierProcessorTest.php +++ b/tests/Module/Commercial/Unit/SupplierProcessorTest.php @@ -101,6 +101,24 @@ final class SupplierProcessorTest extends TestCase self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation())); } + public function testAdminIncompleteInformationPasses(): void + { + // Distinct du cas user=null : un utilisateur AUTHENTIFIE mais non-Commerciale + // (ici un admin, BusinessRoleAwareInterface renvoyant false pour tout role + // metier) n'est pas soumis a la completude Information -> 200 malgre un + // onglet Information incomplet. Prouve que le gate porte bien sur le ROLE + // metier Commerciale, et pas sur « il y a un utilisateur connecte ». + $supplier = $this->minimalSupplier(); + $supplier->setDescription('Une description'); + + $processor = $this->makeProcessor( + payload: ['description' => 'Une description'], + user: $this->adminUser(), + ); + + self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation())); + } + /** * @param list $granted Permissions accordees a l'utilisateur courant * @param array $payload Corps JSON simule de la requete @@ -175,6 +193,33 @@ final class SupplierProcessorTest extends TestCase return $this->createStub(Operation::class); } + /** + * Utilisateur authentifie non-Commerciale (profil admin) : porte + * BusinessRoleAwareInterface mais ne reconnait aucun role metier. Sert a + * distinguer « pas de role Commerciale » de « pas d'utilisateur » (null). + */ + private function adminUser(): UserInterface + { + return new class implements UserInterface, BusinessRoleAwareInterface { + public function hasBusinessRole(string $roleCode): bool + { + return false; + } + + public function getRoles(): array + { + return ['ROLE_ADMIN']; + } + + public function eraseCredentials(): void {} + + public function getUserIdentifier(): string + { + return 'admin-test'; + } + }; + } + private function commercialeUser(): UserInterface { return new class implements UserInterface, BusinessRoleAwareInterface { diff --git a/tests/Module/Core/Api/AbstractApiTestCase.php b/tests/Module/Core/Api/AbstractApiTestCase.php index 9f7bcc2..a258f32 100644 --- a/tests/Module/Core/Api/AbstractApiTestCase.php +++ b/tests/Module/Core/Api/AbstractApiTestCase.php @@ -90,6 +90,26 @@ abstract class AbstractApiTestCase extends ApiTestCase * @return array{username: string, password: string} Les identifiants pour authenticatedClient() */ protected function createUserWithPermission(string $permissionCode): array + { + return $this->createUserWithPermissions([$permissionCode]); + } + + /** + * Variante multi-permissions de {@see createUserWithPermission()} : cree un + * utilisateur non-admin portant PLUSIEURS permissions via un unique role + * jetable. Utile pour prouver qu'une combinaison precise de permissions + * (sans le bypass admin) suffit a debloquer un comportement — ex. la colonne + * SIREN de l'export, gatee par accounting.view EN PLUS de suppliers.view. + * + * Memes garanties que le singulier : suffixe aleatoire, password "testpass", + * rattachement a tous les sites, echec explicite si une permission est + * introuvable en base. + * + * @param list $permissionCodes codes des permissions a accorder + * + * @return array{username: string, password: string} identifiants pour authenticatedClient() + */ + protected function createUserWithPermissions(array $permissionCodes): array { if (!self::$kernel) { self::bootKernel(); @@ -97,17 +117,6 @@ abstract class AbstractApiTestCase extends ApiTestCase $em = $this->getEm(); - /** @var null|Permission $permission */ - $permission = $em->getRepository(Permission::class)->findOneBy(['code' => $permissionCode]); - - self::assertNotNull( - $permission, - sprintf( - 'Permission "%s" introuvable en base. Assurez-vous que `app:sync-permissions` a ete execute.', - $permissionCode, - ), - ); - $suffix = substr(bin2hex(random_bytes(4)), 0, 8); $username = 'testuser_'.$suffix; $password = 'testpass'; @@ -116,7 +125,22 @@ abstract class AbstractApiTestCase extends ApiTestCase $hasher = self::getContainer()->get(UserPasswordHasherInterface::class); $role = new Role('test_'.$suffix, 'Test Role '.$suffix, false); - $role->addPermission($permission); + + foreach ($permissionCodes as $permissionCode) { + /** @var null|Permission $permission */ + $permission = $em->getRepository(Permission::class)->findOneBy(['code' => $permissionCode]); + + self::assertNotNull( + $permission, + sprintf( + 'Permission "%s" introuvable en base. Assurez-vous que `app:sync-permissions` a ete execute.', + $permissionCode, + ), + ); + + $role->addPermission($permission); + } + $em->persist($role); $user = new User(); From 43b2251ef12b7ca5c63c21215681b772afcce48a Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Mon, 8 Jun 2026 08:50:41 +0000 Subject: [PATCH 19/21] chore: bump version to v0.1.95 --- config/version.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/version.yaml b/config/version.yaml index e8386b7..9027f77 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.94' + app.version: '0.1.95' From a9c14704b7848768255cbf856213ea2867687658 Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Mon, 8 Jun 2026 09:47:15 +0000 Subject: [PATCH 20/21] feat(catalog) : categories multi-types (M:N) + bouton Filtres liste (#75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Contexte Une `Category` ne pouvait appartenir qu'à **un seul** `CategoryType` (ManyToOne). Le besoin métier : plusieurs types par catégorie. Cette MR fait passer la relation en **ManyToMany** et ajoute le bouton **« Filtres »** à droite de la liste des catégories (modèle Répertoire Clients). Slice vertical complet (le passage M:N casse mécaniquement le contrat inter-module `CategoryInterface`, consommé par la RG-2.10 fournisseurs). ## Volet A — Relation M:N - `Category.categoryType` (ManyToOne) → `categoryTypes` (ManyToMany, jonction `category_category_type`). Au moins un type obligatoire (`Assert\\Count(min:1)`). - **Unicité du nom GLOBALE** parmi les actifs (`uq_category_name_active`, remplace `uq_category_name_type_active`). Message 409 reformulé. - Migration : table de jonction + backfill + drop colonne `category_type_id` + nouvel index. Validée **rejouable sur base fraîche**. - Contrat Shared : `getCategoryTypeCode()` → `getCategoryTypeCodes(): array`. `Supplier`/`SupplierAddress`/`SupplierFixtures` revalident « contient FOURNISSEUR » (RG-2.10). - Provider/Repository : filtre type via sous-requête `EXISTS` (ne tronque pas la collection embarquée), eager-load anti-N+1. ## Volet B — Bouton « Filtres » - Drawer recherche par nom + types multi (OR). Compteur de filtres actifs. État local, jamais persisté en URL. - Back : filtres `?name=` et `?typeId[]=` sur la collection. ## Front - Multi-select `MalioSelectCheckbox`, `useCategoryForm` en `categoryTypeIds[]`, colonne « Types », clés i18n. ## Tests / vérifs - `make test` : **582 tests, 2474 assertions, 0 échec** ✅ - `make nuxt-test` : **236 tests** ✅ - `make php-cs-fixer-allow-risky` ✅ - Migration rejouée sur base fraîche (`make db-reset`) ✅ - Nouveau `CategoryFilterTest` (name partiel + typeId[] OR + multi-type non dupliqué) --------- Co-authored-by: Matthieu Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/75 Co-authored-by: THOLOT DECHENE Matthieu Co-committed-by: THOLOT DECHENE Matthieu --- .gitea/workflows/pull-request.yml | 4 +- frontend/i18n/locales/fr.json | 17 +- .../catalog/components/CategoryDrawer.vue | 18 +- .../__tests__/useCategoryForm.spec.ts | 91 ++++++---- .../catalog/composables/useCategoryForm.ts | 55 +++--- .../catalog/pages/admin/categories.vue | 169 ++++++++++++++++-- frontend/modules/catalog/types/category.ts | 21 +-- makefile | 5 +- migrations/Version20260608120000.php | 149 +++++++++++++++ src/Module/Catalog/Domain/Entity/Category.php | 74 +++++--- .../CategoryRepositoryInterface.php | 24 ++- .../State/Processor/CategoryProcessor.php | 10 +- .../State/Provider/CategoryProvider.php | 51 +++++- .../DataFixtures/CategoryFixtures.php | 2 +- .../Doctrine/DoctrineCategoryRepository.php | 53 +++++- .../Commercial/Domain/Entity/Supplier.php | 13 +- .../Domain/Entity/SupplierAddress.php | 13 +- .../DataFixtures/SupplierFixtures.php | 13 +- .../Domain/Contract/CategoryInterface.php | 14 +- .../Database/ColumnCommentsCatalog.php | 17 +- .../Api/AbstractCatalogApiTestCase.php | 11 +- .../Module/Catalog/Api/CategoryAuditTest.php | 4 +- tests/Module/Catalog/Api/CategoryCodeTest.php | 10 +- .../Module/Catalog/Api/CategoryFilterTest.php | 137 ++++++++++++++ .../Catalog/Api/CategoryPermissionsTest.php | 8 +- .../Api/CategoryTimestampableBlamableTest.php | 6 +- .../Api/CategoryTypeCodeFilterTest.php | 7 +- .../Module/Catalog/Api/CategoryUniqueTest.php | 74 ++++---- .../Catalog/Api/CategoryValidationTest.php | 69 +++---- .../Api/AbstractCommercialApiTestCase.php | 2 +- .../Api/AbstractSupplierApiTestCase.php | 2 +- .../Domain/Entity/SupplierValidationTest.php | 30 +++- 32 files changed, 913 insertions(+), 260 deletions(-) create mode 100644 migrations/Version20260608120000.php create mode 100644 tests/Module/Catalog/Api/CategoryFilterTest.php diff --git a/.gitea/workflows/pull-request.yml b/.gitea/workflows/pull-request.yml index 56062ab..c981a71 100644 --- a/.gitea/workflows/pull-request.yml +++ b/.gitea/workflows/pull-request.yml @@ -75,7 +75,7 @@ jobs: - name: Bootstrap test database # Aligne sur la cible `test-db-setup` du makefile : apres # `schema:update --force`, on RECREE manuellement l'index unique - # partiel `uq_category_name_type_active` car Doctrine ORM ne sait + # partiel `uq_category_name_active` car Doctrine ORM ne sait # pas exprimer les index fonctionnels partiels (LOWER(name) + WHERE # deleted_at IS NULL) et `schema:update` les considere comme # orphelins et les DROP — collisions non detectees, tests d'unicite @@ -89,7 +89,7 @@ jobs: php bin/console app:apply-column-comments --env=test --no-interaction php bin/console doctrine:fixtures:load --env=test --no-interaction php bin/console app:sync-permissions --env=test --no-interaction - php bin/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" + php bin/console --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_active ON category (LOWER(name)) WHERE deleted_at IS NULL" - name: Run PHPUnit run: php -d memory_limit=512M vendor/bin/phpunit diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 39a87f1..3f20c17 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -420,17 +420,24 @@ "noCategories": "Aucune catégorie pour l'instant.", "table": { "name": "Nom", - "type": "Type" + "types": "Types" + }, + "filters": { + "title": "Filtres", + "search": "Recherche", + "types": "Types de catégorie", + "apply": "Voir les résultats", + "reset": "Réinitialiser" }, "form": { "name": "Nom", - "type": "Type de catégorie", - "typePlaceholder": "Sélectionner un type" + "types": "Types de catégorie", + "typesPlaceholder": "Sélectionner un ou plusieurs types" }, "validation": { "nameRequired": "Le nom est obligatoire.", "nameLength": "Le nom doit faire entre 2 et 120 caractères.", - "typeRequired": "Le type de catégorie est obligatoire." + "typesRequired": "Sélectionnez au moins un type de catégorie." }, "delete": { "title": "Supprimer la catégorie", @@ -440,7 +447,7 @@ "created": "Catégorie créée avec succès", "updated": "Catégorie mise à jour avec succès", "deleted": "Catégorie supprimée avec succès", - "duplicate": "Une catégorie nommée « {name} » existe déjà pour ce type.", + "duplicate": "Une catégorie nommée « {name} » existe déjà.", "typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez." } } diff --git a/frontend/modules/catalog/components/CategoryDrawer.vue b/frontend/modules/catalog/components/CategoryDrawer.vue index 9ada8aa..1a77541 100644 --- a/frontend/modules/catalog/components/CategoryDrawer.vue +++ b/frontend/modules/catalog/components/CategoryDrawer.vue @@ -24,16 +24,18 @@ required /> - - + diff --git a/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts b/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts index 5a14387..c46cffe 100644 --- a/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts +++ b/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts @@ -39,7 +39,7 @@ const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' } const CAT: Category = { id: 42, name: 'Vis', - categoryType: TYPE_VENTE, + categoryTypes: [TYPE_VENTE], deletedAt: null, createdAt: '2026-01-01T10:00:00+00:00', updatedAt: '2026-01-01T10:00:00+00:00', @@ -58,25 +58,25 @@ describe('useCategoryForm', () => { }) describe('loadFrom', () => { - it('pre-remplit le formulaire depuis une categorie existante', () => { + it('pre-remplit le formulaire depuis une categorie existante (multi-types)', () => { const form = useCategoryForm() - form.loadFrom(CAT) + form.loadFrom({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] }) expect(form.name.value).toBe('Vis') - expect(form.categoryTypeId.value).toBe(1) + expect(form.categoryTypeIds.value).toEqual([1, 2]) expect(form.errors).toEqual({}) }) it('vide le formulaire en mode creation (null)', () => { const form = useCategoryForm() form.name.value = 'old' - form.categoryTypeId.value = 99 + form.categoryTypeIds.value = [99] form.loadFrom(null) expect(form.name.value).toBe('') - expect(form.categoryTypeId.value).toBeNull() + expect(form.categoryTypeIds.value).toEqual([]) }) it('reinitialise le snapshot initial → isDirty=false juste apres', () => { @@ -98,13 +98,32 @@ describe('useCategoryForm', () => { expect(form.isDirty.value).toBe(true) }) + + it('passe a true quand on ajoute un type (selection multi)', () => { + const form = useCategoryForm() + form.loadFrom(CAT) + expect(form.isDirty.value).toBe(false) + + form.categoryTypeIds.value = [1, 2] + + expect(form.isDirty.value).toBe(true) + }) + + it('reste false si la selection est identique dans un autre ordre', () => { + const form = useCategoryForm() + form.loadFrom({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] }) + + form.categoryTypeIds.value = [2, 1] + + expect(form.isDirty.value).toBe(false) + }) }) describe('validate', () => { it('signale une erreur si name est vide (RG-1.02)', () => { const form = useCategoryForm() form.name.value = '' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] const ok = form.validate() @@ -115,7 +134,7 @@ describe('useCategoryForm', () => { it('signale erreur si name est whitespace-only (trim → vide)', () => { const form = useCategoryForm() form.name.value = ' ' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] const ok = form.validate() @@ -126,7 +145,7 @@ describe('useCategoryForm', () => { it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => { const form = useCategoryForm() form.name.value = 'A' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] const ok = form.validate() @@ -137,7 +156,7 @@ describe('useCategoryForm', () => { it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => { const form = useCategoryForm() form.name.value = 'A'.repeat(121) - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] const ok = form.validate() @@ -145,21 +164,21 @@ describe('useCategoryForm', () => { expect(form.errors.name).toBe('admin.categories.validation.nameLength') }) - it('signale erreur si categoryTypeId est null (RG-1.05)', () => { + it('signale erreur si aucun type selectionne (RG-1.05)', () => { const form = useCategoryForm() form.name.value = 'Vis' - form.categoryTypeId.value = null + form.categoryTypeIds.value = [] const ok = form.validate() expect(ok).toBe(false) - expect(form.errors.categoryType).toBe('admin.categories.validation.typeRequired') + expect(form.errors.categoryTypes).toBe('admin.categories.validation.typesRequired') }) - it('passe quand name et categoryType sont valides', () => { + it('passe quand name et au moins un type sont valides', () => { const form = useCategoryForm() form.name.value = 'Vis' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1, 2] const ok = form.validate() @@ -171,7 +190,7 @@ describe('useCategoryForm', () => { const form = useCategoryForm() // Erreur prealable : une validation en echec peuple errors.name. form.name.value = '' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] form.validate() expect(form.errors.name).toBeTruthy() @@ -184,17 +203,17 @@ describe('useCategoryForm', () => { }) describe('submitCreate', () => { - it('appelle POST /categories avec body { name trimme, categoryType en IRI }', async () => { + it('appelle POST /categories avec body { name trimme, categoryTypes en IRI[] }', async () => { mockPost.mockResolvedValueOnce(CAT) const form = useCategoryForm() form.name.value = ' Vis ' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1, 2] const result = await form.submitCreate() expect(mockPost).toHaveBeenCalledWith( '/categories', - { name: 'Vis', categoryType: '/api/category_types/1' }, + { name: 'Vis', categoryTypes: ['/api/category_types/1', '/api/category_types/2'] }, { toast: false }, ) expect(result).toEqual(CAT) @@ -203,7 +222,7 @@ describe('useCategoryForm', () => { it('ne declenche aucun appel API si la validation client echoue', async () => { const form = useCategoryForm() form.name.value = '' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] const result = await form.submitCreate() @@ -215,7 +234,7 @@ describe('useCategoryForm', () => { mockPost.mockResolvedValueOnce(CAT) const form = useCategoryForm() form.name.value = 'Vis' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] await form.submitCreate() @@ -231,7 +250,7 @@ describe('useCategoryForm', () => { }) const form = useCategoryForm() form.name.value = 'Vis' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] const result = await form.submitCreate() @@ -258,7 +277,7 @@ describe('useCategoryForm', () => { }) const form = useCategoryForm() form.name.value = 'Vis' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] const result = await form.submitCreate() @@ -269,24 +288,24 @@ describe('useCategoryForm', () => { expect(mockToastError).not.toHaveBeenCalled() }) - it('mappe aussi hydra:violations (negociation de format alternative)', async () => { + it('mappe une violation sur categoryTypes (hydra:violations alternative)', async () => { mockPost.mockRejectedValueOnce({ response: { status: 422, _data: { 'hydra:violations': [ - { propertyPath: 'categoryType', message: 'Type invalide.' }, + { propertyPath: 'categoryTypes', message: 'Sélectionnez au moins un type de catégorie.' }, ], }, }, }) const form = useCategoryForm() form.name.value = 'Vis' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] await form.submitCreate() - expect(form.errors.categoryType).toBe('Type invalide.') + expect(form.errors.categoryTypes).toBe('Sélectionnez au moins un type de catégorie.') }) it('fallback en toast generique si le status n est ni 409 ni 422', async () => { @@ -295,7 +314,7 @@ describe('useCategoryForm', () => { }) const form = useCategoryForm() form.name.value = 'Vis' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] await form.submitCreate() @@ -314,7 +333,7 @@ describe('useCategoryForm', () => { ) const form = useCategoryForm() form.name.value = 'Vis' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] const pending = form.submitCreate() expect(form.submitting.value).toBe(true) @@ -331,28 +350,28 @@ describe('useCategoryForm', () => { mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' }) const form = useCategoryForm() form.loadFrom(CAT) - form.name.value = 'Vis V2' // categoryTypeId inchange + form.name.value = 'Vis V2' // types inchanges await form.submitUpdate(42) expect(mockPatch).toHaveBeenCalledWith( '/categories/42', - { name: 'Vis V2' }, // pas de categoryType car non modifie + { name: 'Vis V2' }, // pas de categoryTypes car non modifies { toast: false }, ) }) - it('envoie categoryType en IRI quand seul le type a change', async () => { - mockPatch.mockResolvedValueOnce({ ...CAT, categoryType: TYPE_ACHAT }) + it('envoie categoryTypes en IRI[] quand on ajoute un type', async () => { + mockPatch.mockResolvedValueOnce({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] }) const form = useCategoryForm() form.loadFrom(CAT) - form.categoryTypeId.value = 2 + form.categoryTypeIds.value = [1, 2] await form.submitUpdate(42) expect(mockPatch).toHaveBeenCalledWith( '/categories/42', - { categoryType: '/api/category_types/2' }, + { categoryTypes: ['/api/category_types/1', '/api/category_types/2'] }, { toast: false }, ) }) @@ -438,7 +457,7 @@ describe('useCategoryForm', () => { form.reset() expect(form.name.value).toBe('') - expect(form.categoryTypeId.value).toBeNull() + expect(form.categoryTypeIds.value).toEqual([]) expect(form.errors).toEqual({}) expect(form.submitting.value).toBe(false) }) diff --git a/frontend/modules/catalog/composables/useCategoryForm.ts b/frontend/modules/catalog/composables/useCategoryForm.ts index 689b9f3..2fadf5e 100644 --- a/frontend/modules/catalog/composables/useCategoryForm.ts +++ b/frontend/modules/catalog/composables/useCategoryForm.ts @@ -13,9 +13,10 @@ * revalide toujours (defense en profondeur). * * Erreurs par champ : delegue a `useFormErrors` (convention ERP-101). Les - * violations 422 sont mappees par `propertyPath` (`name`, `categoryType`) ; + * violations 422 sont mappees par `propertyPath` (`name`, `categoryTypes`) ; * l'erreur globale (status != 422 exploitable) part en toast. Le 409 (doublon - * RG-1.07) reste un cas metier specifique : erreur inline sur `name` + toast. + * de nom GLOBAL, RG-1.07) reste un cas metier specifique : erreur inline sur + * `name` + toast. */ import { computed, ref } from 'vue' import type { Category } from '~/modules/catalog/types/category' @@ -42,20 +43,29 @@ export function useCategoryForm() { // State local du formulaire — pas singleton, chaque appel a useCategoryForm // cree son propre state (cohérent avec le pattern « un drawer = un form »). const name = ref('') - const categoryTypeId = ref(null) + const categoryTypeIds = ref([]) // Snapshot des valeurs initiales : sert a calculer `isDirty` pour le // pattern view → edit du drawer (le bouton Enregistrer reste masque tant // que rien n'a change en mode consultation). const initialName = ref('') - const initialCategoryTypeId = ref(null) + const initialCategoryTypeIds = ref([]) const submitting = ref(false) + // Compare deux listes d'ids sans tenir compte de l'ordre (la selection + // multi-types n'est pas ordonnee). + function sameIds(a: number[], b: number[]): boolean { + if (a.length !== b.length) return false + const sortedA = [...a].sort((x, y) => x - y) + const sortedB = [...b].sort((x, y) => x - y) + return sortedA.every((v, i) => v === sortedB[i]) + } + const isDirty = computed( () => name.value !== initialName.value - || categoryTypeId.value !== initialCategoryTypeId.value, + || !sameIds(categoryTypeIds.value, initialCategoryTypeIds.value), ) /** @@ -66,15 +76,16 @@ export function useCategoryForm() { function loadFrom(category: Category | null): void { formErrors.clearErrors() if (category) { + const ids = category.categoryTypes.map(t => t.id) name.value = category.name - categoryTypeId.value = category.categoryType.id + categoryTypeIds.value = [...ids] initialName.value = category.name - initialCategoryTypeId.value = category.categoryType.id + initialCategoryTypeIds.value = [...ids] } else { name.value = '' - categoryTypeId.value = null + categoryTypeIds.value = [] initialName.value = '' - initialCategoryTypeId.value = null + initialCategoryTypeIds.value = [] } } @@ -95,23 +106,23 @@ export function useCategoryForm() { formErrors.setError('name', t('admin.categories.validation.nameLength')) } - // RG-1.05 — categoryType obligatoire. - if (categoryTypeId.value === null) { - formErrors.setError('categoryType', t('admin.categories.validation.typeRequired')) + // RG-1.05 — au moins un type obligatoire. + if (categoryTypeIds.value.length === 0) { + formErrors.setError('categoryTypes', t('admin.categories.validation.typesRequired')) } - return !formErrors.errors.name && !formErrors.errors.categoryType + return !formErrors.errors.name && !formErrors.errors.categoryTypes } /** - * Construit le payload POST a partir du state. Le `categoryType` est - * envoye en IRI Hydra (`/api/category_types/{id}`) — convention API - * Platform pour referencer une ressource liee. + * Construit le payload POST a partir du state. Les `categoryTypes` sont + * envoyes en tableau d'IRI Hydra (`/api/category_types/{id}`) — convention + * API Platform pour referencer une collection de ressources liees. */ function buildCreatePayload(): Record { return { name: name.value.trim(), - categoryType: `/api/category_types/${categoryTypeId.value}`, + categoryTypes: categoryTypeIds.value.map(id => `/api/category_types/${id}`), } } @@ -174,8 +185,8 @@ export function useCategoryForm() { if (name.value !== initialName.value) { payload.name = name.value.trim() } - if (categoryTypeId.value !== initialCategoryTypeId.value) { - payload.categoryType = `/api/category_types/${categoryTypeId.value}` + if (!sameIds(categoryTypeIds.value, initialCategoryTypeIds.value)) { + payload.categoryTypes = categoryTypeIds.value.map(id => `/api/category_types/${id}`) } // Garde-fou : un PATCH sans changement ne sert a rien. Theoriquement // empeche par le drawer (bouton Enregistrer masque si !isDirty) mais @@ -233,9 +244,9 @@ export function useCategoryForm() { */ function reset(): void { name.value = '' - categoryTypeId.value = null + categoryTypeIds.value = [] initialName.value = '' - initialCategoryTypeId.value = null + initialCategoryTypeIds.value = [] formErrors.clearErrors() submitting.value = false } @@ -243,7 +254,7 @@ export function useCategoryForm() { return { // State name, - categoryTypeId, + categoryTypeIds, errors: formErrors.errors, submitting, isDirty, diff --git a/frontend/modules/catalog/pages/admin/categories.vue b/frontend/modules/catalog/pages/admin/categories.vue index 6d4104e..784b16e 100644 --- a/frontend/modules/catalog/pages/admin/categories.vue +++ b/frontend/modules/catalog/pages/admin/categories.vue @@ -3,13 +3,28 @@ {{ t('admin.categories.title') }} @@ -47,6 +62,60 @@ :loading="deleting" @confirm="handleDelete" /> + + + + + + + + + + + + + +
+ +
+
+
+ + +
@@ -55,7 +124,7 @@ import type { Category } from '~/modules/catalog/types/category' const { t } = useI18n() const { can } = usePermissions() -const { fetchTypes } = useCategoriesAdmin() +const { types, fetchTypes } = useCategoriesAdmin() const { submitDelete } = useCategoryForm() useHead({ title: t('admin.categories.title') }) @@ -74,6 +143,7 @@ const { fetch: fetchCategories, goToPage, setItemsPerPage, + setFilters, } = usePaginatedList({ url: '/categories' }) const drawerOpen = ref(false) @@ -82,21 +152,96 @@ const deleteModalOpen = ref(false) const categoryToDelete = ref(null) const deleting = ref(false) -// Colonnes du datatable. Le type est embarque cote API (cf. spec-back § 3.4) — -// on aplatit en label lisible pour l'affichage. +// Colonnes du datatable. Les types sont embarques cote API (ManyToMany) — on +// aplatit en libelles joints par une virgule pour l'affichage. const columns = [ { key: 'name', label: t('admin.categories.table.name') }, - { key: 'typeLabel', label: t('admin.categories.table.type') }, + { key: 'typesLabel', label: t('admin.categories.table.types') }, ] const categoryItems = computed(() => categories.value.map(cat => ({ id: cat.id, name: cat.name, - typeLabel: cat.categoryType?.label ?? '', + typesLabel: (cat.categoryTypes ?? []).map(ct => ct.label).join(', '), })), ) +// ── Filtres (drawer) ──────────────────────────────────────────────────────── +// Deux niveaux d'etat (pattern Repertoire Clients) : +// - APPLIED : pilote la liste + le compteur du bouton. Modifie uniquement au +// clic « Appliquer » / « Réinitialiser ». +// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation. +const filterDrawerOpen = ref(false) + +const draftSearch = ref('') +const draftTypeIds = ref([]) + +const appliedSearch = ref('') +const appliedTypeIds = ref([]) + +// Options du filtre Type(s), derivees du referentiel deja charge (fetchTypes). +const typeFilterOptions = computed(() => + types.value.map(ct => ({ value: ct.id, label: ct.label })), +) + +const activeFilterCount = computed(() => { + let count = 0 + if (appliedSearch.value.trim() !== '') count++ + if (appliedTypeIds.value.length > 0) count++ + return count +}) + +const filterButtonLabel = computed(() => { + const base = t('admin.categories.filters.title') + return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base +}) + +// Recopie l'etat applique vers le brouillon puis ouvre le drawer. +function openFilters(): void { + draftSearch.value = appliedSearch.value + draftTypeIds.value = [...appliedTypeIds.value] + filterDrawerOpen.value = true +} + +function toggleType(id: number, selected: boolean): void { + draftTypeIds.value = selected + ? [...draftTypeIds.value, id] + : draftTypeIds.value.filter(t => t !== id) +} + +/** + * Construit le payload de filtres serveur a partir de l'etat applique. Cle + * `typeId[]` pour que PHP la parse en tableau (OR cote back). Filtres vides omis. + */ +function buildFilterPayload(): Record { + const payload: Record = {} + if (appliedSearch.value.trim() !== '') payload.name = appliedSearch.value.trim() + if (appliedTypeIds.value.length > 0) payload['typeId[]'] = appliedTypeIds.value.map(String) + return payload +} + +// « Appliquer » : recopie brouillon → applied, pousse les filtres (retombe en +// page 1 via usePaginatedList) et ferme le drawer. +function applyFilters(): void { + appliedSearch.value = draftSearch.value.trim() + appliedTypeIds.value = [...draftTypeIds.value] + + setFilters(buildFilterPayload(), { replace: true }) + filterDrawerOpen.value = false +} + +// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete. +// Le drawer reste ouvert pour montrer le formulaire vide. +function resetFilters(): void { + draftSearch.value = '' + draftTypeIds.value = [] + appliedSearch.value = '' + appliedTypeIds.value = [] + + setFilters({}, { replace: true }) +} + function getCategoryById(id: number): Category | undefined { return categories.value.find(c => c.id === id) } diff --git a/frontend/modules/catalog/types/category.ts b/frontend/modules/catalog/types/category.ts index acb154d..4cc9947 100644 --- a/frontend/modules/catalog/types/category.ts +++ b/frontend/modules/catalog/types/category.ts @@ -4,15 +4,15 @@ * Contrats API consommes : * - GET /api/categories → HydraCollection * - GET /api/categories/{id} → Category - * - POST /api/categories → body { name, categoryType: IRI } - * - PATCH /api/categories/{id} → body partiel { name?, categoryType?: IRI } + * - POST /api/categories → body { name, categoryTypes: IRI[] } + * - PATCH /api/categories/{id} → body partiel { name?, categoryTypes?: IRI[] } * - DELETE /api/categories/{id} → 204 (soft delete via CategoryProcessor) * - GET /api/category_types → HydraCollection * * Notes : - * - Les IRI sont envoyes en POST/PATCH (ex. "/api/category_types/3"). - * - `categoryType` est embarque (groupe Serializer `category:read` sur les - * proprietes de CategoryType, cf. spec-back § 3.4). + * - Les IRI sont envoyes en POST/PATCH (ex. ["/api/category_types/3"]). + * - `categoryTypes` est embarque (groupe Serializer `category:read` sur les + * proprietes de CategoryType) : tableau d'objets type en lecture. * - `createdBy` / `updatedBy` peuvent etre `null` (hors contexte HTTP, * ON DELETE SET NULL en BDD). Affichage : libelle "Systeme" si null. */ @@ -43,7 +43,8 @@ export interface CategoryType { export interface Category { id: number name: string - categoryType: CategoryType + /** Types de la categorie (>= 1, ManyToMany embarque en lecture). */ + categoryTypes: CategoryType[] /** Soft delete : null = active, valeur = supprimee logiquement le {date}. */ deletedAt: string | null createdAt: string @@ -53,12 +54,12 @@ export interface Category { } /** - * Payload accepte en POST /api/categories. `categoryType` est envoye en - * IRI Hydra (ex. `/api/category_types/3`). + * Payload accepte en POST /api/categories. `categoryTypes` est un tableau + * d'IRI Hydra (ex. `['/api/category_types/3', '/api/category_types/5']`). */ export interface CategoryCreateInput { name: string - categoryType: string + categoryTypes: string[] } /** @@ -67,5 +68,5 @@ export interface CategoryCreateInput { */ export interface CategoryUpdateInput { name?: string - categoryType?: string + categoryTypes?: string[] } diff --git a/makefile b/makefile index 0367e92..b749e4b 100644 --- a/makefile +++ b/makefile @@ -207,7 +207,8 @@ migration-migrate: # orphelins du mapping ORM. Les index partiels (LOWER + WHERE) ne sont pas # 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_name_active` (M0 Catalog) : unicite GLOBALE du nom parmi +# les actifs (M:N categorie<->type), 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 @@ -226,7 +227,7 @@ test-db-setup: $(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load $(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_name_active ON category (LOWER(name)) 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" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" diff --git a/migrations/Version20260608120000.php b/migrations/Version20260608120000.php new file mode 100644 index 0000000..d93db8f --- /dev/null +++ b/migrations/Version20260608120000.php @@ -0,0 +1,149 @@ + CategoryType + * de ManyToOne a ManyToMany. + * + * Ordre critique : + * 1. Creation de la table de jonction `category_category_type` (FK category ON + * DELETE CASCADE, FK category_type ON DELETE RESTRICT — conserve le garde-fou + * « on ne supprime pas un type encore reference »). + * 2. Backfill : chaque categorie existante recoit une ligne de jonction vers son + * ancien `category_type_id` (avant de dropper la colonne). + * 3. Drop de l'index unique (LOWER(name), category_type_id), de l'index FK et de + * la colonne `category.category_type_id` (Postgres drope la FK dependante). + * 4. Nouvel index unique GLOBAL sur le nom : LOWER(name) WHERE deleted_at IS NULL + * (l'unicite n'est plus liee au type — RG-1.07 reformulee). + * + * Sur base fraiche, les categories seedees CLIENT (Distributeur/Courtier/Secteur/ + * Autre) et FOURNISSEUR (Negociant/Cooperative/...) n'ont aucun nom en collision + * -> l'index unique global passe sans conflit. + * + * Migration placee au namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) : + * Doctrine Migrations 3.x trie par FQCN puis version ; le namespace racine garantit + * l'ordre par timestamp apres les migrations d'init des tables. + */ +final class Version20260608120000 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Catalog : Category <-> CategoryType en ManyToMany (jonction category_category_type), unicite du nom globalisee.'; + } + + public function up(Schema $schema): void + { + // 1. Table de jonction. + $this->addSql(<<<'SQL' + CREATE TABLE category_category_type ( + category_id INT NOT NULL, + category_type_id INT NOT NULL, + PRIMARY KEY (category_id, category_type_id), + CONSTRAINT fk_category_category_type_category + FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE CASCADE, + CONSTRAINT fk_category_category_type_type + FOREIGN KEY (category_type_id) REFERENCES category_type (id) ON DELETE RESTRICT + ) + SQL); + $this->addSql('CREATE INDEX idx_cat_cat_type_type ON category_category_type (category_type_id)'); + + $this->comment('category_category_type', '_table', 'Jointure M2M category <-> category_type (Catalog) — types portes par la categorie, au moins un obligatoire (RG-1.05).'); + $this->comment('category_category_type', 'category_id', 'FK -> category.id, ON DELETE CASCADE — categorie portant le type.'); + $this->comment('category_category_type', 'category_type_id', 'FK -> category_type.id, ON DELETE RESTRICT — type rattache (un type ne peut etre supprime tant qu il reste reference).'); + + // 2. Backfill depuis l'ancienne colonne ManyToOne (chaque categorie -> 1 type). + $this->addSql(<<<'SQL' + INSERT INTO category_category_type (category_id, category_type_id) + SELECT id, category_type_id FROM category + SQL); + + // 3. Suppression de l'ancien modele : index unique par type, index FK, colonne. + $this->addSql('DROP INDEX uq_category_name_type_active'); + $this->addSql('DROP INDEX idx_category_type_id'); + // DROP COLUMN drope automatiquement la FK fk_category_type qui en depend. + $this->addSql('ALTER TABLE category DROP COLUMN category_type_id'); + + // 4. Unicite du nom desormais GLOBALE parmi les actifs (RG-1.07 reformulee). + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX uq_category_name_active + ON category (LOWER(name)) + WHERE deleted_at IS NULL + SQL); + + // Realignement de la doc SQL de `category` (le type n'est plus une colonne). + $this->comment('category', '_table', 'Categories — referentiel multi-types via la jonction category_category_type, soft-delete via deleted_at, unicite LOWER(name) GLOBALE parmi les actifs (uq_category_name_active).'); + $this->comment('category', 'name', 'Libelle de la categorie (≤ 120 caracteres) — unique GLOBALEMENT parmi les actifs (RG-1.07, uq_category_name_active).'); + } + + public function down(Schema $schema): void + { + // Restauration best-effort de l'ancien modele ManyToOne (1 type par categorie). + $this->addSql('DROP INDEX IF EXISTS uq_category_name_active'); + + $this->addSql('ALTER TABLE category ADD COLUMN category_type_id INT DEFAULT NULL'); + + // Reprend le premier type de chaque categorie (l'ordre des types perdus + // au-dela du premier est best-effort : le modele cible n'en gardait qu'un). + $this->addSql(<<<'SQL' + UPDATE category c + SET category_type_id = ( + SELECT cct.category_type_id + FROM category_category_type cct + WHERE cct.category_id = c.id + ORDER BY cct.category_type_id ASC + LIMIT 1 + ) + SQL); + + // Categories sans aucun type (theorique) : on les rattache a defaut au + // premier type existant pour pouvoir reposer le NOT NULL. + $this->addSql(<<<'SQL' + UPDATE category + SET category_type_id = (SELECT id FROM category_type ORDER BY id ASC LIMIT 1) + WHERE category_type_id IS NULL + SQL); + + $this->addSql('ALTER TABLE category ALTER COLUMN category_type_id SET NOT NULL'); + $this->addSql(<<<'SQL' + ALTER TABLE category + ADD CONSTRAINT fk_category_type + FOREIGN KEY (category_type_id) REFERENCES category_type (id) ON DELETE RESTRICT + SQL); + $this->addSql('CREATE INDEX idx_category_type_id ON category (category_type_id)'); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX uq_category_name_type_active + ON category (LOWER(name), category_type_id) + WHERE deleted_at IS NULL + SQL); + + $this->addSql('DROP TABLE category_category_type'); + } + + /** + * Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou `COMMENT ON COLUMN` + * en dollar-quoting Postgres ($_$...$_$) pour eviter tout echappement. + */ + private function comment(string $table, string $column, string $description): void + { + $quotedTable = '"'.str_replace('"', '""', $table).'"'; + + if ('_table' === $column) { + $this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description)); + + return; + } + + $this->addSql(sprintf( + 'COMMENT ON COLUMN %s.%s IS $_$%s$_$', + $quotedTable, + '"'.str_replace('"', '""', $column).'"', + $description, + )); + } +} diff --git a/src/Module/Catalog/Domain/Entity/Category.php b/src/Module/Catalog/Domain/Entity/Category.php index be04b81..03dc0d7 100644 --- a/src/Module/Catalog/Domain/Entity/Category.php +++ b/src/Module/Catalog/Domain/Entity/Category.php @@ -19,14 +19,18 @@ use App\Shared\Domain\Contract\CategoryInterface; use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Trait\TimestampableBlamableTrait; use DateTimeImmutable; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Validator\Constraints as Assert; /** * Categorie : referentiel metier classifiant les futurs tiers (clients, - * fournisseurs, prestataires). Porte un `name` libre et un `categoryType` - * (FK vers le referentiel statique CategoryType). + * fournisseurs, prestataires). Porte un `name` libre et un ou plusieurs + * `categoryTypes` (ManyToMany vers le referentiel statique CategoryType, + * table de jonction `category_category_type`). Une categorie peut appartenir + * a plusieurs types simultanement (>= 1 obligatoire, RG-1.05). * * - Soft delete via `deletedAt` (pas de hard delete) : la liste exclut par * defaut les categories supprimees (cf. CategoryProvider, ticket 0.3). @@ -81,12 +85,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). 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. +// uniques partiels `uq_category_name_active` (LOWER(name) WHERE deleted_at IS +// NULL — unicite GLOBALE du nom parmi les actifs) 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'])] #[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])] #[Auditable] @@ -126,11 +129,21 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt #[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.')] + /** + * Types de la categorie (>= 1 obligatoire, RG-1.05). ManyToMany vers le + * referentiel statique CategoryType via la jonction `category_category_type`. + * Cote inverse (category_type) en ON DELETE RESTRICT : un type ne peut etre + * supprime tant qu'il reste reference par une categorie. + * + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: CategoryType::class)] + #[ORM\JoinTable(name: 'category_category_type')] + #[ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[ORM\InverseJoinColumn(name: 'category_type_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] + #[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un type de catégorie.')] #[Groups(['category:read', 'category:write'])] - private ?CategoryType $categoryType = null; + private Collection $categoryTypes; /** * Soft delete : null = active, valeur = supprimee logiquement le {date}. @@ -141,6 +154,11 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt #[Groups(['category:read'])] private ?DateTimeImmutable $deletedAt = null; + public function __construct() + { + $this->categoryTypes = new ArrayCollection(); + } + public function getId(): ?int { return $this->id; @@ -173,26 +191,42 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt return $this; } - public function getCategoryType(): ?CategoryType + /** + * @return Collection + */ + public function getCategoryTypes(): Collection { - return $this->categoryType; + return $this->categoryTypes; } - public function setCategoryType(?CategoryType $categoryType): static + public function addCategoryType(CategoryType $categoryType): static { - $this->categoryType = $categoryType; + if (!$this->categoryTypes->contains($categoryType)) { + $this->categoryTypes->add($categoryType); + } + + return $this; + } + + public function removeCategoryType(CategoryType $categoryType): static + { + $this->categoryTypes->removeElement($categoryType); return $this; } /** - * Implemente CategoryInterface : code du type rattache (ou null). Permet - * aux modules tiers de filtrer/valider par type metier sans dependre de - * Catalog. + * Implemente CategoryInterface : liste des codes de types rattaches a la + * categorie. Permet aux modules tiers de filtrer/valider par type metier + * (ex: RG-2.10 « contient FOURNISSEUR ») sans dependre de Catalog. + * + * @return list */ - public function getCategoryTypeCode(): ?string + public function getCategoryTypeCodes(): array { - return $this->categoryType?->getCode(); + return array_values(array_filter( + $this->categoryTypes->map(static fn (CategoryType $t): ?string => $t->getCode())->toArray(), + )); } public function getDeletedAt(): ?DateTimeImmutable diff --git a/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php b/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php index 60f7eb8..17c1ba8 100644 --- a/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php +++ b/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php @@ -23,10 +23,26 @@ 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). + * - $typeCode non null : ne garde que les categories PORTANT ce code de type + * (filtre `?typeCode=`, ex. FOURNISSEUR / CLIENT). Sert au multi-select + * Categorie du fournisseur (M2, RG-2.10). + * - $nameSearch non null : recherche partielle case-insensitive sur le nom + * (filtre `?name=` de la liste admin). + * - $typeIds non vide : ne garde que les categories portant AU MOINS UN des + * types (OR, filtre `?typeId[]=` de la liste admin). * - Tri : name ASC (RG-1.10). + * + * Les categories etant en ManyToMany avec leurs types, la collection + * `categoryTypes` est eager-loadee (addSelect) pour eviter un N+1 a la + * serialisation, et `distinct` est applique des qu'un filtre type joint la + * table de jonction (evite les lignes dupliquees). + * + * @param list $typeIds */ - public function createListQueryBuilder(bool $includeDeleted = false, ?string $typeCode = null): QueryBuilder; + public function createListQueryBuilder( + bool $includeDeleted = false, + ?string $typeCode = null, + ?string $nameSearch = null, + array $typeIds = [], + ): QueryBuilder; } diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php index c97ef7c..8f34334 100644 --- a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php +++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php @@ -22,8 +22,8 @@ use Symfony\Component\HttpKernel\Exception\HttpException; * 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). + * l'index partiel uq_category_name_active — unicite GLOBALE du nom parmi les + * actifs) 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 @@ -78,10 +78,12 @@ final class CategoryProcessor implements ProcessorInterface try { return $this->persistProcessor->process($data, $operation, $uriVariables, $context); } catch (UniqueConstraintViolationException $e) { - // RG-1.07 : doublon (LOWER(name), category_type_id) parmi les non-soft-deleted. + // RG-1.07 : doublon de nom GLOBAL (LOWER(name)) parmi les non-soft-deleted + // (uq_category_name_active). L'unicite n'est plus liee au type depuis le + // passage en ManyToMany. throw new HttpException( 409, - sprintf('Une catégorie nommée "%s" existe déjà pour ce type.', $data->getName() ?? ''), + sprintf('Une catégorie nommée "%s" existe déjà.', $data->getName() ?? ''), $e, ); } diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/CategoryProvider.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/CategoryProvider.php index 25fc1b5..a0cc167 100644 --- a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/CategoryProvider.php +++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/CategoryProvider.php @@ -40,7 +40,12 @@ final class CategoryProvider implements ProviderInterface $includeDeleted = $this->readIncludeDeleted($context); if ($operation instanceof CollectionOperationInterface) { - $qb = $this->repository->createListQueryBuilder($includeDeleted, $this->readTypeCode($context)); + $qb = $this->repository->createListQueryBuilder( + $includeDeleted, + $this->readTypeCode($context), + $this->readNameSearch($context), + $this->readTypeIds($context), + ); // Echappatoire ?pagination=false : retourne la collection complete sans Paginator. // Utile pour les drawers Role/Permission/Site/CategoryType qui alimentent un