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/config/packages/test/doctrine.yaml b/config/packages/test/doctrine.yaml new file mode 100644 index 0000000..26b58a4 --- /dev/null +++ b/config/packages/test/doctrine.yaml @@ -0,0 +1,12 @@ +doctrine: + dbal: + connections: + # Force le profiling DBAL en environnement de test independamment de + # APP_DEBUG. Sans cela, la CI tourne en APP_DEBUG=0 (prod-like) et le + # service `doctrine.debug_data_holder` n'est pas enregistre : le test + # anti-N+1 (SupplierListTest::testListQueryCountDoesNotGrowWithRowCount) + # qui compte les requetes via ce holder echoue alors en CI alors qu'il + # passe en local (APP_DEBUG=1). Activer le profiling ici garde le test + # actif precisement la ou il compte (CI), sans impacter la prod. + default: + profiling: true diff --git a/config/sidebar.php b/config/sidebar.php index 3c551b8..f2baf26 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -53,10 +53,11 @@ return [ 'permission' => 'commercial.clients.view', ], [ - 'label' => 'sidebar.commercial.suppliers', - 'to' => '/suppliers', - 'icon' => 'mdi:account-arrow-left-outline', - 'module' => 'commercial', + 'label' => 'sidebar.commercial.suppliers', + 'to' => '/suppliers', + 'icon' => 'mdi:account-arrow-left-outline', + 'module' => 'commercial', + 'permission' => 'commercial.suppliers.view', ], ], ], diff --git a/config/version.yaml b/config/version.yaml index c77fdff..3c6fa11 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.83' + app.version: '0.1.96' diff --git a/docs/specs/M2-suppliers/spec-back.md b/docs/specs/M2-suppliers/spec-back.md index 2194273..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' => [ @@ -711,91 +721,108 @@ Même pattern que les jumelles `Client*` (`#[Auditable]`, `TimestampableBlamable | Scalaires Comptabilité (siren, refs…) | `supplier:read:accounting` | ✅ (gated) | refs (`tvaMode`…) id+label ∈ `supplier:read:accounting` | | `ribs[]` (label/bic/iban) | `ribs` ∈ `supplier:read:accounting` | ✅ (gated) | — | -### 4.0.bis Réponses JSON de référence (DoD — à confirmer sur l'API réelle) +### 4.0.bis Réponses JSON de référence (DoD — RÉELLES, capturées ERP-92) -> **Definition of Done de cette spec back (RETEX M1 §3)** : avant d'écrire les tickets front, créer un fournisseur de test et **coller ici les réponses RÉELLES** de `GET /api/suppliers` et `GET /api/suppliers/{id}`. Les containers n'étant pas lancés au moment de la rédaction, le JSON ci-dessous est le **contrat CIBLE** — à valider/remplacer par la réponse réelle (`make start` puis `curl`). Toute donnée affichée par le front DOIT apparaître dans ce JSON. +> **Definition of Done CLÔTURÉE (ERP-92, 2026-06-05)** : les réponses ci-dessous sont **réelles**, capturées sur l'API de test via PHPUnit (`SupplierSerializationContractTest`, fournisseur complet seedé). Les `id`/timestamps sont illustratifs (run de test). Toute donnée affichée par le front DOIT apparaître dans ce JSON. Front #93→#96 peuvent démarrer. +> +> **2 constats validés à la capture** (cf. § 4.0.ter) : +> 1. 🔧 **Fix ERP-92** : les réfs comptables (`tvaMode`/`paymentDelay`/`paymentType`/`bank`) sortaient en **IRI nu** (les entités partagées ne portaient que `client:read:accounting`, pas `supplier:read:accounting`). Corrigé → objet `{id, code, label}` embarqué (le front consultation/édition affiche le libellé sans fetch). +> 2. ℹ️ **Liste « riche »** : le groupe `supplier:read` étant partagé liste+détail, la **collection embarque tout le bloc Information** (et, pour un user `accounting.view`, les scalaires compta + `ribs[]`). Comportement identique au M1 (groupe `client:read` partagé) — la datatable n'affiche que Nom/Catégories/Site(s)/MAJ, mais le payload est complet. Le gating `accounting` reste effectif (Commerciale ne voit ni compta ni `ribs` en liste comme en détail). > **Forme d'enveloppe confirmée sur le M1 réel** (API Platform 4.2) : JSON-LD **sans préfixe `hydra:`** → clés `member` / `totalItems` / `view`, avec `@type: "Collection"` et `view.@type: "PartialCollectionView"`. `Content-Type: application/ld+json; charset=utf-8`. Pagination défaut 10 confirmée. Login réel = `POST /api/login_check` (nginx réécrit vers `/login_check`), réponse `204` + cookie HttpOnly `BEARER`. -`GET /api/suppliers` (liste, ADMIN) : +`GET /api/suppliers?search=…` (liste, ADMIN — un membre) : ```json { "@context": "/api/contexts/Supplier", "@id": "/api/suppliers", "@type": "Collection", - "totalItems": 13, + "totalItems": 1, "member": [ { - "@id": "/api/suppliers/1", + "@id": "/api/suppliers/85", "@type": "Supplier", - "id": 1, - "companyName": "RECYCLA SAS", + "id": 85, + "companyName": "DOD59393F 862875", "categories": [ - {"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"} + {"@type": "Category", "@id": "/api/categories/2279", "id": 2279, "name": "test_cli_cat_fr_negociant", "code": "NEGOCIANT", + "categoryType": {"@id": "/api/category_types/602", "@type": "CategoryType", "id": 602, "code": "FOURNISSEUR", "label": "Fournisseur"}, + "createdAt": "…", "updatedAt": "…"} ], + "description": "Fournisseur de test complet.", + "competitors": "Concurrent A, Concurrent B", + "foundedAt": "2008-04-01T00:00:00+02:00", + "employeesCount": 42, + "revenueAmount": "1500000.00", + "directorName": "Jean Dupont", + "profitAmount": "120000.00", + "volumeForecast": 8000, + "siren": "123456789", + "accountNumber": "F0001", + "tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"}, + "nTva": "FR00123456789", + "paymentDelay": {"@id": "/api/payment_delays/11", "@type": "PaymentDelay", "id": 11, "code": "J30", "label": "30 jours"}, + "paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"}, + "ribs": [ + {"@id": "/api/supplier_ribs/27", "@type": "SupplierRib", "id": 27, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "…", "updatedAt": "…"} + ], + "createdAt": "…", "updatedAt": "…", "sites": [ - {"@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}, - {"@id": "/api/sites/2", "id": 2, "name": "Saint-Jean", "postalCode": "17400", "city": "Fontenet", "color": "#…"} + {"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"}, + {"@type": "Site", "@id": "/api/sites/88", "id": 88, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "fullAddress": "Z i\n17400 Fontenet"} ], - "updatedAt": "2026-02-17T09:30:00+00:00", "isArchived": false } ], - "view": { - "@id": "/api/suppliers?page=1", - "@type": "PartialCollectionView", - "first": "/api/suppliers?page=1", - "last": "/api/suppliers?page=2", - "next": "/api/suppliers?page=2" - } + "view": {"@id": "/api/suppliers?search=…", "@type": "PartialCollectionView"} } ``` -> Les fournisseurs archivés sont **exclus** du `totalItems` (sur le M1, 14 clients en base → `totalItems: 13` car 1 archivé filtré par le Provider). `categories[]` (avec `code`/`name`) et `sites[]` (avec `name`/`postalCode` — **pas de `code`**) sont **embarqués** (cohérence M1/ERP-62, § 2.12) ; `sites` est l'agrégat dédoublonné des adresses via `Supplier::getSites()`. Fetch-joins repository obligatoires (anti N+1). +> Les fournisseurs archivés sont **exclus** du `totalItems` (RG-2.17 — filtré par le Provider). `categories[]` (avec `code`/`name`) et `sites[]` (avec `name`/`postalCode` — **pas de `code`**) sont **embarqués** (cohérence M1/ERP-62, § 2.12) ; `sites` est l'agrégat dédoublonné des adresses via `Supplier::getSites()`. Fetch-joins repository (anti N+1) **vérifiés par test** (`SupplierListTest::testListQueryCountDoesNotGrowWithRowCount` : nombre de requêtes constant entre 2 et 4 fournisseurs). ⚠️ Le membre embarque aussi l'**Information complète** et — pour un user `accounting.view` (ici admin) — les **scalaires compta + `ribs[]`** (groupe `supplier:read` partagé liste/détail). Pour la **Commerciale** (sans `accounting.view`), `siren`/`tvaMode`/`paymentType`/`ribs`… **disparaissent** de chaque membre. -`GET /api/suppliers/1` (détail — user avec `accounting.view`) : +`GET /api/suppliers/85` (détail — user avec `accounting.view`) : ```json { - "@id": "/api/suppliers/1", + "@context": "/api/contexts/Supplier", + "@id": "/api/suppliers/85", "@type": "Supplier", - "id": 1, - "companyName": "RECYCLA SAS", + "id": 85, + "companyName": "DOD59393F 862875", "categories": [ - {"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"} + {"@type": "Category", "@id": "/api/categories/2279", "id": 2279, "name": "test_cli_cat_fr_negociant", "code": "NEGOCIANT", + "categoryType": {"@id": "/api/category_types/602", "@type": "CategoryType", "id": 602, "code": "FOURNISSEUR", "label": "Fournisseur"}} ], - "description": "…", "competitors": "…", "foundedAt": "2008-04-01", - "employeesCount": 42, "revenueAmount": "1500000.00", "directorName": "…", - "profitAmount": "120000.00", "volumeForecast": 8000, + "description": "Fournisseur de test complet.", "competitors": "Concurrent A, Concurrent B", + "foundedAt": "2008-04-01T00:00:00+02:00", "employeesCount": 42, "revenueAmount": "1500000.00", + "directorName": "Jean Dupont", "profitAmount": "120000.00", "volumeForecast": 8000, + "siren": "123456789", "accountNumber": "F0001", + "tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"}, + "nTva": "FR00123456789", + "paymentDelay": {"@id": "/api/payment_delays/11", "@type": "PaymentDelay", "id": 11, "code": "J30", "label": "30 jours"}, + "paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"}, "contacts": [ - {"@id": "/api/supplier_contacts/1", "id": 1, "firstName": "Marie", "lastName": "Martin", - "jobTitle": "Responsable achats", "phonePrimary": "0612345678", "phoneSecondary": null, - "email": "marie.martin@recycla.fr"} + {"@id": "/api/supplier_contacts/39", "@type": "SupplierContact", "id": 39, "firstName": "Marie", "lastName": "Martin", + "jobTitle": "Responsable achats", "phonePrimary": "0612345678", "email": "marie.martin@seed.test"} ], "addresses": [ - {"@id": "/api/supplier_addresses/1", "id": 1, "addressType": "DEPART", - "country": "France", "postalCode": "86000", "city": "Poitiers", - "street": "12 rue des Acacias", "streetComplement": null, + {"@id": "/api/supplier_addresses/33", "@type": "SupplierAddress", "id": 33, "addressType": "DEPART", + "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias", "bennes": 3, "triageProvider": true, - "sites": [{"@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}], - "categories": [{"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}], - "contacts": [{"@id": "/api/supplier_contacts/1", "id": 1, "firstName": "Marie", "lastName": "Martin"}]} + "sites": [ + {"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}, + {"@type": "Site", "@id": "/api/sites/88", "id": 88, "name": "Saint-Jean", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00"} + ], + "contacts": [{"@id": "/api/supplier_contacts/39", "@type": "SupplierContact", "id": 39, "firstName": "Marie", "lastName": "Martin"}], + "categories": [{"@type": "Category", "@id": "/api/categories/2279", "id": 2279, "name": "test_cli_cat_fr_negociant", "code": "NEGOCIANT"}]} ], - "siren": "123456789", "accountNumber": "F0001", - "tvaMode": {"@id": "/api/tva_modes/1", "id": 1, "label": "France (ventes)"}, - "nTva": "FR00123456789", - "paymentDelay": {"@id": "/api/payment_delays/2", "id": 2, "label": "30 jours"}, - "paymentType": {"@id": "/api/payment_types/2", "id": 2, "code": "LCR", "label": "LCR"}, - "bank": null, "ribs": [ - {"@id": "/api/supplier_ribs/1", "id": 1, "label": "Compte principal", - "bic": "SOGEFRPP", "iban": "FR7630003035400005000000123"} + {"@id": "/api/supplier_ribs/27", "@type": "SupplierRib", "id": 27, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"} ], - "isArchived": false, "archivedAt": null, - "updatedAt": "2026-02-17T09:30:00+00:00" + "isArchived": false } ``` -> Pour un user **sans** `accounting.view` (ex. Commerciale) : les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank`, `ribs` **sont absentes** (pas `null` — réellement non sérialisées car le Provider retire le groupe). Le gating par **omission de clé** est confirmé confortable côté front. Le blame `updatedBy` est sérialisé en **IRI** (`"/api/me"` quand c'est l'user courant) — en tenir compte côté front. +> Pour un user **sans** `accounting.view` (ex. Commerciale) : les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank`, `ribs` **sont absentes** (pas `null` — réellement non sérialisées : le `SupplierReadGroupContextBuilder` n'ajoute pas le groupe). Gating par **omission de clé** confirmé sur le JSON réel (`SupplierSerializationContractTest::testRibsAbsentForCommercialeWithoutAccountingView` + `testAccountingScalarsGatedByOmission`). `bennes`/`triageProvider`/`addressType`/`addresses[].contacts` restent visibles (onglet Adresse non gaté). NB : ici `bank` est absent (paymentType=LCR sans banque) ; avec un VIREMENT, `bank` est embarqué `{id, code, label}` (fix ERP-92). ### 4.0.ter Pièges de sérialisation CONSTATÉS sur le M1 réel → parade M2 (OBLIGATOIRE) @@ -1046,7 +1073,7 @@ Le M1 a subi un aller-retour (ERP-68) faute de fixtures alignées. Pour le M2, p - [x] 3 maillons de sérialisation documentés pour chaque champ liste + détail (§ 4.0) - [x] Décision embed vs GetCollection explicite et câblée (embed détail + sous-ressources write — § 3.3 / § 3.4 / § 4.5), **pas de POST-only** -- [ ] **Réponses JSON RÉELLES** collées (§ 4.0.bis) — *en attente de `make start` + curl (DoD avant tickets front)* +- [x] **Réponses JSON RÉELLES** collées (§ 4.0.bis) — capturées via PHPUnit (ERP-92, 2026-06-05) ; fix réfs compta IRI→{id,label} inclus - [x] Matrice RBAC rôle × onglet + mode strict PATCH (§ 2.9 / RG-2.16) - [x] Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit, routes à plat : rappelés - [x] Réutilisations M1 identifiées (référentiels compta partagés, taxonomie code/type, `usePaginatedList`, blocs, archive, normalisation) diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index cf7bc63..3f20c17 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -259,7 +259,11 @@ "commercial_client": "Client", "commercial_clientaddress": "Adresse client", "commercial_clientcontact": "Contact client", - "commercial_clientrib": "RIB client" + "commercial_clientrib": "RIB client", + "commercial_supplier": "Fournisseur", + "commercial_supplieraddress": "Adresse fournisseur", + "commercial_suppliercontact": "Contact fournisseur", + "commercial_supplierrib": "RIB fournisseur" }, "empty": "Aucune activité enregistrée", "no_results": "Aucun résultat pour ces filtres", @@ -416,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", @@ -436,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/frontend/tests/e2e/_fixtures/personas.ts b/frontend/tests/e2e/_fixtures/personas.ts index c610608..73afbd4 100644 --- a/frontend/tests/e2e/_fixtures/personas.ts +++ b/frontend/tests/e2e/_fixtures/personas.ts @@ -75,6 +75,15 @@ export const personas: Record = { 'commercial.clients.accounting.view', 'commercial.clients.accounting.manage', 'commercial.clients.archive', + // Commercial — Repertoire fournisseurs (M2, ERP-90). Meme logique que + // les clients : mappe sur le persona "tout", pas de nouveau persona + // (regle ABSOLUE n°7). commercial.suppliers.view n'ajoute pas de lien + // dans la section Administration, donc expectedAdminLinks reste inchange. + 'commercial.suppliers.view', + 'commercial.suppliers.manage', + 'commercial.suppliers.accounting.view', + 'commercial.suppliers.accounting.manage', + 'commercial.suppliers.archive', ], expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'], }, diff --git a/makefile b/makefile index 9977884..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,9 +227,10 @@ 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" fixtures: $(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load 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/migrations/Version20260605130000.php b/migrations/Version20260605130000.php new file mode 100644 index 0000000..70bdb48 --- /dev/null +++ b/migrations/Version20260605130000.php @@ -0,0 +1,438 @@ + echec des FK. Le namespace racine garantit l'ordre par timestamp. + * + * Style DDL aligne sur le M1 (Version20260601000000) : `INT GENERATED BY DEFAULT + * AS IDENTITY` (et non SERIAL), `TIMESTAMP(0) WITHOUT TIME ZONE` (et non + * TIMESTAMPTZ, car le TimestampableBlamableTrait mappe `datetime_immutable`). + * Garantit que `schema:update` restera un no-op quand les entites arriveront + * (ticket ERP-86). + * + * Decision unicite (Matthieu 02/06, alignee Q4 du M1) : unicite metier sur le + * NOM DE SOCIETE uniquement (uq_supplier_company_name_active, partiel). Pas + * d'index unique sur siren ni email. + * + * Chaque colonne porte un `COMMENT ON COLUMN` (regle ABSOLUE n°12, garde-fou + * ColumnsHaveSqlCommentTest). Les tables n'etant pas encore mappees par l'ORM + * (entites a ERP-86), ces commentaires survivent au `schema:update --force` du + * setup de test (additif, ne drop pas les tables non mappees). + */ +final class Version20260605130000 extends AbstractMigration +{ + public function getDescription(): string + { + return 'ERP-85 (M2) : tables supplier + sous-collections + jointures M2M (referentiels comptables et CategoryType FOURNISSEUR reutilises).'; + } + + public function up(Schema $schema): void + { + $this->createSupplierTable(); + $this->createSupplierCategory(); + $this->createSupplierContact(); + $this->createSupplierAddress(); + $this->createSupplierAddressJoinTables(); + $this->createSupplierRib(); + } + + public function down(Schema $schema): void + { + // 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 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'); + } + + // ================================================================= + // Table principale `supplier` + // ================================================================= + + private function createSupplierTable(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE supplier ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + company_name VARCHAR(180) NOT NULL, + description TEXT DEFAULT NULL, + competitors VARCHAR(255) DEFAULT NULL, + founded_at DATE DEFAULT NULL, + employees_count INT DEFAULT NULL, + revenue_amount NUMERIC(15, 2) DEFAULT NULL, + director_name VARCHAR(120) DEFAULT NULL, + profit_amount NUMERIC(15, 2) DEFAULT NULL, + volume_forecast INT DEFAULT NULL, + siren VARCHAR(20) DEFAULT NULL, + account_number VARCHAR(40) DEFAULT NULL, + tva_mode_id INT DEFAULT NULL, + n_tva VARCHAR(40) DEFAULT NULL, + payment_delay_id INT DEFAULT NULL, + payment_type_id INT DEFAULT NULL, + bank_id INT DEFAULT NULL, + is_archived BOOLEAN DEFAULT FALSE NOT NULL, + archived_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT DEFAULT NULL, + updated_by INT DEFAULT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_supplier_tva_mode + FOREIGN KEY (tva_mode_id) REFERENCES tva_mode (id) ON DELETE RESTRICT, + CONSTRAINT fk_supplier_payment_delay + FOREIGN KEY (payment_delay_id) REFERENCES payment_delay (id) ON DELETE RESTRICT, + CONSTRAINT fk_supplier_payment_type + FOREIGN KEY (payment_type_id) REFERENCES payment_type (id) ON DELETE RESTRICT, + CONSTRAINT fk_supplier_bank + FOREIGN KEY (bank_id) REFERENCES bank (id) ON DELETE RESTRICT, + CONSTRAINT fk_supplier_created_by + FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, + CONSTRAINT fk_supplier_updated_by + FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL + ) + SQL); + + $this->addSql('CREATE INDEX idx_supplier_is_archived ON supplier (is_archived)'); + $this->addSql('CREATE INDEX idx_supplier_deleted_at ON supplier (deleted_at)'); + $this->addSql('CREATE INDEX idx_supplier_created_by ON supplier (created_by)'); + $this->addSql('CREATE INDEX idx_supplier_updated_by ON supplier (updated_by)'); + + // Index sur les FK des referentiels comptables (Postgres n'indexe pas + // automatiquement les colonnes portant une FOREIGN KEY). + $this->addSql('CREATE INDEX idx_supplier_tva_mode_id ON supplier (tva_mode_id)'); + $this->addSql('CREATE INDEX idx_supplier_payment_delay_id ON supplier (payment_delay_id)'); + $this->addSql('CREATE INDEX idx_supplier_payment_type_id ON supplier (payment_type_id)'); + $this->addSql('CREATE INDEX idx_supplier_bank_id ON supplier (bank_id)'); + + // Unicite metier partielle : nom de societe insensible a la casse, parmi + // les non-archives ET non soft-deletes uniquement (spec § 2.6). Pas + // d'index unique sur siren ni email. + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX uq_supplier_company_name_active + ON supplier (LOWER(company_name)) + WHERE is_archived = FALSE AND deleted_at IS NULL + SQL); + + $this->comment('supplier', '_table', 'Repertoire fournisseurs (M2 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M3).'); + $this->comment('supplier', 'id', 'Identifiant interne auto-incremente.'); + $this->comment('supplier', 'company_name', 'Raison sociale du fournisseur (stockee en MAJUSCULES). Unique case-insensitive parmi les actifs non archives/non supprimes (uq_supplier_company_name_active, § 2.6).'); + $this->comment('supplier', 'description', 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-2.03), optionnel sinon.'); + $this->comment('supplier', 'competitors', 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-2.03).'); + $this->comment('supplier', 'founded_at', 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-2.03).'); + $this->comment('supplier', 'employees_count', 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-2.03).'); + $this->comment('supplier', 'revenue_amount', 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-2.03).'); + $this->comment('supplier', 'director_name', 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-2.03).'); + $this->comment('supplier', 'profit_amount', 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-2.03).'); + $this->comment('supplier', 'volume_forecast', 'Onglet Information : volume previsionnel (entier >= 0) — specifique fournisseur. Obligatoire role Commerciale (RG-2.03).'); + $this->comment('supplier', 'siren', 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (§ 2.6).'); + $this->comment('supplier', 'account_number', 'Onglet Comptabilite : numero de compte comptable du fournisseur.'); + $this->comment('supplier', 'tva_mode_id', 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id (referentiel partage M1), ON DELETE RESTRICT.'); + $this->comment('supplier', 'n_tva', 'Onglet Comptabilite : numero de TVA intracommunautaire.'); + $this->comment('supplier', 'payment_delay_id', 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id (M1), ON DELETE RESTRICT.'); + $this->comment('supplier', 'payment_type_id', 'Onglet Comptabilite : type de reglement — FK -> payment_type.id (M1), ON DELETE RESTRICT. Pilote RG-2.07 (Banque si VIREMENT) et RG-2.08 (RIB).'); + $this->comment('supplier', 'bank_id', 'Onglet Comptabilite : banque — FK -> bank.id (M1), ON DELETE RESTRICT. Obligatoire ssi payment_type = VIREMENT (RG-2.07), null sinon.'); + $this->comment('supplier', 'is_archived', 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission commercial.suppliers.archive.'); + $this->comment('supplier', 'archived_at', 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.'); + $this->comment('supplier', 'deleted_at', 'Horodatage du soft-delete technique (HP M3) — non expose par l API au M2. Null = ligne active.'); + $this->addTimestampableBlamableComments('supplier'); + } + + // ================================================================= + // M2M supplier <-> category (type FOURNISSEUR — RG-2.10) + // ================================================================= + + private function createSupplierCategory(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE supplier_category ( + supplier_id INT NOT NULL, + category_id INT NOT NULL, + PRIMARY KEY (supplier_id, category_id), + CONSTRAINT fk_supplier_category_supplier + FOREIGN KEY (supplier_id) REFERENCES supplier (id) ON DELETE CASCADE, + CONSTRAINT fk_supplier_category_category + FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT + ) + SQL); + $this->addSql('CREATE INDEX idx_supplier_category_category ON supplier_category (category_id)'); + + $this->comment('supplier_category', '_table', 'Jointure M2M supplier <-> category (Catalog) — categories de type FOURNISSEUR du fournisseur, au moins une obligatoire (RG-2.10).'); + $this->comment('supplier_category', 'supplier_id', 'FK -> supplier.id, ON DELETE CASCADE — fournisseur porteur de la categorie.'); + $this->comment('supplier_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie de type FOURNISSEUR rattachee au fournisseur (RG-2.10).'); + } + + // ================================================================= + // Sous-collection : contacts (1:n) + // ================================================================= + + private function createSupplierContact(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE supplier_contact ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + supplier_id INT NOT NULL, + first_name VARCHAR(120) DEFAULT NULL, + last_name VARCHAR(120) DEFAULT NULL, + job_title VARCHAR(120) DEFAULT NULL, + phone_primary VARCHAR(20) DEFAULT NULL, + phone_secondary VARCHAR(20) DEFAULT NULL, + email VARCHAR(180) DEFAULT NULL, + position INT DEFAULT 0 NOT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT DEFAULT NULL, + updated_by INT DEFAULT NULL, + PRIMARY KEY (id), + CONSTRAINT chk_supplier_contact_name + CHECK (first_name IS NOT NULL OR last_name IS NOT NULL), + CONSTRAINT fk_supplier_contact_supplier + FOREIGN KEY (supplier_id) REFERENCES supplier (id) ON DELETE CASCADE, + CONSTRAINT fk_supplier_contact_created_by + FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, + CONSTRAINT fk_supplier_contact_updated_by + FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL + ) + SQL); + $this->addSql('CREATE INDEX idx_supplier_contact_supplier ON supplier_contact (supplier_id)'); + + $this->comment('supplier_contact', '_table', 'Contacts d un fournisseur (1:n) — au moins firstName OU lastName par contact (RG-2.04).'); + $this->comment('supplier_contact', 'id', 'Identifiant interne auto-incremente.'); + $this->comment('supplier_contact', 'supplier_id', 'FK -> supplier.id, ON DELETE CASCADE — fournisseur proprietaire du contact.'); + $this->comment('supplier_contact', 'first_name', 'Prenom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-2.04, chk_supplier_contact_name).'); + $this->comment('supplier_contact', 'last_name', 'Nom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-2.04, chk_supplier_contact_name).'); + $this->comment('supplier_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).'); + $this->comment('supplier_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (normalisation serveur).'); + $this->comment('supplier_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).'); + $this->comment('supplier_contact', 'email', 'Email du contact (lowercase serveur).'); + $this->comment('supplier_contact', 'position', 'Ordre d affichage du contact dans la liste du fournisseur (croissant).'); + $this->addTimestampableBlamableComments('supplier_contact'); + } + + // ================================================================= + // Sous-collection : adresses (1:n) + // ================================================================= + + private function createSupplierAddress(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE supplier_address ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + supplier_id INT NOT NULL, + address_type VARCHAR(20) NOT NULL, + country VARCHAR(80) DEFAULT 'France' NOT NULL, + postal_code VARCHAR(20) NOT NULL, + city VARCHAR(120) NOT NULL, + street VARCHAR(255) NOT NULL, + street_complement VARCHAR(255) DEFAULT NULL, + bennes INT DEFAULT NULL, + triage_provider BOOLEAN DEFAULT FALSE NOT NULL, + position INT DEFAULT 0 NOT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT DEFAULT NULL, + updated_by INT DEFAULT NULL, + PRIMARY KEY (id), + CONSTRAINT chk_supplier_address_type + CHECK (address_type IN ('PROSPECT', 'DEPART', 'RENDU')), + CONSTRAINT fk_supplier_address_supplier + FOREIGN KEY (supplier_id) REFERENCES supplier (id) ON DELETE CASCADE, + CONSTRAINT fk_supplier_address_created_by + FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, + CONSTRAINT fk_supplier_address_updated_by + FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL + ) + SQL); + $this->addSql('CREATE INDEX idx_supplier_address_supplier ON supplier_address (supplier_id)'); + + $this->comment('supplier_address', '_table', 'Adresses d un fournisseur (1:n) — type PROSPECT/DEPART/RENDU exclusif (RG-2.09), >= 1 site rattache (RG-2.06).'); + $this->comment('supplier_address', 'id', 'Identifiant interne auto-incremente.'); + $this->comment('supplier_address', 'supplier_id', 'FK -> supplier.id, ON DELETE CASCADE — fournisseur proprietaire de l adresse.'); + $this->comment('supplier_address', 'address_type', 'Type d adresse : PROSPECT | DEPART | RENDU (radio exclusif par construction — RG-2.09, chk_supplier_address_type).'); + $this->comment('supplier_address', 'country', 'Pays de l adresse — defaut France.'); + $this->comment('supplier_address', 'postal_code', 'Code postal (4-5 chiffres attendus).'); + $this->comment('supplier_address', 'city', 'Ville — preremplie depuis le code postal via API BAN cote front.'); + $this->comment('supplier_address', 'street', 'Numero et voie de l adresse.'); + $this->comment('supplier_address', 'street_complement', 'Complement d adresse (etage, batiment...) — optionnel.'); + $this->comment('supplier_address', 'bennes', 'Nombre de bennes sur le site fournisseur (entier nullable) — specifique fournisseur.'); + $this->comment('supplier_address', 'triage_provider', 'Le fournisseur est prestataire de triage sur cette adresse. Faux par defaut.'); + $this->comment('supplier_address', 'position', 'Ordre d affichage de l adresse dans la liste du fournisseur (croissant).'); + $this->addTimestampableBlamableComments('supplier_address'); + } + + // ================================================================= + // Jointures de supplier_address (M2M) + // ================================================================= + + private function createSupplierAddressJoinTables(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE supplier_address_site ( + supplier_address_id INT NOT NULL, + site_id INT NOT NULL, + PRIMARY KEY (supplier_address_id, site_id), + CONSTRAINT fk_supplier_address_site_address + FOREIGN KEY (supplier_address_id) REFERENCES supplier_address (id) ON DELETE CASCADE, + CONSTRAINT fk_supplier_address_site_site + FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT + ) + SQL); + $this->comment('supplier_address_site', '_table', 'Jointure M2M supplier_address <-> site (Sites) — sites rattaches a l adresse (>= 1 obligatoire, RG-2.06).'); + $this->comment('supplier_address_site', 'supplier_address_id', 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.'); + $this->comment('supplier_address_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.'); + + $this->addSql(<<<'SQL' + CREATE TABLE supplier_address_contact ( + supplier_address_id INT NOT NULL, + supplier_contact_id INT NOT NULL, + PRIMARY KEY (supplier_address_id, supplier_contact_id), + CONSTRAINT fk_supplier_address_contact_address + FOREIGN KEY (supplier_address_id) REFERENCES supplier_address (id) ON DELETE CASCADE, + CONSTRAINT fk_supplier_address_contact_contact + FOREIGN KEY (supplier_contact_id) REFERENCES supplier_contact (id) ON DELETE CASCADE + ) + SQL); + $this->comment('supplier_address_contact', '_table', 'Jointure M2M supplier_address <-> supplier_contact — contacts associes a une adresse.'); + $this->comment('supplier_address_contact', 'supplier_address_id', 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.'); + $this->comment('supplier_address_contact', 'supplier_contact_id', 'FK -> supplier_contact.id, ON DELETE CASCADE — contact associe a l adresse.'); + + $this->addSql(<<<'SQL' + CREATE TABLE supplier_address_category ( + supplier_address_id INT NOT NULL, + category_id INT NOT NULL, + PRIMARY KEY (supplier_address_id, category_id), + CONSTRAINT fk_supplier_address_category_address + FOREIGN KEY (supplier_address_id) REFERENCES supplier_address (id) ON DELETE CASCADE, + CONSTRAINT fk_supplier_address_category_category + FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT + ) + SQL); + $this->comment('supplier_address_category', '_table', 'Jointure M2M supplier_address <-> category — categories d adresse de type FOURNISSEUR (RG-2.10).'); + $this->comment('supplier_address_category', 'supplier_address_id', 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.'); + $this->comment('supplier_address_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type FOURNISSEUR (RG-2.10).'); + } + + // ================================================================= + // Sous-collection : RIB (1:n) + // ================================================================= + + private function createSupplierRib(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE supplier_rib ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + supplier_id INT NOT NULL, + label VARCHAR(120) NOT NULL, + bic VARCHAR(20) NOT NULL, + iban VARCHAR(34) NOT NULL, + position INT DEFAULT 0 NOT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT DEFAULT NULL, + updated_by INT DEFAULT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_supplier_rib_supplier + FOREIGN KEY (supplier_id) REFERENCES supplier (id) ON DELETE CASCADE, + CONSTRAINT fk_supplier_rib_created_by + FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, + CONSTRAINT fk_supplier_rib_updated_by + FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL + ) + SQL); + $this->addSql('CREATE INDEX idx_supplier_rib_supplier ON supplier_rib (supplier_id)'); + + $this->comment('supplier_rib', '_table', 'Coordonnees bancaires d un fournisseur (1:n) — >= 1 RIB attendu selon le type de reglement (RG-2.08). Tous les champs audites (pas d AuditIgnore).'); + $this->comment('supplier_rib', 'id', 'Identifiant interne auto-incremente.'); + $this->comment('supplier_rib', 'supplier_id', 'FK -> supplier.id, ON DELETE CASCADE — fournisseur proprietaire du RIB.'); + $this->comment('supplier_rib', 'label', 'Libelle du RIB (ex: compte principal).'); + $this->comment('supplier_rib', 'bic', 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).'); + $this->comment('supplier_rib', 'iban', 'IBAN du compte (≤ 34 caracteres).'); + $this->comment('supplier_rib', 'position', 'Ordre d affichage du RIB dans la liste du fournisseur (croissant).'); + $this->addTimestampableBlamableComments('supplier_rib'); + } + + // ================================================================= + // Helpers + // ================================================================= + + /** + * Pose les 4 commentaires standardises Timestampable/Blamable sur une table, + * en reutilisant le catalogue partage (source unique, cf. ERP-67). + */ + private function addTimestampableBlamableComments(string $table): void + { + foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) { + $this->comment($table, $column, $description); + } + } + + /** + * Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou + * `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour eviter + * tout echappement d apostrophe. + */ + 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/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 5a43d5c..17c1ba8 100644 --- a/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php +++ b/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php @@ -23,7 +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 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): 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 bc7f4c7..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); + $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 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..77df0a9 --- /dev/null +++ b/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php @@ -0,0 +1,361 @@ += 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 + { + // 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'); + + $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()); + } +} diff --git a/tests/Module/Commercial/Domain/Entity/SupplierValidationTest.php b/tests/Module/Commercial/Domain/Entity/SupplierValidationTest.php new file mode 100644 index 0000000..04ec47f --- /dev/null +++ b/tests/Module/Commercial/Domain/Entity/SupplierValidationTest.php @@ -0,0 +1,188 @@ + banque), RG-2.08 (LCR -> >= 1 RIB). + * + * On valide l'entite avec le validator Symfony (mapping par attributs) et on + * assert le propertyPath exact de chaque violation (contrat ERP-101 : + * exploitable par extractApiViolations). Pas de base : les Callback ne touchent + * que des champs en memoire (categories via un double CategoryInterface). + * + * @internal + */ +final class SupplierValidationTest extends TestCase +{ + private ValidatorInterface $validator; + + protected function setUp(): void + { + $this->validator = Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->getValidator() + ; + } + + // === RG-2.10 : categories de type FOURNISSEUR === + + public function testFournisseurCategoryIsAccepted(): void + { + $supplier = $this->validSupplier(); + + self::assertSame([], $this->violationPaths($supplier)); + } + + public function testNonFournisseurCategoryIsRejectedOnCategoriesPath(): void + { + $supplier = new Supplier(); + $supplier->setCompanyName('Recycla SAS'); + $supplier->addCategory($this->category('CLIENT')); + + self::assertContains('categories', $this->violationPaths($supplier)); + } + + public function testMultiTypeCategoryContainingFournisseurIsAccepted(): void + { + // RG-2.10 sous ManyToMany : une categorie qui PORTE FOURNISSEUR (parmi + // d'autres types) reste autorisee sur un fournisseur. + $supplier = new Supplier(); + $supplier->setCompanyName('Recycla SAS'); + $supplier->addCategory($this->category('CLIENT', 'FOURNISSEUR')); + + self::assertNotContains('categories', $this->violationPaths($supplier)); + } + + // === RG-2.07 : Virement impose une banque === + + public function testVirementWithoutBankIsRejectedOnBankPath(): void + { + $supplier = $this->validSupplier(); + $supplier->setPaymentType($this->paymentType('VIREMENT')); + + self::assertContains('bank', $this->violationPaths($supplier)); + } + + public function testVirementWithBankPasses(): void + { + $supplier = $this->validSupplier(); + $supplier->setPaymentType($this->paymentType('VIREMENT')); + $supplier->setBank(new Bank()); + + self::assertNotContains('bank', $this->violationPaths($supplier)); + } + + // === RG-2.08 : LCR impose au moins un RIB === + + public function testLcrWithoutRibIsRejectedOnRibsPath(): void + { + $supplier = $this->validSupplier(); + $supplier->setPaymentType($this->paymentType('LCR')); + + self::assertContains('ribs', $this->violationPaths($supplier)); + } + + public function testLcrWithRibPasses(): void + { + $supplier = $this->validSupplier(); + $supplier->setPaymentType($this->paymentType('LCR')); + $supplier->addRib(new SupplierRib()); + + self::assertNotContains('ribs', $this->violationPaths($supplier)); + } + + public function testNeutralPaymentTypeRequiresNeitherBankNorRib(): void + { + // Un type de reglement neutre (ni VIREMENT ni LCR) n'exige ni banque ni RIB. + $supplier = $this->validSupplier(); + $supplier->setPaymentType($this->paymentType('CHEQUE')); + + $paths = $this->violationPaths($supplier); + self::assertNotContains('bank', $paths); + self::assertNotContains('ribs', $paths); + } + + /** + * Fournisseur valide (nom + 1 categorie FOURNISSEUR), sans onglet + * Comptabilite renseigne : sert de base aux tests RG-2.07/2.08. + */ + private function validSupplier(): Supplier + { + $supplier = new Supplier(); + $supplier->setCompanyName('Recycla SAS'); + $supplier->addCategory($this->category('FOURNISSEUR')); + + return $supplier; + } + + /** + * @return list propertyPaths des violations levees par le validator + */ + private function violationPaths(Supplier $supplier): array + { + $paths = []; + foreach ($this->validator->validate($supplier) as $violation) { + $paths[] = $violation->getPropertyPath(); + } + + return $paths; + } + + /** + * Double minimal de CategoryInterface (pas d'acces base) PORTANT les codes de + * type voulus — seul element regarde par validateCategoryType. Variadic pour + * couvrir le cas multi-types (ManyToMany). + * + * @return list n'est pas le type de retour : helper renvoyant un double + */ + private function category(string ...$typeCodes): CategoryInterface + { + return new class(array_values($typeCodes)) implements CategoryInterface { + /** @param list $typeCodes */ + public function __construct(private readonly array $typeCodes) {} + + public function getId(): ?int + { + return 1; + } + + public function getName(): ?string + { + return 'Categorie test'; + } + + public function getCode(): ?string + { + return 'TEST'; + } + + /** @return list */ + public function getCategoryTypeCodes(): array + { + return $this->typeCodes; + } + }; + } + + private function paymentType(string $code): PaymentType + { + $type = new PaymentType(); + $type->setCode($code); + $type->setLabel($code); + + return $type; + } +} diff --git a/tests/Module/Commercial/Unit/SupplierInformationCompletenessValidatorTest.php b/tests/Module/Commercial/Unit/SupplierInformationCompletenessValidatorTest.php new file mode 100644 index 0000000..40cc545 --- /dev/null +++ b/tests/Module/Commercial/Unit/SupplierInformationCompletenessValidatorTest.php @@ -0,0 +1,129 @@ +completeSupplier(); + + $this->validator()->validate($supplier); + + // Aucune exception levee : la completude est satisfaite. + $this->addToAssertionCount(1); + } + + public function testEmptyInformationListsEveryMissingField(): void + { + $supplier = new Supplier(); + $supplier->setCompanyName('Recycla SAS'); // onglet principal, hors Information + + try { + $this->validator()->validate($supplier); + self::fail('Une ValidationException etait attendue (onglet Information vide).'); + } catch (ValidationException $e) { + $paths = []; + foreach ($e->getConstraintViolationList() as $violation) { + $paths[] = $violation->getPropertyPath(); + } + + // Les 8 champs Information (dont volumeForecast, NEW vs Client) sont + // tous signales d'un coup, chacun sous son propre propertyPath. + sort($paths); + self::assertSame([ + 'competitors', + 'description', + 'directorName', + 'employeesCount', + 'foundedAt', + 'profitAmount', + 'revenueAmount', + 'volumeForecast', + ], $paths); + } + } + + public function testPartialInformationReportsOnlyMissingFields(): void + { + $supplier = $this->completeSupplier(); + $supplier->setDirectorName(null); + $supplier->setVolumeForecast(null); + + try { + $this->validator()->validate($supplier); + self::fail('Une ValidationException etait attendue (2 champs manquants).'); + } catch (ValidationException $e) { + $paths = []; + foreach ($e->getConstraintViolationList() as $violation) { + $paths[] = $violation->getPropertyPath(); + } + + sort($paths); + self::assertSame(['directorName', 'volumeForecast'], $paths); + } + } + + public function testZeroNumericValuesAreNotMissing(): void + { + // employeesCount = 0, profitAmount = "0.00", volumeForecast = 0 sont des + // valeurs valides (un zero n'est pas une absence) -> pas de violation. + $supplier = $this->completeSupplier(); + $supplier->setEmployeesCount(0); + $supplier->setProfitAmount('0.00'); + $supplier->setVolumeForecast(0); + + $this->validator()->validate($supplier); + + $this->addToAssertionCount(1); + } + + public function testBlankStringIsMissing(): void + { + // Une chaine vide apres trim compte comme manquante. + $supplier = $this->completeSupplier(); + $supplier->setDescription(' '); + + $this->expectException(ValidationException::class); + $this->validator()->validate($supplier); + } + + /** + * Fournisseur dont l'onglet Information est entierement renseigne. + */ + private function completeSupplier(): Supplier + { + $supplier = new Supplier(); + $supplier->setCompanyName('Recycla SAS'); + $supplier->setDescription('Specialiste du recyclage'); + $supplier->setCompetitors('Concurrent A, Concurrent B'); + $supplier->setFoundedAt(new DateTimeImmutable('2010-01-01')); + $supplier->setEmployeesCount(42); + $supplier->setRevenueAmount('1000000.00'); + $supplier->setDirectorName('Marie Durand'); + $supplier->setProfitAmount('150000.00'); + $supplier->setVolumeForecast(5000); + + return $supplier; + } + + private function validator(): SupplierInformationCompletenessValidator + { + return new SupplierInformationCompletenessValidator(); + } +} diff --git a/tests/Module/Commercial/Unit/SupplierProcessorTest.php b/tests/Module/Commercial/Unit/SupplierProcessorTest.php new file mode 100644 index 0000000..250ae0a --- /dev/null +++ b/tests/Module/Commercial/Unit/SupplierProcessorTest.php @@ -0,0 +1,244 @@ + 422, meme + // sur un POST (les champs Information n'y sont pas renseignables). + $supplier = $this->minimalSupplier(); + $supplier->setDescription('Une description'); // les autres champs Information restent null + + $processor = $this->makeProcessor( + payload: ['description' => 'Une description'], + user: $this->commercialeUser(), + ); + + $this->expectException(ValidationException::class); + $processor->process($supplier, $this->operation()); + } + + public function testCommercialeIncompleteInformationOnMainOnlyPatchIsUnprocessable(): void + { + // RG-2.03 : pour une Commerciale, la completude Information est exigee + // meme quand le payload ne touche PAS l'onglet Information (ici + // companyName seul) -> 422. + $supplier = $this->minimalSupplier(); + $supplier->setCompanyName('Renamed Co'); + + $processor = $this->makeProcessor( + granted: ['commercial.suppliers.manage'], + payload: ['companyName' => 'Renamed Co'], + user: $this->commercialeUser(), + managed: true, + originalData: [ + 'companyName' => 'TEST CO', + 'isArchived' => false, + ], + ); + + $this->expectException(ValidationException::class); + $processor->process($supplier, $this->operation()); + } + + public function testCommercialeCompleteInformationPasses(): void + { + // RG-2.03 satisfaite : tous les champs Information renseignes -> 200. + $supplier = $this->completeInformationSupplier(); + + $processor = $this->makeProcessor( + granted: ['commercial.suppliers.manage'], + payload: ['description' => 'desc'], + user: $this->commercialeUser(), + ); + + self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation())); + } + + public function testNonCommercialeSkipsInformationCompleteness(): void + { + // Meme onglet Information incomplet, mais user non-Commerciale -> aucun + // blocage (la completude est specifique a la Commerciale). + $supplier = $this->minimalSupplier(); + $supplier->setDescription('Une description'); + + $processor = $this->makeProcessor( + payload: ['description' => 'Une description'], + user: null, + ); + + 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 + * @param bool $managed true = entite geree par l'ORM (PATCH), false = creation (POST) + * @param array $originalData Etat persiste simule (getOriginalEntityData) + */ + private function makeProcessor( + array $granted = [], + array $payload = [], + ?UserInterface $user = null, + bool $managed = false, + array $originalData = [], + ): SupplierProcessor { + $persist = new class implements ProcessorInterface { + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + return $data; + } + }; + + $security = $this->createStub(Security::class); + $security->method('isGranted')->willReturnCallback( + static fn (mixed $attribute): bool => is_string($attribute) && in_array($attribute, $granted, true), + ); + $security->method('getUser')->willReturn($user); + + $requestStack = new RequestStack(); + $requestStack->push(new Request([], [], [], [], [], [], json_encode($payload, JSON_THROW_ON_ERROR))); + + $uow = $this->createMock(UnitOfWork::class); + $uow->method('getOriginalEntityData')->willReturn($originalData); + + $em = $this->createMock(EntityManagerInterface::class); + $em->method('contains')->willReturn($managed); + $em->method('getUnitOfWork')->willReturn($uow); + + return new SupplierProcessor( + $persist, + new SupplierFieldNormalizer(), + new SupplierInformationCompletenessValidator(), + $security, + $requestStack, + $em, + ); + } + + private function minimalSupplier(): Supplier + { + $supplier = new Supplier(); + $supplier->setCompanyName('Test Co'); + + return $supplier; + } + + private function completeInformationSupplier(): Supplier + { + $supplier = $this->minimalSupplier(); + $supplier->setDescription('desc'); + $supplier->setCompetitors('concurrents'); + $supplier->setFoundedAt(new DateTimeImmutable('2010-01-01')); + $supplier->setEmployeesCount(10); + $supplier->setRevenueAmount('1000.00'); + $supplier->setDirectorName('Marie Durand'); + $supplier->setProfitAmount('100.00'); + $supplier->setVolumeForecast(500); + + return $supplier; + } + + private function operation(): Operation + { + 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 { + public function hasBusinessRole(string $roleCode): bool + { + return BusinessRoles::COMMERCIALE === $roleCode; + } + + public function getRoles(): array + { + return ['ROLE_USER']; + } + + public function eraseCredentials(): void {} + + public function getUserIdentifier(): string + { + return 'commerciale-test'; + } + }; + } +} 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();