Compare commits

..

1 Commits

Author SHA1 Message Date
gitea-actions 08281613b9 chore: bump version to v0.1.91
Build & Push Docker Image / build (push) Has been cancelled
2026-06-08 07:55:17 +00:00
97 changed files with 925 additions and 4524 deletions
+2 -2
View File
@@ -75,7 +75,7 @@ jobs:
- name: Bootstrap test database - name: Bootstrap test database
# Aligne sur la cible `test-db-setup` du makefile : apres # Aligne sur la cible `test-db-setup` du makefile : apres
# `schema:update --force`, on RECREE manuellement l'index unique # `schema:update --force`, on RECREE manuellement l'index unique
# partiel `uq_category_name_active` car Doctrine ORM ne sait # partiel `uq_category_name_type_active` car Doctrine ORM ne sait
# pas exprimer les index fonctionnels partiels (LOWER(name) + WHERE # pas exprimer les index fonctionnels partiels (LOWER(name) + WHERE
# deleted_at IS NULL) et `schema:update` les considere comme # deleted_at IS NULL) et `schema:update` les considere comme
# orphelins et les DROP — collisions non detectees, tests d'unicite # 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 app:apply-column-comments --env=test --no-interaction
php bin/console doctrine:fixtures:load --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 app:sync-permissions --env=test --no-interaction
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" 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"
- name: Run PHPUnit - name: Run PHPUnit
run: php -d memory_limit=512M vendor/bin/phpunit run: php -d memory_limit=512M vendor/bin/phpunit
+1 -1
View File
@@ -141,7 +141,7 @@ Disponibles uniquement après `make db-reset` / `make fixtures` (état « avec s
| `bob` | `bob` | ROLE_USER | — | | `bob` | `bob` | ROLE_USER | — |
| `bureau` | `demo` | ROLE_USER | clients : view + manage | | `bureau` | `demo` | ROLE_USER | clients : view + manage |
| `compta` | `demo` | ROLE_USER | clients : view + accounting.view / manage | | `compta` | `demo` | ROLE_USER | clients : view + accounting.view / manage |
| `commerciale` | `demo` | ROLE_USER | clients : view + manage | | `commerciale` | `demo` | ROLE_USER | clients : view + manage (Information obligatoire — RG-1.04) |
| `usine` | `demo` | ROLE_USER | aucun accès clients | | `usine` | `demo` | ROLE_USER | aucun accès clients |
--- ---
-12
View File
@@ -1,12 +0,0 @@
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
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.99' app.version: '0.1.91'
+5 -4
View File
@@ -10,8 +10,8 @@ trous, zéro duplication »).
ERP-60 n'écrit QUE les tests des RG non déjà couvertes par la stack, et mappe ici ERP-60 n'écrit QUE les tests des RG non déjà couvertes par la stack, et mappe ici
l'intégralité des RG (existantes + nouvelles + déléguées). Les tests dépendants l'intégralité des RG (existantes + nouvelles + déléguées). Les tests dépendants
des **rôles métier** (matrice RBAC bureau/compta/commerciale/usine) sont des **rôles métier** (matrice RBAC bureau/compta/commerciale/usine + RG-1.04
**délégués à ERP-74 (#493)** : ces rôles n'existent qu'après le fonctionnel) sont **délégués à ERP-74 (#493)** : ces rôles n'existent qu'après le
merge de la stack. merge de la stack.
## Mapping RG → test ## Mapping RG → test
@@ -21,7 +21,7 @@ merge de la stack.
| ~~RG-1.01~~ | _(supprimée V1 — refonte-contact)_ contact inline retiré du Client ; complétude couverte par RG-1.05 / RG-1.14 (`ClientContact`) | — | refonte-contact | | ~~RG-1.01~~ | _(supprimée V1 — refonte-contact)_ contact inline retiré du Client ; complétude couverte par RG-1.05 / RG-1.14 (`ClientContact`) | — | refonte-contact |
| ~~RG-1.02~~ | _(supprimée du Client V1)_ téléphones inline retirés du Client (testés sur `ClientContact`) | — | refonte-contact | | ~~RG-1.02~~ | _(supprimée du Client V1)_ téléphones inline retirés du Client (testés sur `ClientContact`) | — | refonte-contact |
| RG-1.03 | distributor/broker exclusifs + type catégorie | `ClientApiTest::testPostWithDistributorAndBrokerReturns422` ; `::testPostDistributorReferencingNonDistributorReturns422` ; `::testPostValidDistributorReturns201` ; `ClientProcessorTest` (unit) | ERP-55 | | RG-1.03 | distributor/broker exclusifs + type catégorie | `ClientApiTest::testPostWithDistributorAndBrokerReturns422` ; `::testPostDistributorReferencingNonDistributorReturns422` ; `::testPostValidDistributorReturns201` ; `ClientProcessorTest` (unit) | ERP-55 |
| ~~RG-1.04~~ | _(SUPPRIMÉE)_ Onglet Information désormais facultatif pour tous les rôles (validation de complétude retirée) | — | ERP-55 / ERP-74 | | RG-1.04 | Onglet Information obligatoire pour rôle Commerciale | `ClientProcessorTest::testCommercialeIncompleteInformationIsUnprocessable` ; `::testNonCommercialeSkipsInformationCompleteness` (unit, dormant). **Test fonctionnel + durcissement → ERP-74** | ERP-55 / **ERP-74** |
| RG-1.05 | Contact : prénom OU nom → 422 (CHECK) | `ClientSubResourceApiTest::testPostContactWithoutNameReturns422` | ERP-57 | | RG-1.05 | Contact : prénom OU nom → 422 (CHECK) | `ClientSubResourceApiTest::testPostContactWithoutNameReturns422` | ERP-57 |
| RG-1.06/07/08 | Adresse prospect exclusive de livraison/facturation → 422 (Assert\Callback + CHECK filet) | `ClientAddressTest::testProspectAddressCannotBeDelivery` ; `::testProspectAddressCannotBeBilling` | ERP-60 / **ERP-76** | | RG-1.06/07/08 | Adresse prospect exclusive de livraison/facturation → 422 (Assert\Callback + CHECK filet) | `ClientAddressTest::testProspectAddressCannotBeDelivery` ; `::testProspectAddressCannotBeBilling` | ERP-60 / **ERP-76** |
| RG-1.09 | Code postal `^[0-9]{4,5}$` → 422 | `ClientSubResourceApiTest::testPostAddressWithInvalidPostalCodeReturns422` | ERP-57 | | RG-1.09 | Code postal `^[0-9]{4,5}$` → 422 | `ClientSubResourceApiTest::testPostAddressWithInvalidPostalCodeReturns422` | ERP-57 |
@@ -60,7 +60,8 @@ merge de la stack.
- **Matrice RBAC différenciée** par rôle métier (Bureau / Compta / Commerciale / - **Matrice RBAC différenciée** par rôle métier (Bureau / Compta / Commerciale /
Usine) : 200/403 par verbe et par onglet selon le rôle. Usine) : 200/403 par verbe et par onglet selon le rôle.
- ~~**RG-1.04 fonctionnel**~~ : _(SUPPRIMÉE)_ l'onglet Information est désormais facultatif pour tous les rôles. - **RG-1.04 fonctionnel** : PATCH onglet Information par une Commerciale avec
champs incomplets → 422 ; même PATCH par Admin → 200 (+ durcissement code/spec).
- Raison : ces rôles métier ne sont seedés qu'après le merge de la stack M1. - Raison : ces rôles métier ne sont seedés qu'après le merge de la stack M1.
## Gaps & suivi ## Gaps & suivi
+5 -4
View File
@@ -310,7 +310,7 @@ CREATE TABLE client (
distributor_id INT REFERENCES client(id) ON DELETE SET NULL, distributor_id INT REFERENCES client(id) ON DELETE SET NULL,
broker_id INT REFERENCES client(id) ON DELETE SET NULL, broker_id INT REFERENCES client(id) ON DELETE SET NULL,
triage_service BOOLEAN NOT NULL DEFAULT FALSE, triage_service BOOLEAN NOT NULL DEFAULT FALSE,
-- Onglet Information (facultatif pour tous — RG-1.04 supprimée) -- Onglet Information (Commerciale obligatoire — RG-1.04 — null sinon)
description TEXT, description TEXT,
competitors VARCHAR(255), competitors VARCHAR(255),
founded_at DATE, founded_at DATE,
@@ -864,7 +864,8 @@ Cf. § 2.6. Pattern Shared standard.
### Onglet Information ### Onglet Information
- ~~**RG-1.04**~~ _(SUPPRIMÉE)_ : l'onglet Information est désormais **facultatif pour tous les rôles**, y compris Commerciale. Les champs `description`, `competitors`, `foundedAt`, `employeesCount`, `revenueAmount`, `directorName`, `profitAmount` restent optionnels (colonnes nullable, aucune validation de complétude). Le validator `ClientInformationCompletenessValidator` et le gating par rôle métier dans le `ClientProcessor` ont été retirés. - **RG-1.04** _(durcie — ERP-74)_ : Pour un utilisateur portant le rôle métier **Commerciale**, **tous** les champs de l'onglet Information (`description`, `competitors`, `foundedAt`, `employeesCount`, `revenueAmount`, `directorName`, `profitAmount`) sont obligatoires sur **POST et sur tout PATCH**, **indépendamment des champs réellement envoyés** (la condition d'intersection avec `client:write:information` a été retirée). Pour les autres rôles, ces champs restent optionnels. Implémenté via un validator custom `ClientInformationCompletenessValidator` invoqué systématiquement par le `ClientProcessor` quand le user porte le rôle Commerciale.
- **Conséquence** : le POST n'exposant que le groupe `client:write:main`, l'onglet Information n'y est pas renseignable → une Commerciale obtient **422** sur tout POST (cf. § 8.1). La complétude se fait donc via les PATCH `client:write:information` ultérieurs. Un Admin (non gaté) crée normalement (201).
### Onglet Contact ### Onglet Contact
@@ -882,7 +883,7 @@ Cf. § 2.6. Pattern Shared standard.
### Onglet Comptabilité ### Onglet Comptabilité
- **RG-1.30** _(ajoutée — correctif incohérence spec-front/spec-back)_ : à la **validation complète de l'onglet Comptabilité**, les six champs scalaires `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType` sont **obligatoires** (alignement sur spec-front § Onglet Comptabilité). Colonnes `nullable` en base (l'onglet est rempli dans un second temps, et l'onglet principal ne les envoie pas) + validateur contextuel `ClientAccountingCompletenessValidator` invoqué par le `ClientProcessor` (parti pris : nullable + validateur d'onglet plutôt qu'un `Assert\NotBlank` sur l'entité). Déclenchement : uniquement quand **les six champs sont présents dans le payload** (le front les envoie toujours ensemble via « Valider ») ; un PATCH ciblant un sous-ensemble de champs comptables (édition ponctuelle) n'est pas soumis à la complétude. Chaque champ manquant → 422 sur son `propertyPath` (mapping inline front, ERP-101). `bank` reste hors complétude (conditionnel RG-1.12). - **RG-1.30** _(ajoutée — correctif incohérence spec-front/spec-back)_ : à la **validation complète de l'onglet Comptabilité**, les six champs scalaires `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType` sont **obligatoires** (alignement sur spec-front § Onglet Comptabilité). Colonnes `nullable` en base (l'onglet est rempli dans un second temps, et l'onglet principal ne les envoie pas) + validateur contextuel `ClientAccountingCompletenessValidator` invoqué par le `ClientProcessor` — même parti que RG-1.04 (Information). Déclenchement : uniquement quand **les six champs sont présents dans le payload** (le front les envoie toujours ensemble via « Valider ») ; un PATCH ciblant un sous-ensemble de champs comptables (édition ponctuelle) n'est pas soumis à la complétude. Chaque champ manquant → 422 sur son `propertyPath` (mapping inline front, ERP-101). `bank` reste hors complétude (conditionnel RG-1.12).
- **RG-1.12** : Le champ `bank` est visible et obligatoire **uniquement** si `paymentType.code = 'VIREMENT'`. Validation server-side dans le `ClientProcessor` : si `payment_type.code = VIREMENT` et `bank IS NULL` → 422. - **RG-1.12** : Le champ `bank` est visible et obligatoire **uniquement** si `paymentType.code = 'VIREMENT'`. Validation server-side dans le `ClientProcessor` : si `payment_type.code = VIREMENT` et `bank IS NULL` → 422.
- **RG-1.13** : Les champs RIB (`label`, `bic`, `iban`) sont obligatoires si **au moins un bloc RIB est présent ET** `paymentType.code = 'LCR'`. C'est-à-dire : - **RG-1.13** : Les champs RIB (`label`, `bic`, `iban`) sont obligatoires si **au moins un bloc RIB est présent ET** `paymentType.code = 'LCR'`. C'est-à-dire :
- Si `paymentType.code = LCR` ET `client.ribs.count() = 0` → 422 « Au moins un RIB est obligatoire pour le type LCR ». - Si `paymentType.code = LCR` ET `client.ribs.count() = 0` → 422 « Au moins un RIB est obligatoire pour le type LCR ».
@@ -937,7 +938,7 @@ Cf. § 2.6. Pattern Shared standard.
- [ ] ~~RG-1.02~~ _(supprimée du Client V1)_ : plus de téléphones inline sur le Client (téléphones testés sur `ClientContact`) - [ ] ~~RG-1.02~~ _(supprimée du Client V1)_ : plus de téléphones inline sur le Client (téléphones testés sur `ClientContact`)
- [ ] **RG-1.03** : POST avec distributor ET broker → 422 ; POST distributor seul → 201 - [ ] **RG-1.03** : POST avec distributor ET broker → 422 ; POST distributor seul → 201
- [ ] **RG-1.03** : POST distributor référençant un client SANS catégorie de code DISTRIBUTEUR → 422 (validation custom `ClientProcessor::hasCategoryCode`) - [ ] **RG-1.03** : POST distributor référençant un client SANS catégorie de code DISTRIBUTEUR → 422 (validation custom `ClientProcessor::hasCategoryCode`)
- [x] ~~**RG-1.04**~~ _(SUPPRIMÉE)_ : onglet Information facultatif pour tous les rôles (plus de validation de complétude) - [ ] **RG-1.04** : PATCH onglet Information par un user Commerciale avec champs incomplets → 422 ; même PATCH par Admin → 200
- [ ] **RG-1.05** : POST contact sans firstName ni lastName → 422 (BDD CHECK lève une exception) - [ ] **RG-1.05** : POST contact sans firstName ni lastName → 422 (BDD CHECK lève une exception)
- [ ] **RG-1.06/07/08** : POST adresse avec isProspect=true ET isDelivery=true → 422 / CHECK - [ ] **RG-1.06/07/08** : POST adresse avec isProspect=true ET isDelivery=true → 422 / CHECK
- [ ] **RG-1.09** : POST adresse avec postalCode invalide (3 chiffres) → 422 ; CP/ville incohérents → 200 (pas de validation stricte côté serveur) - [ ] **RG-1.09** : POST adresse avec postalCode invalide (3 chiffres) → 422 ; CP/ville incohérents → 200 (pas de validation stricte côté serveur)
+8 -8
View File
@@ -13,7 +13,7 @@ date_redaction: 2026-05-28
# === LIENS === # === LIENS ===
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-31898" maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-31898"
regles_metier: [RG-1.01, RG-1.02, RG-1.03, RG-1.05, RG-1.06, RG-1.07, RG-1.08, RG-1.09, RG-1.10, RG-1.11, RG-1.12, RG-1.13, RG-1.14, RG-1.15, RG-1.16, RG-1.17, RG-1.18, RG-1.19, RG-1.20, RG-1.21, RG-1.22, RG-1.23, RG-1.24, RG-1.25, RG-1.26, RG-1.27, RG-1.28, RG-1.29] regles_metier: [RG-1.01, RG-1.02, RG-1.03, RG-1.04, RG-1.05, RG-1.06, RG-1.07, RG-1.08, RG-1.09, RG-1.10, RG-1.11, RG-1.12, RG-1.13, RG-1.14, RG-1.15, RG-1.16, RG-1.17, RG-1.18, RG-1.19, RG-1.20, RG-1.21, RG-1.22, RG-1.23, RG-1.24, RG-1.25, RG-1.26, RG-1.27, RG-1.28, RG-1.29]
roles: [Admin, Bureau, Compta, Commerciale, Usine] roles: [Admin, Bureau, Compta, Commerciale, Usine]
lien_spec_back: ./spec-back.md lien_spec_back: ./spec-back.md
@@ -105,13 +105,13 @@ Saisir les informations de l'entreprise.
| Champ | Type | Obligatoire | Règle | | Champ | Type | Obligatoire | Règle |
|---|---|---|---| |---|---|---|---|
| **Description** | `<MalioInputTextArea>` | Non | — _(onglet facultatif, RG-1.04 supprimée)_ | | **Description** | `<MalioInputTextArea>` | Conditionnel | RG-1.04 (obligatoire pour rôle Commerciale) |
| **Concurrents** | `<MalioInputText>` | Non | — | | **Concurrents** | `<MalioInputText>` | Conditionnel | RG-1.04 |
| **Date de création** (de l'entreprise) | `<input type="date">` (exception Malio — pas de composant date couvert) | Non | — | | **Date de création** (de l'entreprise) | `<input type="date">` (exception Malio — pas de composant date couvert) | Conditionnel | RG-1.04 |
| **Nombre de salariés** | `<MalioInputNumber>` | Non | — | | **Nombre de salariés** | `<MalioInputNumber>` | Conditionnel | RG-1.04 |
| **CA €** | `<MalioInputAmount>` | Non | — | | **CA €** | `<MalioInputAmount>` | Conditionnel | RG-1.04 |
| **Dirigeant** | `<MalioInputText>` | Non | — | | **Dirigeant** | `<MalioInputText>` | Conditionnel | RG-1.04 |
| **Résultat €** | `<MalioInputAmount>` | Non | — | | **Résultat €** | `<MalioInputAmount>` | Conditionnel | RG-1.04 |
**Action** : « Valider » → PATCH partiel `/api/clients/{id}` (groupe `client:write:information`). **Action** : « Valider » → PATCH partiel `/api/clients/{id}` (groupe `client:write:information`).
+60 -87
View File
@@ -126,7 +126,7 @@ Toutes les entités métier nouvelles implémentent `TimestampableInterface` + `
Notes (miroir M1) : 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). - **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). 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`). - **Commerciale** a `view` + `manage` mais **pas** `accounting.view` → l'onglet Comptabilité est masqué (front) et filtré (back, 2 niveaux : `security` API Platform + `SupplierProvider`).
- **Bureau** : `view` + `manage` (tout sauf Comptabilité). - **Bureau** : `view` + `manage` (tout sauf Comptabilité).
- **Usine** : aucune permission → item sidebar invisible, accès direct 403. - **Usine** : aucune permission → item sidebar invisible, accès direct 403.
@@ -159,11 +159,9 @@ final class SupplierFieldNormalizer
Le formatage `XX XX XX XX XX` est fait à l'affichage côté front. Le back stocke `0612345678` (chiffres seuls). 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 + hydratation anti-N+1 (cohérence M1/ERP-62) ### 2.12 Liste : embed catégories + sites + fetch-joins (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. 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.
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é. > 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é.
@@ -215,8 +213,6 @@ 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, '<table>')`. 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). > **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, '<table>')`. 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 ```sql
-- ===================================================================== -- =====================================================================
-- Seed taxonomie : nouveau type FOURNISSEUR (référentiels comptables = M1, non recréés) -- Seed taxonomie : nouveau type FOURNISSEUR (référentiels comptables = M1, non recréés)
@@ -426,10 +422,8 @@ use Symfony\Component\Validator\Constraints as Assert;
// Cohérence M1/ERP-62 : la LISTE embarque catégories + sites (pas de // Cohérence M1/ERP-62 : la LISTE embarque catégories + sites (pas de
// champ dérivé aplati). Maillon (c) : category:read + site:read dans // champ dérivé aplati). Maillon (c) : category:read + site:read dans
// le contexte pour exposer Category(code/name) + Site(name/postalCode). // le contexte pour exposer Category(code/name) + Site(name/postalCode).
// ⚠ Anti-N+1 : pas de fetch-join dans la requête de liste — le // ⚠ Le SupplierRepository DOIT fetch-join categories + addresses.sites
// SupplierRepository hydrate categories/sites/contacts via des requêtes // pour éviter le N+1 sur la liste (cf. § 2.12).
// 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' => [ normalizationContext: ['groups' => [
'supplier:read', 'supplier:read',
'category:read', 'category:read',
@@ -448,14 +442,13 @@ use Symfony\Component\Validator\Constraints as Assert;
normalizationContext: ['groups' => [ normalizationContext: ['groups' => [
'supplier:read', 'supplier:read',
'supplier:item:read', // embed contacts / addresses 'supplier:item:read', // embed contacts / addresses
// ⚠ supplier:read:accounting est volontairement ABSENT ici : il est 'supplier:read:accounting', // scalaires compta + embed ribs (filtré par le Provider selon accounting.view)
// 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 '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 'site:read', // embed des Site (id/name/postalCode/city/color, pas de code) — relation imbriquée
'default:read', '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, provider: SupplierProvider::class,
), ),
new Post( new Post(
@@ -465,13 +458,10 @@ use Symfony\Component\Validator\Constraints as Assert;
processor: SupplierProcessor::class, processor: SupplierProcessor::class,
), ),
new Patch( new Patch(
// Security élargie : `manage` OU `accounting.manage` — le rôle Compta security: "is_granted('commercial.suppliers.manage')",
// n'a pas `manage` mais doit pouvoir éditer l'onglet Comptabilité d'un // Le SupplierProcessor inspecte les groupes envoyés pour autoriser
// fournisseur existant (§ 2.9). Le SupplierProcessor re-gate ensuite // onglet par onglet (cf. § 2.10 + § 5). Patch des champs comptables
// onglet par onglet (mode strict RG-2.16) : // exige is_granted('commercial.suppliers.accounting.manage') ;
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'). // patch isArchived exige is_granted('commercial.suppliers.archive').
normalizationContext: ['groups' => ['supplier:read', 'default:read']], normalizationContext: ['groups' => ['supplier:read', 'default:read']],
denormalizationContext: ['groups' => [ denormalizationContext: ['groups' => [
@@ -721,108 +711,91 @@ Même pattern que les jumelles `Client*` (`#[Auditable]`, `TimestampableBlamable
| Scalaires Comptabilité (siren, refs…) | `supplier:read:accounting` | ✅ (gated) | refs (`tvaMode`…) id+label ∈ `supplier:read:accounting` | | Scalaires Comptabilité (siren, refs…) | `supplier:read:accounting` | ✅ (gated) | refs (`tvaMode`…) id+label ∈ `supplier:read:accounting` |
| `ribs[]` (label/bic/iban) | `ribs``supplier:read:accounting` | ✅ (gated) | — | | `ribs[]` (label/bic/iban) | `ribs``supplier:read:accounting` | ✅ (gated) | — |
### 4.0.bis Réponses JSON de référence (DoD — RÉELLES, capturées ERP-92) ### 4.0.bis Réponses JSON de référence (DoD — à confirmer sur l'API réelle)
> **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. > **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.
>
> **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`. > **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?search=…` (liste, ADMIN — un membre) : `GET /api/suppliers` (liste, ADMIN) :
```json ```json
{ {
"@context": "/api/contexts/Supplier", "@context": "/api/contexts/Supplier",
"@id": "/api/suppliers", "@id": "/api/suppliers",
"@type": "Collection", "@type": "Collection",
"totalItems": 1, "totalItems": 13,
"member": [ "member": [
{ {
"@id": "/api/suppliers/85", "@id": "/api/suppliers/1",
"@type": "Supplier", "@type": "Supplier",
"id": 85, "id": 1,
"companyName": "DOD59393F 862875", "companyName": "RECYCLA SAS",
"categories": [ "categories": [
{"@type": "Category", "@id": "/api/categories/2279", "id": 2279, "name": "test_cli_cat_fr_negociant", "code": "NEGOCIANT", {"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}
"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": [ "sites": [
{"@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"}, {"@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"},
{"@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"} {"@id": "/api/sites/2", "id": 2, "name": "Saint-Jean", "postalCode": "17400", "city": "Fontenet", "color": "#"}
], ],
"updatedAt": "2026-02-17T09:30:00+00:00",
"isArchived": false "isArchived": false
} }
], ],
"view": {"@id": "/api/suppliers?search=…", "@type": "PartialCollectionView"} "view": {
"@id": "/api/suppliers?page=1",
"@type": "PartialCollectionView",
"first": "/api/suppliers?page=1",
"last": "/api/suppliers?page=2",
"next": "/api/suppliers?page=2"
}
} }
``` ```
> 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. > 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).
`GET /api/suppliers/85` (détail — user avec `accounting.view`) : `GET /api/suppliers/1` (détail — user avec `accounting.view`) :
```json ```json
{ {
"@context": "/api/contexts/Supplier", "@id": "/api/suppliers/1",
"@id": "/api/suppliers/85",
"@type": "Supplier", "@type": "Supplier",
"id": 85, "id": 1,
"companyName": "DOD59393F 862875", "companyName": "RECYCLA SAS",
"categories": [ "categories": [
{"@type": "Category", "@id": "/api/categories/2279", "id": 2279, "name": "test_cli_cat_fr_negociant", "code": "NEGOCIANT", {"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}
"categoryType": {"@id": "/api/category_types/602", "@type": "CategoryType", "id": 602, "code": "FOURNISSEUR", "label": "Fournisseur"}}
], ],
"description": "Fournisseur de test complet.", "competitors": "Concurrent A, Concurrent B", "description": "…", "competitors": "…", "foundedAt": "2008-04-01",
"foundedAt": "2008-04-01T00:00:00+02:00", "employeesCount": 42, "revenueAmount": "1500000.00", "employeesCount": 42, "revenueAmount": "1500000.00", "directorName": "…",
"directorName": "Jean Dupont", "profitAmount": "120000.00", "volumeForecast": 8000, "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": [ "contacts": [
{"@id": "/api/supplier_contacts/39", "@type": "SupplierContact", "id": 39, "firstName": "Marie", "lastName": "Martin", {"@id": "/api/supplier_contacts/1", "id": 1, "firstName": "Marie", "lastName": "Martin",
"jobTitle": "Responsable achats", "phonePrimary": "0612345678", "email": "marie.martin@seed.test"} "jobTitle": "Responsable achats", "phonePrimary": "0612345678", "phoneSecondary": null,
"email": "marie.martin@recycla.fr"}
], ],
"addresses": [ "addresses": [
{"@id": "/api/supplier_addresses/33", "@type": "SupplierAddress", "id": 33, "addressType": "DEPART", {"@id": "/api/supplier_addresses/1", "id": 1, "addressType": "DEPART",
"country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias", "country": "France", "postalCode": "86000", "city": "Poitiers",
"street": "12 rue des Acacias", "streetComplement": null,
"bennes": 3, "triageProvider": true, "bennes": 3, "triageProvider": true,
"sites": [ "sites": [{"@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}],
{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}, "categories": [{"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}],
{"@type": "Site", "@id": "/api/sites/88", "id": 88, "name": "Saint-Jean", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00"} "contacts": [{"@id": "/api/supplier_contacts/1", "id": 1, "firstName": "Marie", "lastName": "Martin"}]}
],
"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": [ "ribs": [
{"@id": "/api/supplier_ribs/27", "@type": "SupplierRib", "id": 27, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"} {"@id": "/api/supplier_ribs/1", "id": 1, "label": "Compte principal",
"bic": "SOGEFRPP", "iban": "FR7630003035400005000000123"}
], ],
"isArchived": false "isArchived": false, "archivedAt": null,
"updatedAt": "2026-02-17T09:30:00+00:00"
} }
``` ```
> 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). > 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.
### 4.0.ter Pièges de sérialisation CONSTATÉS sur le M1 réel → parade M2 (OBLIGATOIRE) ### 4.0.ter Pièges de sérialisation CONSTATÉS sur le M1 réel → parade M2 (OBLIGATOIRE)
@@ -1073,7 +1046,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] 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** - [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**
- [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 - [ ] **Réponses JSON RÉELLES** collées (§ 4.0.bis) — *en attente de `make start` + curl (DoD avant tickets front)*
- [x] Matrice RBAC rôle × onglet + mode strict PATCH (§ 2.9 / RG-2.16) - [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] 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) - [x] Réutilisations M1 identifiées (référentiels compta partagés, taxonomie code/type, `usePaginatedList`, blocs, archive, normalisation)
+4 -3
View File
@@ -2,9 +2,7 @@
Valeurs en dur issues de la maquette Figma (design Starseed) : Valeurs en dur issues de la maquette Figma (design Starseed) :
- sidebar depliee : 232px (w-[232px], repli laisse par defaut 72px) - sidebar depliee : 232px (w-[232px], repli laisse par defaut 72px)
- marge horizontale du contenu sur desktop : 170px (xl:px-[170px]) - marge horizontale du contenu sur desktop : 170px (xl:px-[170px])
La marge haute du contenu (44px) vit desormais DANS l'entete (PageHeader, - bande blanche sticky sous la navbar : 47px (h-[47px])
pt-11) et non sur le <main> : sinon, l'entete etant sticky, ce padding
laissait un trou blanc entre le SiteSelector et l'entete.
A faire evoluer uniquement avec une mise a jour de maquette. A faire evoluer uniquement avec une mise a jour de maquette.
--> -->
<template> <template>
@@ -27,6 +25,9 @@
<SiteSelector v-if="showSiteSelector"/> <SiteSelector v-if="showSiteSelector"/>
<main <main
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-10 sm:px-6 lg:px-12 xl:px-11"> class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-10 sm:px-6 lg:px-12 xl:px-11">
<div
aria-hidden="true"
class="pointer-events-none sticky top-0 z-30 h-11 flex-shrink-0 bg-white"/>
<slot/> <slot/>
</main> </main>
</div> </div>
+6 -11
View File
@@ -416,26 +416,21 @@
"newCategory": "Ajouter", "newCategory": "Ajouter",
"editCategory": "Modifier la catégorie", "editCategory": "Modifier la catégorie",
"createCategory": "Créer une catégorie", "createCategory": "Créer une catégorie",
"viewCategory": "Détail de la catégorie",
"noCategories": "Aucune catégorie pour l'instant.", "noCategories": "Aucune catégorie pour l'instant.",
"table": { "table": {
"name": "Nom", "name": "Nom",
"types": "Types" "type": "Type"
},
"filters": {
"title": "Filtres",
"search": "Recherche",
"types": "Types de catégorie",
"apply": "Voir les résultats",
"reset": "Réinitialiser"
}, },
"form": { "form": {
"name": "Nom", "name": "Nom",
"types": "Types de catégorie" "type": "Type de catégorie",
"typePlaceholder": "Sélectionner un type"
}, },
"validation": { "validation": {
"nameRequired": "Le nom est obligatoire.", "nameRequired": "Le nom est obligatoire.",
"nameLength": "Le nom doit faire entre 2 et 120 caractères.", "nameLength": "Le nom doit faire entre 2 et 120 caractères.",
"typesRequired": "Sélectionnez au moins un type de catégorie." "typeRequired": "Le type de catégorie est obligatoire."
}, },
"delete": { "delete": {
"title": "Supprimer la catégorie", "title": "Supprimer la catégorie",
@@ -445,7 +440,7 @@
"created": "Catégorie créée avec succès", "created": "Catégorie créée avec succès",
"updated": "Catégorie mise à jour avec succès", "updated": "Catégorie mise à jour avec succès",
"deleted": "Catégorie supprimée avec succès", "deleted": "Catégorie supprimée avec succès",
"duplicate": "Une catégorie nommée « {name} » existe déjà.", "duplicate": "Une catégorie nommée « {name} » existe déjà pour ce type.",
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez." "typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
} }
} }
@@ -24,17 +24,16 @@
required required
/> />
<!-- Types (RG-1.05 : au moins un obligatoire). MalioSelectCheckbox <!-- Type (RG-1.05 obligatoire). MalioSelect porte la valeur en
porte un tableau d'ids (categoryType id) ; conversion en tableau number (categoryType id) ; conversion en IRI au moment du save
d'IRI au moment du save par le composable useCategoryForm. --> par le composable useCategoryForm. -->
<MalioSelectCheckbox <MalioSelect
v-model="form.categoryTypeIds.value" v-model="form.categoryTypeId.value"
:options="typeOptions" :options="typeOptions"
:label="t('admin.categories.form.types')" :label="t('admin.categories.form.type')"
:error="form.errors.categoryTypes" :empty-option-label="t('admin.categories.form.typePlaceholder')"
:display-tag="true" :error="form.errors.categoryType"
:disabled="loadingTypes" :disabled="loadingTypes"
required
/> />
</form> </form>
@@ -90,17 +89,28 @@ const emit = defineEmits<{
delete: [] delete: []
}>() }>()
// Mode du drawer : creation (pas de category prop, POST au save) ou /**
// modification d'une categorie existante (PATCH au save). Pas de distinction * Mode du drawer (dérivé du composable `useCategoryForm`) :
// view/edit : comme les autres drawers, le titre et le bouton Enregistrer sont * - 'create' : pas de category prop, formulaire vide, POST au save.
// stables quel que soit l'etat « dirty » du formulaire. * - 'view' : category prop set, formulaire pre-rempli, save MASQUE
* jusqu'a ce que l'utilisateur modifie un champ.
* - 'edit' : category prop set et formulaire « dirty » (au moins un
* champ different de l'original), PATCH au save.
*/
type DrawerMode = 'create' | 'view' | 'edit'
const isCreateMode = computed(() => props.category === null) const isCreateMode = computed(() => props.category === null)
const headerLabel = computed(() => const mode = computed<DrawerMode>(() => {
isCreateMode.value if (isCreateMode.value) return 'create'
? t('admin.categories.createCategory') return form.isDirty.value ? 'edit' : 'view'
: t('admin.categories.editCategory'), })
)
const headerLabel = computed(() => {
if (mode.value === 'create') return t('admin.categories.createCategory')
if (mode.value === 'edit') return t('admin.categories.editCategory')
return t('admin.categories.viewCategory')
})
// Le bouton Supprimer n'est visible qu'en consultation/edition d'une categorie // Le bouton Supprimer n'est visible qu'en consultation/edition d'une categorie
// existante et seulement pour les users ayant la permission manage. En mode // existante et seulement pour les users ayant la permission manage. En mode
@@ -109,12 +119,10 @@ const canShowDelete = computed(
() => !isCreateMode.value && can('catalog.categories.manage'), () => !isCreateMode.value && can('catalog.categories.manage'),
) )
// Save : visible en creation, et en consultation/edition d'une categorie // Save : visible en creation, ou en edition (apres modification d'un champ).
// existante (l'utilisateur doit pouvoir enregistrer sans qu'un champ ait // Masque en view tant que rien n'a change.
// d'abord ete modifie). Le bouton reste neanmoins protege par son `disabled`
// pendant la soumission / le chargement des types.
const canShowSave = computed( const canShowSave = computed(
() => isCreateMode.value || can('catalog.categories.manage'), () => mode.value === 'create' || mode.value === 'edit',
) )
const typeOptions = computed(() => const typeOptions = computed(() =>
@@ -144,18 +152,18 @@ watch(
) )
/** /**
* Sauvegarde : delegue au composable (POST en creation, PATCH en modification). * Sauvegarde : delegue au composable (POST en mode create, PATCH en mode
* Le toast succes + mapping erreur 409/422 est gere par le composable. Le PATCH * edit). Le toast succes + mapping erreur 409/422 est gere par le composable.
* envoie le payload complet, donc le bouton Enregistrer sauvegarde a tout * En cas de succes, on ferme le drawer et on previent le parent pour qu'il
* moment (meme sans modification). En cas de succes, on ferme le drawer et on * refresh la liste.
* previent le parent pour qu'il refresh la liste.
*/ */
async function handleSave(): Promise<void> { async function handleSave(): Promise<void> {
const result = isCreateMode.value let result: Category | null = null
? await form.submitCreate() if (mode.value === 'create') {
: props.category result = await form.submitCreate()
? await form.submitUpdate(props.category.id) } else if (mode.value === 'edit' && props.category) {
: null result = await form.submitUpdate(props.category.id)
}
if (result) { if (result) {
emit('saved') emit('saved')
emit('update:modelValue', false) emit('update:modelValue', false)
@@ -39,7 +39,7 @@ const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
const CAT: Category = { const CAT: Category = {
id: 42, id: 42,
name: 'Vis', name: 'Vis',
categoryTypes: [TYPE_VENTE], categoryType: TYPE_VENTE,
deletedAt: null, deletedAt: null,
createdAt: '2026-01-01T10:00:00+00:00', createdAt: '2026-01-01T10:00:00+00:00',
updatedAt: '2026-01-01T10:00:00+00:00', updatedAt: '2026-01-01T10:00:00+00:00',
@@ -58,25 +58,25 @@ describe('useCategoryForm', () => {
}) })
describe('loadFrom', () => { describe('loadFrom', () => {
it('pre-remplit le formulaire depuis une categorie existante (multi-types)', () => { it('pre-remplit le formulaire depuis une categorie existante', () => {
const form = useCategoryForm() const form = useCategoryForm()
form.loadFrom({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] }) form.loadFrom(CAT)
expect(form.name.value).toBe('Vis') expect(form.name.value).toBe('Vis')
expect(form.categoryTypeIds.value).toEqual([1, 2]) expect(form.categoryTypeId.value).toBe(1)
expect(form.errors).toEqual({}) expect(form.errors).toEqual({})
}) })
it('vide le formulaire en mode creation (null)', () => { it('vide le formulaire en mode creation (null)', () => {
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = 'old' form.name.value = 'old'
form.categoryTypeIds.value = [99] form.categoryTypeId.value = 99
form.loadFrom(null) form.loadFrom(null)
expect(form.name.value).toBe('') expect(form.name.value).toBe('')
expect(form.categoryTypeIds.value).toEqual([]) expect(form.categoryTypeId.value).toBeNull()
}) })
it('reinitialise le snapshot initial → isDirty=false juste apres', () => { it('reinitialise le snapshot initial → isDirty=false juste apres', () => {
@@ -98,32 +98,13 @@ describe('useCategoryForm', () => {
expect(form.isDirty.value).toBe(true) 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', () => { describe('validate', () => {
it('signale une erreur si name est vide (RG-1.02)', () => { it('signale une erreur si name est vide (RG-1.02)', () => {
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = '' form.name.value = ''
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
const ok = form.validate() const ok = form.validate()
@@ -134,7 +115,7 @@ describe('useCategoryForm', () => {
it('signale erreur si name est whitespace-only (trim → vide)', () => { it('signale erreur si name est whitespace-only (trim → vide)', () => {
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = ' ' form.name.value = ' '
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
const ok = form.validate() const ok = form.validate()
@@ -145,7 +126,7 @@ describe('useCategoryForm', () => {
it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => { it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => {
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = 'A' form.name.value = 'A'
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
const ok = form.validate() const ok = form.validate()
@@ -156,7 +137,7 @@ describe('useCategoryForm', () => {
it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => { it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => {
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = 'A'.repeat(121) form.name.value = 'A'.repeat(121)
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
const ok = form.validate() const ok = form.validate()
@@ -164,21 +145,21 @@ describe('useCategoryForm', () => {
expect(form.errors.name).toBe('admin.categories.validation.nameLength') expect(form.errors.name).toBe('admin.categories.validation.nameLength')
}) })
it('signale erreur si aucun type selectionne (RG-1.05)', () => { it('signale erreur si categoryTypeId est null (RG-1.05)', () => {
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = 'Vis' form.name.value = 'Vis'
form.categoryTypeIds.value = [] form.categoryTypeId.value = null
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.categoryTypes).toBe('admin.categories.validation.typesRequired') expect(form.errors.categoryType).toBe('admin.categories.validation.typeRequired')
}) })
it('passe quand name et au moins un type sont valides', () => { it('passe quand name et categoryType sont valides', () => {
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = 'Vis' form.name.value = 'Vis'
form.categoryTypeIds.value = [1, 2] form.categoryTypeId.value = 1
const ok = form.validate() const ok = form.validate()
@@ -190,7 +171,7 @@ describe('useCategoryForm', () => {
const form = useCategoryForm() const form = useCategoryForm()
// Erreur prealable : une validation en echec peuple errors.name. // Erreur prealable : une validation en echec peuple errors.name.
form.name.value = '' form.name.value = ''
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
form.validate() form.validate()
expect(form.errors.name).toBeTruthy() expect(form.errors.name).toBeTruthy()
@@ -203,17 +184,17 @@ describe('useCategoryForm', () => {
}) })
describe('submitCreate', () => { describe('submitCreate', () => {
it('appelle POST /categories avec body { name trimme, categoryTypes en IRI[] }', async () => { it('appelle POST /categories avec body { name trimme, categoryType en IRI }', async () => {
mockPost.mockResolvedValueOnce(CAT) mockPost.mockResolvedValueOnce(CAT)
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = ' Vis ' form.name.value = ' Vis '
form.categoryTypeIds.value = [1, 2] form.categoryTypeId.value = 1
const result = await form.submitCreate() const result = await form.submitCreate()
expect(mockPost).toHaveBeenCalledWith( expect(mockPost).toHaveBeenCalledWith(
'/categories', '/categories',
{ name: 'Vis', categoryTypes: ['/api/category_types/1', '/api/category_types/2'] }, { name: 'Vis', categoryType: '/api/category_types/1' },
{ toast: false }, { toast: false },
) )
expect(result).toEqual(CAT) expect(result).toEqual(CAT)
@@ -222,7 +203,7 @@ describe('useCategoryForm', () => {
it('ne declenche aucun appel API si la validation client echoue', async () => { it('ne declenche aucun appel API si la validation client echoue', async () => {
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = '' form.name.value = ''
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
const result = await form.submitCreate() const result = await form.submitCreate()
@@ -234,7 +215,7 @@ describe('useCategoryForm', () => {
mockPost.mockResolvedValueOnce(CAT) mockPost.mockResolvedValueOnce(CAT)
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = 'Vis' form.name.value = 'Vis'
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
await form.submitCreate() await form.submitCreate()
@@ -250,7 +231,7 @@ describe('useCategoryForm', () => {
}) })
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = 'Vis' form.name.value = 'Vis'
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
const result = await form.submitCreate() const result = await form.submitCreate()
@@ -277,7 +258,7 @@ describe('useCategoryForm', () => {
}) })
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = 'Vis' form.name.value = 'Vis'
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
const result = await form.submitCreate() const result = await form.submitCreate()
@@ -288,24 +269,24 @@ describe('useCategoryForm', () => {
expect(mockToastError).not.toHaveBeenCalled() expect(mockToastError).not.toHaveBeenCalled()
}) })
it('mappe une violation sur categoryTypes (hydra:violations alternative)', async () => { it('mappe aussi hydra:violations (negociation de format alternative)', async () => {
mockPost.mockRejectedValueOnce({ mockPost.mockRejectedValueOnce({
response: { response: {
status: 422, status: 422,
_data: { _data: {
'hydra:violations': [ 'hydra:violations': [
{ propertyPath: 'categoryTypes', message: 'Sélectionnez au moins un type de catégorie.' }, { propertyPath: 'categoryType', message: 'Type invalide.' },
], ],
}, },
}, },
}) })
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = 'Vis' form.name.value = 'Vis'
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
await form.submitCreate() await form.submitCreate()
expect(form.errors.categoryTypes).toBe('Sélectionnez au moins un type de catégorie.') expect(form.errors.categoryType).toBe('Type invalide.')
}) })
it('fallback en toast generique si le status n est ni 409 ni 422', async () => { it('fallback en toast generique si le status n est ni 409 ni 422', async () => {
@@ -314,7 +295,7 @@ describe('useCategoryForm', () => {
}) })
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = 'Vis' form.name.value = 'Vis'
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
await form.submitCreate() await form.submitCreate()
@@ -333,7 +314,7 @@ describe('useCategoryForm', () => {
) )
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = 'Vis' form.name.value = 'Vis'
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
const pending = form.submitCreate() const pending = form.submitCreate()
expect(form.submitting.value).toBe(true) expect(form.submitting.value).toBe(true)
@@ -346,52 +327,45 @@ describe('useCategoryForm', () => {
}) })
describe('submitUpdate', () => { describe('submitUpdate', () => {
it('appelle PATCH /categories/{id} avec le payload complet (name + categoryTypes)', async () => { it('appelle PATCH /categories/{id} uniquement avec les champs modifies', async () => {
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' }) mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
const form = useCategoryForm() const form = useCategoryForm()
form.loadFrom(CAT) form.loadFrom(CAT)
form.name.value = 'Vis V2' // types inchanges form.name.value = 'Vis V2' // categoryTypeId inchange
await form.submitUpdate(42)
// Payload complet : meme si seul le name change, on renvoie aussi
// les categoryTypes (PATCH full payload, cf. drawers simples).
expect(mockPatch).toHaveBeenCalledWith(
'/categories/42',
{ name: 'Vis V2', categoryTypes: ['/api/category_types/1'] },
{ toast: false },
)
})
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.categoryTypeIds.value = [1, 2]
await form.submitUpdate(42) await form.submitUpdate(42)
expect(mockPatch).toHaveBeenCalledWith( expect(mockPatch).toHaveBeenCalledWith(
'/categories/42', '/categories/42',
{ name: CAT.name, categoryTypes: ['/api/category_types/1', '/api/category_types/2'] }, { name: 'Vis V2' }, // pas de categoryType car non modifie
{ toast: false }, { toast: false },
) )
}) })
it('envoie un PATCH complet meme sans modification (save a tout moment)', async () => { it('envoie categoryType en IRI quand seul le type a change', async () => {
mockPatch.mockResolvedValueOnce(CAT) mockPatch.mockResolvedValueOnce({ ...CAT, categoryType: TYPE_ACHAT })
const form = useCategoryForm() const form = useCategoryForm()
form.loadFrom(CAT) form.loadFrom(CAT)
// Aucune modification : le PATCH part quand meme avec le payload complet. form.categoryTypeId.value = 2
await form.submitUpdate(42)
expect(mockPatch).toHaveBeenCalledWith(
'/categories/42',
{ categoryType: '/api/category_types/2' },
{ toast: false },
)
})
it('court-circuite l appel API si aucun champ n a change', async () => {
const form = useCategoryForm()
form.loadFrom(CAT)
// Aucune modification — isDirty=false, patch payload vide.
const result = await form.submitUpdate(42) const result = await form.submitUpdate(42)
expect(mockPatch).toHaveBeenCalledWith( expect(mockPatch).not.toHaveBeenCalled()
'/categories/42', expect(result).toBeNull()
{ name: CAT.name, categoryTypes: ['/api/category_types/1'] },
{ toast: false },
)
expect(result).toEqual(CAT)
expect(form.submitting.value).toBe(false) expect(form.submitting.value).toBe(false)
}) })
@@ -464,7 +438,7 @@ describe('useCategoryForm', () => {
form.reset() form.reset()
expect(form.name.value).toBe('') expect(form.name.value).toBe('')
expect(form.categoryTypeIds.value).toEqual([]) expect(form.categoryTypeId.value).toBeNull()
expect(form.errors).toEqual({}) expect(form.errors).toEqual({})
expect(form.submitting.value).toBe(false) expect(form.submitting.value).toBe(false)
}) })
@@ -13,10 +13,9 @@
* revalide toujours (defense en profondeur). * revalide toujours (defense en profondeur).
* *
* Erreurs par champ : delegue a `useFormErrors` (convention ERP-101). Les * Erreurs par champ : delegue a `useFormErrors` (convention ERP-101). Les
* violations 422 sont mappees par `propertyPath` (`name`, `categoryTypes`) ; * violations 422 sont mappees par `propertyPath` (`name`, `categoryType`) ;
* l'erreur globale (status != 422 exploitable) part en toast. Le 409 (doublon * l'erreur globale (status != 422 exploitable) part en toast. Le 409 (doublon
* de nom GLOBAL, RG-1.07) reste un cas metier specifique : erreur inline sur * RG-1.07) reste un cas metier specifique : erreur inline sur `name` + toast.
* `name` + toast.
*/ */
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { Category } from '~/modules/catalog/types/category' import type { Category } from '~/modules/catalog/types/category'
@@ -43,29 +42,20 @@ export function useCategoryForm() {
// State local du formulaire — pas singleton, chaque appel a useCategoryForm // State local du formulaire — pas singleton, chaque appel a useCategoryForm
// cree son propre state (cohérent avec le pattern « un drawer = un form »). // cree son propre state (cohérent avec le pattern « un drawer = un form »).
const name = ref('') const name = ref('')
const categoryTypeIds = ref<number[]>([]) const categoryTypeId = ref<number | null>(null)
// Snapshot des valeurs initiales : sert a calculer `isDirty` pour le // Snapshot des valeurs initiales : sert a calculer `isDirty` pour le
// pattern view → edit du drawer (le bouton Enregistrer reste masque tant // pattern view → edit du drawer (le bouton Enregistrer reste masque tant
// que rien n'a change en mode consultation). // que rien n'a change en mode consultation).
const initialName = ref('') const initialName = ref('')
const initialCategoryTypeIds = ref<number[]>([]) const initialCategoryTypeId = ref<number | null>(null)
const submitting = ref(false) 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( const isDirty = computed(
() => () =>
name.value !== initialName.value name.value !== initialName.value
|| !sameIds(categoryTypeIds.value, initialCategoryTypeIds.value), || categoryTypeId.value !== initialCategoryTypeId.value,
) )
/** /**
@@ -76,16 +66,15 @@ export function useCategoryForm() {
function loadFrom(category: Category | null): void { function loadFrom(category: Category | null): void {
formErrors.clearErrors() formErrors.clearErrors()
if (category) { if (category) {
const ids = category.categoryTypes.map(t => t.id)
name.value = category.name name.value = category.name
categoryTypeIds.value = [...ids] categoryTypeId.value = category.categoryType.id
initialName.value = category.name initialName.value = category.name
initialCategoryTypeIds.value = [...ids] initialCategoryTypeId.value = category.categoryType.id
} else { } else {
name.value = '' name.value = ''
categoryTypeIds.value = [] categoryTypeId.value = null
initialName.value = '' initialName.value = ''
initialCategoryTypeIds.value = [] initialCategoryTypeId.value = null
} }
} }
@@ -106,23 +95,23 @@ export function useCategoryForm() {
formErrors.setError('name', t('admin.categories.validation.nameLength')) formErrors.setError('name', t('admin.categories.validation.nameLength'))
} }
// RG-1.05 — au moins un type obligatoire. // RG-1.05 — categoryType obligatoire.
if (categoryTypeIds.value.length === 0) { if (categoryTypeId.value === null) {
formErrors.setError('categoryTypes', t('admin.categories.validation.typesRequired')) formErrors.setError('categoryType', t('admin.categories.validation.typeRequired'))
} }
return !formErrors.errors.name && !formErrors.errors.categoryTypes return !formErrors.errors.name && !formErrors.errors.categoryType
} }
/** /**
* Construit le payload POST a partir du state. Les `categoryTypes` sont * Construit le payload POST a partir du state. Le `categoryType` est
* envoyes en tableau d'IRI Hydra (`/api/category_types/{id}`) — convention * envoye en IRI Hydra (`/api/category_types/{id}`) — convention API
* API Platform pour referencer une collection de ressources liees. * Platform pour referencer une ressource liee.
*/ */
function buildCreatePayload(): Record<string, unknown> { function buildCreatePayload(): Record<string, unknown> {
return { return {
name: name.value.trim(), name: name.value.trim(),
categoryTypes: categoryTypeIds.value.map(id => `/api/category_types/${id}`), categoryType: `/api/category_types/${categoryTypeId.value}`,
} }
} }
@@ -174,18 +163,26 @@ export function useCategoryForm() {
} }
/** /**
* PATCH /api/categories/{id}. Envoie le payload complet (name + * PATCH /api/categories/{id}. Envoie uniquement les champs modifies pour
* categoryTypes), comme les autres drawers du projet : le bouton * coller a la semantique merge-patch (Content-Type pose par useApi).
* Enregistrer sauvegarde a tout moment, meme sans modification, et renvoie * Renvoie la categorie mise a jour, ou `null` en cas d'echec.
* toujours un retour (toast succes + refresh). Renvoie la categorie mise a
* jour, ou `null` en cas d'echec.
*/ */
async function submitUpdate(id: number): Promise<Category | null> { async function submitUpdate(id: number): Promise<Category | null> {
if (!validate()) return null if (!validate()) return null
submitting.value = true submitting.value = true
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {}
name: name.value.trim(), if (name.value !== initialName.value) {
categoryTypes: categoryTypeIds.value.map(id => `/api/category_types/${id}`), payload.name = name.value.trim()
}
if (categoryTypeId.value !== initialCategoryTypeId.value) {
payload.categoryType = `/api/category_types/${categoryTypeId.value}`
}
// Garde-fou : un PATCH sans changement ne sert a rien. Theoriquement
// empeche par le drawer (bouton Enregistrer masque si !isDirty) mais
// on protege le composable contre un appel direct mal utilise.
if (Object.keys(payload).length === 0) {
submitting.value = false
return null
} }
try { try {
const updated = await api.patch<Category>(`/categories/${id}`, payload, { const updated = await api.patch<Category>(`/categories/${id}`, payload, {
@@ -236,9 +233,9 @@ export function useCategoryForm() {
*/ */
function reset(): void { function reset(): void {
name.value = '' name.value = ''
categoryTypeIds.value = [] categoryTypeId.value = null
initialName.value = '' initialName.value = ''
initialCategoryTypeIds.value = [] initialCategoryTypeId.value = null
formErrors.clearErrors() formErrors.clearErrors()
submitting.value = false submitting.value = false
} }
@@ -246,7 +243,7 @@ export function useCategoryForm() {
return { return {
// State // State
name, name,
categoryTypeIds, categoryTypeId,
errors: formErrors.errors, errors: formErrors.errors,
submitting, submitting,
isDirty, isDirty,
@@ -3,28 +3,13 @@
<PageHeader> <PageHeader>
{{ t('admin.categories.title') }} {{ t('admin.categories.title') }}
<template #actions> <template #actions>
<!-- gap-8 = 32px d'espacement entre Filtres et Ajouter (meme <MalioButton
design que le Repertoire Clients). --> v-if="canManage"
<div class="flex items-center gap-8"> :label="t('admin.categories.newCategory')"
<!-- Bouton Filtres a GAUCHE d'Ajouter. Le compteur reflete icon-name="mdi:add-bold"
les filtres actifs. --> icon-position="left"
<MalioButton @click="openCreateDrawer"
variant="tertiary" />
:label="filterButtonLabel"
icon-name="mdi:tune"
icon-position="left"
icon-size="24"
@click="openFilters"
/>
<MalioButton
v-if="canManage"
variant="secondary"
:label="t('admin.categories.newCategory')"
icon-name="mdi:add-bold"
icon-position="left"
@click="openCreateDrawer"
/>
</div>
</template> </template>
</PageHeader> </PageHeader>
@@ -62,60 +47,6 @@
:loading="deleting" :loading="deleting"
@confirm="handleDelete" @confirm="handleDelete"
/> />
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
« Appliquer ». Meme pattern que le Repertoire Clients. Etat 100 %
local, jamais dans l'URL (regle ABSOLUE n°6). -->
<MalioDrawer
v-model="filterDrawerOpen"
drawer-class="max-w-[450px]"
body-class="p-0"
footer-class="justify-between border-t border-black p-6"
>
<template #header>
<h2 class="text-[24px] font-bold uppercase">{{ t('admin.categories.filters.title') }}</h2>
</template>
<MalioAccordion>
<!-- Recherche par nom (param `name`, partiel insensible a la casse). -->
<MalioAccordionItem :title="t('admin.categories.filters.search')" value="search">
<MalioInputText
v-model="draftSearch"
icon-name="mdi:magnify"
/>
</MalioAccordionItem>
<!-- Type(s) : cases a cocher (multi). Une categorie remonte si
elle porte AU MOINS UN des types coches (OR cote back). -->
<MalioAccordionItem :title="t('admin.categories.filters.types')" value="types">
<div class="flex flex-col">
<MalioCheckbox
v-for="opt in typeFilterOptions"
:id="`filter-type-${opt.value}`"
:key="opt.value"
:label="opt.label"
:model-value="draftTypeIds.includes(opt.value)"
@update:model-value="(val: boolean) => toggleType(opt.value, val)"
/>
</div>
</MalioAccordionItem>
</MalioAccordion>
<template #footer>
<MalioButton
variant="tertiary"
:label="t('admin.categories.filters.reset')"
button-class="w-m-btn-action"
@click="resetFilters"
/>
<MalioButton
variant="primary"
:label="t('admin.categories.filters.apply')"
button-class="w-[170px]"
@click="applyFilters"
/>
</template>
</MalioDrawer>
</div> </div>
</template> </template>
@@ -124,7 +55,7 @@ import type { Category } from '~/modules/catalog/types/category'
const { t } = useI18n() const { t } = useI18n()
const { can } = usePermissions() const { can } = usePermissions()
const { types, fetchTypes } = useCategoriesAdmin() const { fetchTypes } = useCategoriesAdmin()
const { submitDelete } = useCategoryForm() const { submitDelete } = useCategoryForm()
useHead({ title: t('admin.categories.title') }) useHead({ title: t('admin.categories.title') })
@@ -143,7 +74,6 @@ const {
fetch: fetchCategories, fetch: fetchCategories,
goToPage, goToPage,
setItemsPerPage, setItemsPerPage,
setFilters,
} = usePaginatedList<Category>({ url: '/categories' }) } = usePaginatedList<Category>({ url: '/categories' })
const drawerOpen = ref(false) const drawerOpen = ref(false)
@@ -152,96 +82,21 @@ const deleteModalOpen = ref(false)
const categoryToDelete = ref<Category | null>(null) const categoryToDelete = ref<Category | null>(null)
const deleting = ref(false) const deleting = ref(false)
// Colonnes du datatable. Les types sont embarques cote API (ManyToMany) — on // Colonnes du datatable. Le type est embarque cote API (cf. spec-back § 3.4) —
// aplatit en libelles joints par une virgule pour l'affichage. // on aplatit en label lisible pour l'affichage.
const columns = [ const columns = [
{ key: 'name', label: t('admin.categories.table.name') }, { key: 'name', label: t('admin.categories.table.name') },
{ key: 'typesLabel', label: t('admin.categories.table.types') }, { key: 'typeLabel', label: t('admin.categories.table.type') },
] ]
const categoryItems = computed(() => const categoryItems = computed(() =>
categories.value.map(cat => ({ categories.value.map(cat => ({
id: cat.id, id: cat.id,
name: cat.name, name: cat.name,
typesLabel: (cat.categoryTypes ?? []).map(ct => ct.label).join(', '), typeLabel: cat.categoryType?.label ?? '',
})), })),
) )
// ── 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<number[]>([])
const appliedSearch = ref('')
const appliedTypeIds = ref<number[]>([])
// 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<string, string | string[]> {
const payload: Record<string, string | string[]> = {}
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 { function getCategoryById(id: number): Category | undefined {
return categories.value.find(c => c.id === id) return categories.value.find(c => c.id === id)
} }
+10 -11
View File
@@ -4,15 +4,15 @@
* Contrats API consommes : * Contrats API consommes :
* - GET /api/categories → HydraCollection<Category> * - GET /api/categories → HydraCollection<Category>
* - GET /api/categories/{id} → Category * - GET /api/categories/{id} → Category
* - POST /api/categories → body { name, categoryTypes: IRI[] } * - POST /api/categories → body { name, categoryType: IRI }
* - PATCH /api/categories/{id} → body partiel { name?, categoryTypes?: IRI[] } * - PATCH /api/categories/{id} → body partiel { name?, categoryType?: IRI }
* - DELETE /api/categories/{id} → 204 (soft delete via CategoryProcessor) * - DELETE /api/categories/{id} → 204 (soft delete via CategoryProcessor)
* - GET /api/category_types → HydraCollection<CategoryType> * - GET /api/category_types → HydraCollection<CategoryType>
* *
* Notes : * Notes :
* - Les IRI sont envoyes en POST/PATCH (ex. ["/api/category_types/3"]). * - Les IRI sont envoyes en POST/PATCH (ex. "/api/category_types/3").
* - `categoryTypes` est embarque (groupe Serializer `category:read` sur les * - `categoryType` est embarque (groupe Serializer `category:read` sur les
* proprietes de CategoryType) : tableau d'objets type en lecture. * proprietes de CategoryType, cf. spec-back § 3.4).
* - `createdBy` / `updatedBy` peuvent etre `null` (hors contexte HTTP, * - `createdBy` / `updatedBy` peuvent etre `null` (hors contexte HTTP,
* ON DELETE SET NULL en BDD). Affichage : libelle "Systeme" si null. * ON DELETE SET NULL en BDD). Affichage : libelle "Systeme" si null.
*/ */
@@ -43,8 +43,7 @@ export interface CategoryType {
export interface Category { export interface Category {
id: number id: number
name: string name: string
/** Types de la categorie (>= 1, ManyToMany embarque en lecture). */ categoryType: CategoryType
categoryTypes: CategoryType[]
/** Soft delete : null = active, valeur = supprimee logiquement le {date}. */ /** Soft delete : null = active, valeur = supprimee logiquement le {date}. */
deletedAt: string | null deletedAt: string | null
createdAt: string createdAt: string
@@ -54,12 +53,12 @@ export interface Category {
} }
/** /**
* Payload accepte en POST /api/categories. `categoryTypes` est un tableau * Payload accepte en POST /api/categories. `categoryType` est envoye en
* d'IRI Hydra (ex. `['/api/category_types/3', '/api/category_types/5']`). * IRI Hydra (ex. `/api/category_types/3`).
*/ */
export interface CategoryCreateInput { export interface CategoryCreateInput {
name: string name: string
categoryTypes: string[] categoryType: string
} }
/** /**
@@ -68,5 +67,5 @@ export interface CategoryCreateInput {
*/ */
export interface CategoryUpdateInput { export interface CategoryUpdateInput {
name?: string name?: string
categoryTypes?: string[] categoryType?: string
} }
@@ -87,9 +87,8 @@
@update:model-value="onPostalCodeChange" @update:model-value="onPostalCodeChange"
/> />
<!-- Ville : MalioSelect alimente par le code postal (BAN). Si la BAN est <!-- Ville : MalioSelect alimente par le code postal (BAN). En mode
indisponible, bascule en saisie libre — recuperable : re-saisir le degrade (service indisponible), bascule en saisie libre. -->
code postal relance la recherche et repasse en select au succes. -->
<MalioSelect <MalioSelect
v-if="!degraded" v-if="!degraded"
:model-value="model.city" :model-value="model.city"
@@ -116,14 +115,11 @@
sur l'input interne, pas sur la cellule de grille. Le wrapper porte sur l'input interne, pas sur la cellule de grille. Le wrapper porte
le col-span-2, le champ le remplit (w-full). --> le col-span-2, le champ le remplit (w-full). -->
<div class="col-span-2"> <div class="col-span-2">
<!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple <!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple en
seulement en lecture seule (MalioInputAutocomplete ne reaffiche pas mode degrade OU en lecture seule (MalioInputAutocomplete ne reaffiche
sa valeur liee, il n'afficherait rien en readonly). Une erreur BAN pas sa valeur liee, il n'afficherait rien en readonly). -->
ne bascule PAS en saisie libre : l'autocompletion reste montee et
chaque frappe relance la recherche (l'utilisateur peut aussi taper
une rue librement). -->
<MalioInputAutocomplete <MalioInputAutocomplete
v-if="!readonly" v-if="!degraded && !readonly"
:model-value="model.street" :model-value="model.street"
:options="addressOptions" :options="addressOptions"
:loading="addressLoading" :loading="addressLoading"
@@ -221,12 +217,8 @@ function onAddressTypeChange(value: string | number | null): void {
emit('update:modelValue', { ...props.modelValue, ...addressFlagsFromType(value as AddressType) }) emit('update:modelValue', { ...props.modelValue, ...addressFlagsFromType(value as AddressType) })
} }
// Repli saisie libre de la VILLE quand la BAN est indisponible (recuperable : // Mode degrade : service BAN indisponible → Ville/Adresse en saisie libre.
// remis a false des qu'une recherche de ville aboutit). N'affecte plus le champ
// Adresse, qui reste en autocompletion et reessaie a chaque frappe.
const degraded = ref(false) const degraded = ref(false)
// Avertissement « service indisponible » envoye au parent une seule fois.
let unavailableNotified = false
// Villes proposees par la BAN (alimentees a la saisie du code postal). // Villes proposees par la BAN (alimentees a la saisie du code postal).
const banCityOptions = ref<RefOption[]>([]) const banCityOptions = ref<RefOption[]>([])
// Adresses proposees par la BAN (alimentees a la saisie d'adresse). // Adresses proposees par la BAN (alimentees a la saisie d'adresse).
@@ -266,10 +258,10 @@ function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDr
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
} }
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */ /** Bascule définitivement en mode degrade et previent le parent (toast unique). */
function notifyUnavailable(): void { function enterDegraded(): void {
if (!unavailableNotified) { if (!degraded.value) {
unavailableNotified = true degraded.value = true
emit('degraded') emit('degraded')
} }
} }
@@ -278,6 +270,9 @@ function notifyUnavailable(): void {
async function onPostalCodeChange(value: string): Promise<void> { async function onPostalCodeChange(value: string): Promise<void> {
update('postalCode', value) update('postalCode', value)
if (degraded.value) {
return
}
const digits = (value ?? '').replace(/\D/g, '') const digits = (value ?? '').replace(/\D/g, '')
if (digits.length < 5) { if (digits.length < 5) {
return return
@@ -285,22 +280,15 @@ async function onPostalCodeChange(value: string): Promise<void> {
try { try {
const suggestions = await autocomplete.searchCity(digits) const suggestions = await autocomplete.searchCity(digits)
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city })) banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
// Service repondu : on (re)passe la Ville en select assiste.
degraded.value = false
} }
catch { catch {
// BAN indispo : Ville en saisie libre (recuperable au prochain essai). enterDegraded()
degraded.value = true
notifyUnavailable()
} }
} }
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */ /** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
async function onAddressSearch(query: string): Promise<void> { async function onAddressSearch(query: string): Promise<void> {
// La BAN exige au moins 3 caracteres : on n'envoie rien en deca (evite un 400) if (degraded.value) {
// et on vide les suggestions devenues obsoletes.
if (query.trim().length < 3) {
banAddressOptions.value = []
return return
} }
addressLoading.value = true addressLoading.value = true
@@ -311,10 +299,7 @@ async function onAddressSearch(query: string): Promise<void> {
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label })) banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
} }
catch { catch {
// Erreur transitoire : on vide les suggestions, la prochaine frappe reessaie enterDegraded()
// (pas de bascule definitive — c'etait le bug). Avertissement une seule fois.
banAddressOptions.value = []
notifyUnavailable()
} }
finally { finally {
addressLoading.value = false addressLoading.value = false
@@ -1,21 +1,16 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { defineComponent, h, ref, computed } from 'vue' import { defineComponent, h, ref, computed } from 'vue'
import { emptyAddress } from '~/modules/commercial/types/clientForm' import { emptyAddress } from '~/modules/commercial/types/clientForm'
import ClientAddressBlock from '../ClientAddressBlock.vue' import ClientAddressBlock from '../ClientAddressBlock.vue'
// Mocks controlables du composable BAN (hoisted) : chaque test configure le // Le composable BAN est mocke : aucun appel reseau, aucune suggestion chargee.
// comportement de searchCity / searchAddress (succes, rejet, rejet-puis-succes). // On reproduit ainsi l'etat « adresse persistee, mais liste de suggestions
// Par defaut ils renvoient undefined (aucune suggestion) — etat « adresse // vide » (remontage apres validation / edition d'une adresse existante).
// persistee mais liste vide » couvert par les tests d'affichage.
const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({
searchCityMock: vi.fn(),
searchAddressMock: vi.fn(),
}))
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({ vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
useAddressAutocomplete: () => ({ useAddressAutocomplete: () => ({
searchCity: searchCityMock, searchCity: vi.fn(),
searchAddress: searchAddressMock, searchAddress: vi.fn(),
}), }),
})) }))
@@ -135,57 +130,3 @@ describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => {
expect(field?.attributes('data-error')).toBe('Code postal invalide.') expect(field?.attributes('data-error')).toBe('Code postal invalide.')
}) })
}) })
describe('ClientAddressBlock — recherche adresse robuste (erreur BAN)', () => {
beforeEach(() => {
searchAddressMock.mockReset()
})
it('n\'appelle pas la BAN en deca de 3 caracteres', async () => {
const wrapper = mountBlock(null)
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
auto.vm.$emit('search', 'ab')
await flushPromises()
expect(searchAddressMock).not.toHaveBeenCalled()
})
it('relance la recherche apres une erreur (pas de bascule definitive)', async () => {
searchAddressMock
.mockRejectedValueOnce(new Error('BAN indisponible'))
.mockResolvedValueOnce([
{ label: '8 Boulevard du Port, Paris', street: '8 Boulevard du Port', postalCode: '75001', city: 'Paris' },
])
const wrapper = mountBlock(null)
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
// 1er essai -> erreur BAN.
auto.vm.$emit('search', 'boulevard du port')
await flushPromises()
expect(searchAddressMock).toHaveBeenCalledTimes(1)
// 2e essai -> DOIT relancer l'appel (c'etait le bug : plus aucune recherche).
auto.vm.$emit('search', 'boulevard du porte')
await flushPromises()
expect(searchAddressMock).toHaveBeenCalledTimes(2)
// L'autocompletion reste montee (aucune bascule en saisie libre).
expect(wrapper.find('[data-testid="addr-autocomplete"]').exists()).toBe(true)
})
it('emet « degraded » une seule fois malgre plusieurs erreurs', async () => {
searchAddressMock.mockRejectedValue(new Error('BAN indisponible'))
const wrapper = mountBlock(null)
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
auto.vm.$emit('search', 'rue de la paix')
await flushPromises()
auto.vm.$emit('search', 'rue de la paixx')
await flushPromises()
expect(wrapper.emitted('degraded')).toHaveLength(1)
})
})
@@ -99,9 +99,7 @@ export function useClientReferentials() {
*/ */
async function loadCommon(): Promise<void> { async function loadCommon(): Promise<void> {
await Promise.allSettled([ await Promise.allSettled([
// Taxonomie multi-types (ERP-84) : un client ne porte que des categories fetchAll<CategoryMember>('/categories')
// de type CLIENT (pas FOURNISSEUR) -> on filtre la collection cote API.
fetchAll<CategoryMember>('/categories', { typeCode: 'CLIENT' })
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }), .then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
fetchAll<SiteMember>('/sites') fetchAll<SiteMember>('/sites')
// Libelle = numero de departement (2 premiers chiffres du code // Libelle = numero de departement (2 premiers chiffres du code
@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<!-- En-tete : retour repertoire + nom du client. --> <!-- En-tete : retour repertoire + nom du client. -->
<div class="flex items-center gap-3 pt-11"> <div class="flex items-center gap-3">
<MalioButtonIcon <MalioButtonIcon
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
@@ -9,7 +9,7 @@
v-bind="{ ariaLabel: t('commercial.clients.edit.back') }" v-bind="{ ariaLabel: t('commercial.clients.edit.back') }"
@click="goBack" @click="goBack"
/> />
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1> <h1 class="text-[32px] font-bold text-m-primary">{{ headerTitle }}</h1>
</div> </div>
<!-- Etats de chargement / introuvable. --> <!-- Etats de chargement / introuvable. -->
@@ -41,7 +41,6 @@
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/> />
<MalioSelect <MalioSelect
v-if="showRelationAndTriage"
:model-value="main.relationType" :model-value="main.relationType"
:options="relationOptions" :options="relationOptions"
:label="t('commercial.clients.form.main.relation')" :label="t('commercial.clients.form.main.relation')"
@@ -50,7 +49,7 @@
@update:model-value="onRelationChange" @update:model-value="onRelationChange"
/> />
<MalioSelect <MalioSelect
v-if="showRelationAndTriage && main.relationType === 'courtier'" v-if="main.relationType === 'courtier'"
:model-value="main.brokerIri" :model-value="main.brokerIri"
:options="brokerOptions" :options="brokerOptions"
:label="t('commercial.clients.form.main.brokerName')" :label="t('commercial.clients.form.main.brokerName')"
@@ -60,7 +59,7 @@
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
/> />
<MalioSelect <MalioSelect
v-if="showRelationAndTriage && main.relationType === 'distributeur'" v-if="main.relationType === 'distributeur'"
:model-value="main.distributorIri" :model-value="main.distributorIri"
:options="distributorOptions" :options="distributorOptions"
:label="t('commercial.clients.form.main.distributorName')" :label="t('commercial.clients.form.main.distributorName')"
@@ -70,7 +69,6 @@
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
/> />
<MalioCheckbox <MalioCheckbox
v-if="showRelationAndTriage"
v-model="main.triageService" v-model="main.triageService"
:label="t('commercial.clients.form.main.triageService')" :label="t('commercial.clients.form.main.triageService')"
group-class="self-center" group-class="self-center"
@@ -92,14 +90,11 @@
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
sur les inputs (champ 40px centre dans un h-12 -> ~4px de
coussin de chaque cote). -->
<MalioInputTextArea <MalioInputTextArea
v-model="information.description" v-model="information.description"
:label="t('commercial.clients.form.information.description')" :label="t('commercial.clients.form.information.description')"
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1"
text-input="h-full text-lg" text-input="h-full text-lg"
:readonly="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.description" :error="informationErrors.errors.description"
@@ -210,7 +205,6 @@
icon-name="mdi:add-bold" icon-name="mdi:add-bold"
icon-position="left" icon-position="left"
:label="t('commercial.clients.form.address.add')" :label="t('commercial.clients.form.address.add')"
:disabled="!canAddAddress"
@click="addAddress" @click="addAddress"
/> />
<MalioButton <MalioButton
@@ -295,9 +289,9 @@
</div> </div>
</div> </div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13). --> <!-- Blocs RIB (0..n) — obligatoires si type de reglement = LCR (RG-1.13). -->
<div <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in ribs"
:key="rib.id ?? `new-${index}`" :key="rib.id ?? `new-${index}`"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
> >
@@ -336,12 +330,10 @@
<div v-if="!accountingReadonly" class="flex justify-center gap-6"> <div v-if="!accountingReadonly" class="flex justify-center gap-6">
<MalioButton <MalioButton
v-if="isRibRequired"
variant="secondary" variant="secondary"
icon-name="mdi:add-bold" icon-name="mdi:add-bold"
icon-position="left" icon-position="left"
:label="t('commercial.clients.form.accounting.addRib')" :label="t('commercial.clients.form.accounting.addRib')"
:disabled="!canAddRib"
@click="addRib" @click="addRib"
/> />
<MalioButton <MalioButton
@@ -387,7 +379,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient' import { useClient } from '~/modules/commercial/composables/useClient'
import { useClientReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useClientReferentials' import { useClientReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors' import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
@@ -418,18 +410,16 @@ import {
type MainFormDraft, type MainFormDraft,
} from '~/modules/commercial/utils/clientEdit' } from '~/modules/commercial/utils/clientEdit'
import { import {
addressTypeFromFlags,
buildClientFormTabKeys, buildClientFormTabKeys,
hasAllRequiredAccountingFields, hasAllRequiredAccountingFields,
hasAtLeastOneValidContact, hasAtLeastOneValidContact,
isAddressValid,
isBankRequiredForPaymentType, isBankRequiredForPaymentType,
isBillingEmailRequired, isBillingEmailRequired,
isContactBlank, isContactBlank,
isContactNamed, isContactNamed,
isRibBlank, isRibBlank,
isRibComplete,
isRibRequiredForPaymentType, isRibRequiredForPaymentType,
showsRelationAndTriageFields,
} from '~/modules/commercial/utils/clientFormRules' } from '~/modules/commercial/utils/clientFormRules'
import { import {
emptyAddress, emptyAddress,
@@ -440,7 +430,6 @@ import {
type RibFormDraft, type RibFormDraft,
} from '~/modules/commercial/types/clientForm' } from '~/modules/commercial/types/clientForm'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { readHistoryTab } from '~/shared/utils/historyTab'
// Masques de saisie (la normalisation finale reste serveur). // Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -511,9 +500,7 @@ function hydrate(detail: ClientDetail): void {
// un bloc vierge (non persiste tant qu'incomplet — cf. submit*/canValidate*). // un bloc vierge (non persiste tant qu'incomplet — cf. submit*/canValidate*).
if (contacts.value.length === 0) contacts.value.push(emptyContact()) if (contacts.value.length === 0) contacts.value.push(emptyContact())
if (addresses.value.length === 0) addresses.value.push(emptyAddress()) if (addresses.value.length === 0) addresses.value.push(emptyAddress())
// RIB : amorce un bloc vide seulement si le type de reglement est une LCR if (ribs.value.length === 0) ribs.value.push(emptyRib())
// (sinon la section reste masquee — RG-1.13).
if (isRibRequired.value && ribs.value.length === 0) ribs.value.push(emptyRib())
// Charge les listes distributeur / courtier si une relation est deja posee. // Charge les listes distributeur / courtier si une relation est deja posee.
if (main.relationType === 'distributeur') referentials.loadDistributors().catch(() => {}) if (main.relationType === 'distributeur') referentials.loadDistributors().catch(() => {})
if (main.relationType === 'courtier') referentials.loadBrokers().catch(() => {}) if (main.relationType === 'courtier') referentials.loadBrokers().catch(() => {})
@@ -564,28 +551,6 @@ const relationOptions = computed<RefOption[]>(() => [
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') }, { value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
]) ])
// Codes des categories selectionnees (resolus depuis l'union referentiel + embed).
const selectedCategoryCodes = computed(() =>
main.categoryIris
.map(iri => mainCategoryOptions.value.find(c => c.value === iri)?.code)
.filter((code): code is string => code !== undefined),
)
// « Relation » + « Prestation de triage » masques par defaut, reveles des qu'une
// categorie ordinaire (autre que Distributeur/Courtier) est selectionnee.
const showRelationAndTriage = computed(() => showsRelationAndTriageFields(selectedCategoryCodes.value))
// Masquer ces champs reinitialise leurs valeurs : pas de relation/triage fantome
// soumis pour un client Distributeur/Courtier.
watch(showRelationAndTriage, (visible) => {
if (!visible) {
main.relationType = null
main.distributorIri = null
main.brokerIri = null
main.triageService = false
}
})
// Distributeur / courtier : referentiel charge a la demande UNION valeur courante. // Distributeur / courtier : referentiel charge a la demande UNION valeur courante.
const currentDistributorOption = computed<RefOption[]>(() => { const currentDistributorOption = computed<RefOption[]>(() => {
const d = client.value?.distributor const d = client.value?.distributor
@@ -627,13 +592,11 @@ const tabs = computed(() => tabKeys.value.map(key => ({
icon: TAB_ICONS[key], icon: TAB_ICONS[key],
}))) })))
// Onglet initial : repris de la consultation (history.state), sinon Information. const activeTab = ref('information')
const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
// ── Navigation ────────────────────────────────────────────────────────────── // ── Navigation ──────────────────────────────────────────────────────────────
/** Retour consultation en conservant l'onglet courant (via history.state). */
function goBack(): void { function goBack(): void {
router.push({ path: `/clients/${clientId}`, state: { tab: activeTab.value } }) router.push(`/clients/${clientId}`)
} }
/** /**
@@ -824,17 +787,18 @@ async function submitContacts(): Promise<void> {
// ── Onglet Adresse ─────────────────────────────────────────────────────────── // ── Onglet Adresse ───────────────────────────────────────────────────────────
const canValidateAddresses = computed(() => const canValidateAddresses = computed(() =>
addresses.value.length > 0 && addresses.value.every(isAddressValid), addresses.value.length > 0
&& addresses.value.every((a) => {
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
return addressTypeFromFlags(a) !== null
&& a.siteIris.length >= 1
&& a.categoryIris.length >= 1
&& (!isBillingEmailRequired(a) || filledBillingEmail)
}),
) )
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => {
const last = addresses.value[addresses.value.length - 1]
return last !== undefined && isAddressValid(last)
})
function addAddress(): void { function addAddress(): void {
if (canAddAddress.value) addresses.value.push(emptyAddress()) addresses.value.push(emptyAddress())
} }
function askRemoveAddress(index: number): void { function askRemoveAddress(index: number): void {
@@ -906,42 +870,25 @@ const selectedPaymentTypeCode = computed(() =>
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value)) const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value)) const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
// Les blocs RIB ne sont affiches que pour une LCR (RG-1.13).
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
function onPaymentTypeChange(value: string | number | null): void { function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value) accounting.paymentTypeIri = value === null ? null : String(value)
if (!isBankRequired.value) accounting.bankIri = null if (!isBankRequired.value) accounting.bankIri = null
// Les RIB n'ont de sens que pour une LCR (RG-1.13) : on amorce un bloc vide }
// quand LCR est choisi, sinon on vide la liste — les RIB deja persistes sont
// marques pour suppression serveur au prochain enregistrement. function ribIsComplete(rib: { label: string | null, bic: string | null, iban: string | null }): boolean {
if (isRibRequired.value) { const filled = (v: string | null) => v !== null && v.trim() !== ''
if (ribs.value.length === 0) ribs.value.push(emptyRib()) return filled(rib.label) && filled(rib.bic) && filled(rib.iban)
}
else {
for (const rib of ribs.value) {
if (rib.id != null) removedRibIds.value.push(rib.id)
}
ribs.value = []
ribErrors.value = []
}
} }
const canValidateAccounting = computed(() => { const canValidateAccounting = computed(() => {
if (!hasAllRequiredAccountingFields(accounting)) return false if (!hasAllRequiredAccountingFields(accounting)) return false
if (isBankRequired.value && accounting.bankIri === null) return false if (isBankRequired.value && accounting.bankIri === null) return false
if (isRibRequired.value && !ribs.value.some(isRibComplete)) return false if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false
return true return true
}) })
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
const canAddRib = computed(() => {
const last = ribs.value[ribs.value.length - 1]
return last !== undefined && isRibComplete(last)
})
function addRib(): void { function addRib(): void {
if (canAddRib.value) ribs.value.push(emptyRib()) ribs.value.push(emptyRib())
} }
function askRemoveRib(index: number): void { function askRemoveRib(index: number): void {
@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<!-- En-tete : retour repertoire + nom du client + actions (Modifier / Archiver|Restaurer). --> <!-- En-tete : retour repertoire + nom du client + actions (Modifier / Archiver|Restaurer). -->
<div class="flex items-center gap-3 pt-11"> <div class="flex items-center gap-3">
<MalioButtonIcon <MalioButtonIcon
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
@@ -9,7 +9,7 @@
v-bind="{ ariaLabel: t('commercial.clients.consultation.back') }" v-bind="{ ariaLabel: t('commercial.clients.consultation.back') }"
@click="goBack" @click="goBack"
/> />
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1> <h1 class="text-[32px] font-bold text-m-primary">{{ headerTitle }}</h1>
<!-- gap-12 = 48px : meme espacement que Ajouter / Filtres du repertoire. --> <!-- gap-12 = 48px : meme espacement que Ajouter / Filtres du repertoire. -->
<div class="ml-auto flex items-center gap-12"> <div class="ml-auto flex items-center gap-12">
@@ -88,14 +88,11 @@
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
sur les inputs (champ 40px centre dans un h-12 -> ~4px de
coussin de chaque cote). -->
<MalioInputTextArea <MalioInputTextArea
:model-value="information.description" :model-value="information.description"
:label="t('commercial.clients.form.information.description')" :label="t('commercial.clients.form.information.description')"
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1"
text-input="h-full text-lg" text-input="h-full text-lg"
readonly readonly
/> />
@@ -281,7 +278,6 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient' import { useClient } from '~/modules/commercial/composables/useClient'
import { buildClientFormTabKeys } from '~/modules/commercial/utils/clientFormRules' import { buildClientFormTabKeys } from '~/modules/commercial/utils/clientFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab'
import { import {
canEditClient, canEditClient,
categoryOptionsOf, categoryOptionsOf,
@@ -297,7 +293,7 @@ import {
type ClientDetail, type ClientDetail,
type SelectOption, type SelectOption,
} from '~/modules/commercial/utils/clientConsultation' } from '~/modules/commercial/utils/clientConsultation'
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm' import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/clientForm'
// Masque d'affichage (purement visuel, la donnee reste celle du serveur). // Masque d'affichage (purement visuel, la donnee reste celle du serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -354,10 +350,10 @@ const addressViews = computed(() => {
const views = (client.value?.addresses ?? []).map(mapAddressView) const views = (client.value?.addresses ?? []).map(mapAddressView)
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }] return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
}) })
// Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le const ribs = computed(() => {
// client n'en a pas (un RIB n'existe que pour un reglement LCR — RG-1.13). Pas const list = (client.value?.ribs ?? []).map(mapRibToDraft)
// de bloc vierge fantome en consultation. return list.length ? list : [emptyRib()]
const ribs = computed(() => (client.value?.ribs ?? []).map(mapRibToDraft)) })
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view). // Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail))) const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail)))
@@ -417,17 +413,15 @@ const tabs = computed(() => tabKeys.value.map(key => ({
icon: TAB_ICONS[key], icon: TAB_ICONS[key],
}))) })))
// Onglet initial : repris de l'edition au retour (history.state), sinon Information. const activeTab = ref('information')
const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
// ── Navigation ───────────────────────────────────────────────────────────── // ── Navigation ─────────────────────────────────────────────────────────────
function goBack(): void { function goBack(): void {
router.push('/clients') router.push('/clients')
} }
/** Bascule en edition en conservant l'onglet courant (via history.state). */
function goEdit(): void { function goEdit(): void {
router.push({ path: `/clients/${clientId}/edit`, state: { tab: activeTab.value } }) router.push(`/clients/${clientId}/edit`)
} }
// ── Archivage / Restauration ──────────────────────────────────────────────── // ── Archivage / Restauration ────────────────────────────────────────────────
@@ -3,18 +3,8 @@
<PageHeader> <PageHeader>
{{ t('commercial.clients.title') }} {{ t('commercial.clients.title') }}
<template #actions> <template #actions>
<!-- gap-8 = 32px d'espacement entre Filtres et Ajouter. --> <!-- gap-12 = 48px d'espacement entre Ajouter et Filtres. -->
<div class="flex items-center gap-8"> <div class="flex items-center gap-12">
<!-- Bouton Filtres a GAUCHE d'Ajouter. Le compteur reflete les filtres actifs. -->
<MalioButton
v-if="canView"
variant="tertiary"
:label="filterButtonLabel"
icon-name="mdi:tune"
icon-position="left"
icon-size="24"
@click="openFilters"
/>
<MalioButton <MalioButton
v-if="canManage" v-if="canManage"
variant="secondary" variant="secondary"
@@ -23,6 +13,18 @@
icon-position="left" icon-position="left"
@click="goToCreate" @click="goToCreate"
/> />
<!-- Bouton Filtres a DROITE d'Ajouter : meme design que
l'audit-log. Le compteur reflete les filtres actifs. -->
<MalioButton
v-if="canView"
variant="tertiary"
:label="filterButtonLabel"
icon-name="mdi:tune"
icon-position="left"
icon-size="24"
button-class="w-[184px] justify-start gap-4 text-black"
@click="openFilters"
/>
</div> </div>
</template> </template>
</PageHeader> </PageHeader>
@@ -37,7 +39,7 @@
:per-page="itemsPerPage" :per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions" :per-page-options="itemsPerPageOptions"
row-clickable row-clickable
table-class="table-fixed clients-table" table-class="table-fixed"
:empty-message="t('commercial.clients.empty')" :empty-message="t('commercial.clients.empty')"
@row-click="onRowClick" @row-click="onRowClick"
@update:page="goToPage" @update:page="goToPage"
@@ -54,7 +56,7 @@
<span <span
v-for="site in (item.sites as ClientSite[])" v-for="site in (item.sites as ClientSite[])"
:key="site.id" :key="site.id"
class="inline-flex items-center rounded-full px-2 py-0.5 font-medium text-white" class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium text-white"
:style="{ backgroundColor: site.color }" :style="{ backgroundColor: site.color }"
> >
{{ site.name }} {{ site.name }}
@@ -68,7 +70,7 @@
</template> </template>
</MalioDataTable> </MalioDataTable>
<div class="flex justify-center mt-4"> <div class="flex justify-center mt-6">
<MalioButton <MalioButton
v-if="canView" v-if="canView"
variant="primary" variant="primary"
@@ -348,9 +350,7 @@ async function loadFilterOptions(): Promise<void> {
const [cats, sites] = await Promise.all([ const [cats, sites] = await Promise.all([
api.get<{ member?: Array<{ code: string, name: string }> }>( api.get<{ member?: Array<{ code: string, name: string }> }>(
'/categories', '/categories',
// Taxonomie multi-types (ERP-84) : le filtre du repertoire client ne { pagination: 'false' },
// propose que les categories de type CLIENT (pas les FOURNISSEUR).
{ pagination: 'false', typeCode: 'CLIENT' },
{ headers: { Accept: 'application/ld+json' }, toast: false }, { headers: { Accept: 'application/ld+json' }, toast: false },
), ),
api.get<{ member?: Array<{ id: number, name: string }> }>( api.get<{ member?: Array<{ id: number, name: string }> }>(
@@ -419,16 +419,3 @@ onMounted(() => {
}) })
}) })
</script> </script>
<style scoped>
/*
* Colonne Sites uniquement (3e colonne : companyName, categories, SITES,
* lastActivity) : ses badges rendent la cellule trop haute. On reduit le padding
* vertical de SON td (16px Malio -> 8px) sans toucher les autres colonnes ni les
* couleurs/tailles (qui restent sur les defauts Malio).
*/
:deep(.clients-table tbody td:nth-child(3)) {
padding-top: 8px;
padding-bottom: 8px;
}
</style>
@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<!-- En-tete : retour vers le repertoire + titre. --> <!-- En-tete : retour vers le repertoire + titre. -->
<div class="flex items-center gap-3 pt-11"> <div class="flex items-center gap-3">
<MalioButtonIcon <MalioButtonIcon
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
@@ -9,7 +9,7 @@
v-bind="{ ariaLabel: t('commercial.clients.form.back') }" v-bind="{ ariaLabel: t('commercial.clients.form.back') }"
@click="goBack" @click="goBack"
/> />
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('commercial.clients.form.title') }}</h1> <h1 class="text-[32px] font-bold text-m-primary">{{ t('commercial.clients.form.title') }}</h1>
</div> </div>
<!-- Formulaire principal (pre-onglets) <!-- Formulaire principal (pre-onglets)
@@ -35,7 +35,6 @@
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/> />
<MalioSelect <MalioSelect
v-if="showRelationAndTriage"
:model-value="main.relationType" :model-value="main.relationType"
:options="relationOptions" :options="relationOptions"
:label="t('commercial.clients.form.main.relation')" :label="t('commercial.clients.form.main.relation')"
@@ -44,7 +43,7 @@
@update:model-value="onRelationChange" @update:model-value="onRelationChange"
/> />
<MalioSelect <MalioSelect
v-if="showRelationAndTriage && main.relationType === 'courtier'" v-if="main.relationType === 'courtier'"
:model-value="main.brokerIri" :model-value="main.brokerIri"
:options="referentials.brokers.value" :options="referentials.brokers.value"
:label="t('commercial.clients.form.main.brokerName')" :label="t('commercial.clients.form.main.brokerName')"
@@ -54,7 +53,7 @@
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
/> />
<MalioSelect <MalioSelect
v-if="showRelationAndTriage && main.relationType === 'distributeur'" v-if="main.relationType === 'distributeur'"
:model-value="main.distributorIri" :model-value="main.distributorIri"
:options="referentials.distributors.value" :options="referentials.distributors.value"
:label="t('commercial.clients.form.main.distributorName')" :label="t('commercial.clients.form.main.distributorName')"
@@ -64,7 +63,6 @@
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
/> />
<MalioCheckbox <MalioCheckbox
v-if="showRelationAndTriage"
v-model="main.triageService" v-model="main.triageService"
:label="t('commercial.clients.form.main.triageService')" :label="t('commercial.clients.form.main.triageService')"
group-class="self-center" group-class="self-center"
@@ -86,15 +84,13 @@
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs, dont <!-- pt-1 : aligne le bord superieur du textarea sur celui des
le champ de 40px est centre dans un conteneur h-12 (~4px de inputs (centres dans un conteneur h-12, soit ~4px de retrait haut). -->
coussin en HAUT et en BAS). Sans pb-1, le textarea descend ~4px
plus bas que les champs voisins. -->
<MalioInputTextArea <MalioInputTextArea
v-model="information.description" v-model="information.description"
:label="t('commercial.clients.form.information.description')" :label="t('commercial.clients.form.information.description')"
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1"
text-input="h-full text-lg" text-input="h-full text-lg"
:readonly="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.description" :error="informationErrors.errors.description"
@@ -138,15 +134,13 @@
/> />
</div> </div>
<div v-if="!isValidated('information')" class="mt-12 flex justify-center"> <div v-if="!isValidated('information')" class="mt-12 flex justify-center">
<!-- Desactive tant que le client n'est pas cree (evite un PATCH <!-- Desactive tant que le client n'est pas cree : evite un PATCH
avant le POST si clic trop tot, Information etant l'onglet avant le POST si l'utilisateur clique trop tot (le panneau
actif par defaut) OU si aucun champ n'est rempli : onglet Information est l'onglet actif par defaut). -->
facultatif, mais pas de validation a vide (on passe alors
directement a Contact). -->
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('commercial.clients.form.submit')" :label="t('commercial.clients.form.submit')"
:disabled="tabSubmitting || clientId === null || !canValidateInformation" :disabled="tabSubmitting || clientId === null"
@click="submitInformation" @click="submitInformation"
/> />
</div> </div>
@@ -210,7 +204,6 @@
icon-name="mdi:add-bold" icon-name="mdi:add-bold"
icon-position="left" icon-position="left"
:label="t('commercial.clients.form.address.add')" :label="t('commercial.clients.form.address.add')"
:disabled="!canAddAddress"
@click="addAddress" @click="addAddress"
/> />
<MalioButton <MalioButton
@@ -294,9 +287,9 @@
</div> </div>
</div> </div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13). --> <!-- Blocs RIB (0..n) — obligatoires si type de reglement = LCR. -->
<div <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in ribs"
:key="index" :key="index"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
> >
@@ -336,12 +329,10 @@
<div v-if="!accountingReadonly" class="flex justify-center gap-6"> <div v-if="!accountingReadonly" class="flex justify-center gap-6">
<MalioButton <MalioButton
v-if="isRibRequired"
variant="secondary" variant="secondary"
icon-name="mdi:add-bold" icon-name="mdi:add-bold"
icon-position="left" icon-position="left"
:label="t('commercial.clients.form.accounting.addRib')" :label="t('commercial.clients.form.accounting.addRib')"
:disabled="!canAddRib"
@click="addRib" @click="addRib"
/> />
<MalioButton <MalioButton
@@ -389,20 +380,17 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials' import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors' import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
import { import {
addressTypeFromFlags,
buildClientFormTabKeys, buildClientFormTabKeys,
CLIENT_FORM_PLACEHOLDER_TABS, CLIENT_FORM_PLACEHOLDER_TABS,
hasAllRequiredAccountingFields, hasAllRequiredAccountingFields,
hasAtLeastOneInformationField,
hasAtLeastOneValidContact, hasAtLeastOneValidContact,
isAddressValid,
isBankRequiredForPaymentType, isBankRequiredForPaymentType,
isBillingEmailRequired, isBillingEmailRequired,
isContactBlank, isContactBlank,
isContactNamed, isContactNamed,
isRibBlank, isRibBlank,
isRibComplete,
isRibRequiredForPaymentType, isRibRequiredForPaymentType,
showsRelationAndTriageFields,
} from '~/modules/commercial/utils/clientFormRules' } from '~/modules/commercial/utils/clientFormRules'
import { import {
emptyAddress, emptyAddress,
@@ -495,28 +483,6 @@ const relationOptions = computed<RefOption[]>(() => [
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') }, { value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
]) ])
// Codes des categories selectionnees (resolus depuis les IRI du brouillon).
const selectedCategoryCodes = computed(() =>
main.categoryIris
.map(iri => referentials.categories.value.find(c => c.value === iri)?.code)
.filter((code): code is string => code !== undefined),
)
// « Relation » + « Prestation de triage » masques par defaut, reveles des qu'une
// categorie ordinaire (autre que Distributeur/Courtier) est selectionnee.
const showRelationAndTriage = computed(() => showsRelationAndTriageFields(selectedCategoryCodes.value))
// Masquer ces champs reinitialise leurs valeurs : pas de relation/triage fantome
// soumis pour un client Distributeur/Courtier.
watch(showRelationAndTriage, (visible) => {
if (!visible) {
main.relationType = null
main.distributorIri = null
main.brokerIri = null
main.triageService = false
}
})
// Validation du formulaire principal (gate le bouton « Valider ») : // Validation du formulaire principal (gate le bouton « Valider ») :
// - companyName / >= 1 categorie obligatoires ; // - companyName / >= 1 categorie obligatoires ;
// - relation Distributeur/Courtier optionnelle, mais le nom correspondant // - relation Distributeur/Courtier optionnelle, mais le nom correspondant
@@ -572,9 +538,7 @@ async function submitMain(): Promise<void> {
main.companyName = created.companyName ?? main.companyName main.companyName = created.companyName ?? main.companyName
mainLocked.value = true mainLocked.value = true
// Information est facultatif : on deverrouille jusqu'a Contact (index 1) unlockedIndex.value = 0
// pour que l'utilisateur puisse y aller directement sans valider Information.
unlockedIndex.value = tabIndex('contact')
activeTab.value = 'information' activeTab.value = 'information'
toast.success({ title: t('commercial.clients.toast.createSuccess') }) toast.success({ title: t('commercial.clients.toast.createSuccess') })
} }
@@ -661,12 +625,9 @@ const information = reactive({
directorName: null as string | null, directorName: null as string | null,
}) })
// Onglet facultatif, mais pas de validation « a vide » : au moins un champ rempli.
const canValidateInformation = computed(() => hasAtLeastOneInformationField(information))
/** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */ /** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */
async function submitInformation(): Promise<void> { async function submitInformation(): Promise<void> {
if (clientId.value === null || tabSubmitting.value || !canValidateInformation.value) return if (clientId.value === null || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
informationErrors.clearErrors() informationErrors.clearErrors()
try { try {
@@ -792,17 +753,18 @@ const countryOptions: RefOption[] = [
// Type d'adresse (Select) obligatoire + RG-1.10 (>= 1 site) + RG-1.11 (email // Type d'adresse (Select) obligatoire + RG-1.10 (>= 1 site) + RG-1.11 (email
// facturation si Facturation) sur chaque adresse. // facturation si Facturation) sur chaque adresse.
const canValidateAddresses = computed(() => const canValidateAddresses = computed(() =>
addresses.value.length > 0 && addresses.value.every(isAddressValid), addresses.value.length > 0
&& addresses.value.every((a) => {
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
return addressTypeFromFlags(a) !== null
&& a.siteIris.length >= 1
&& a.categoryIris.length >= 1
&& (!isBillingEmailRequired(a) || filledBillingEmail)
}),
) )
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => {
const last = addresses.value[addresses.value.length - 1]
return last !== undefined && isAddressValid(last)
})
function addAddress(): void { function addAddress(): void {
if (canAddAddress.value) addresses.value.push(emptyAddress()) addresses.value.push(emptyAddress())
} }
function askRemoveAddress(index: number): void { function askRemoveAddress(index: number): void {
@@ -891,22 +853,15 @@ const selectedPaymentTypeCode = computed(() =>
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value)) const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value)) const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
// Les blocs RIB ne sont affiches que pour une LCR (RG-1.13).
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
function onPaymentTypeChange(value: string | number | null): void { function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value) accounting.paymentTypeIri = value === null ? null : String(value)
// La banque n'a de sens que pour un virement : on la vide sinon (RG-1.12). // La banque n'a de sens que pour un virement : on la vide sinon (RG-1.12).
if (!isBankRequired.value) accounting.bankIri = null if (!isBankRequired.value) accounting.bankIri = null
// Les RIB n'ont de sens que pour une LCR (RG-1.13) : on amorce un bloc vide }
// quand LCR est choisi, on vide la liste sinon (pas de RIB fantome soumis).
if (isRibRequired.value) { function ribIsComplete(rib: RibFormDraft): boolean {
if (ribs.value.length === 0) ribs.value.push(emptyRib()) const filled = (v: string | null) => v !== null && v.trim() !== ''
} return filled(rib.label) && filled(rib.bic) && filled(rib.iban)
else {
ribs.value = []
ribErrors.value = []
}
} }
// RG-1.30 : les 6 champs scalaires obligatoires (comme les onglets Contact / // RG-1.30 : les 6 champs scalaires obligatoires (comme les onglets Contact /
@@ -915,18 +870,12 @@ function onPaymentTypeChange(value: string | number | null): void {
const canValidateAccounting = computed(() => { const canValidateAccounting = computed(() => {
if (!hasAllRequiredAccountingFields(accounting)) return false if (!hasAllRequiredAccountingFields(accounting)) return false
if (isBankRequired.value && (accounting.bankIri === null)) return false if (isBankRequired.value && (accounting.bankIri === null)) return false
if (isRibRequired.value && !ribs.value.some(isRibComplete)) return false if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false
return true return true
}) })
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
const canAddRib = computed(() => {
const last = ribs.value[ribs.value.length - 1]
return last !== undefined && isRibComplete(last)
})
function addRib(): void { function addRib(): void {
if (canAddRib.value) ribs.value.push(emptyRib()) ribs.value.push(emptyRib())
} }
function askRemoveRib(index: number): void { function askRemoveRib(index: number): void {
@@ -1038,7 +987,8 @@ interface ContactResponse {
onMounted(() => { onMounted(() => {
// Echec du chargement des referentiels non bloquant : les selects restent vides. // Echec du chargement des referentiels non bloquant : les selects restent vides.
referentials.loadCommon().catch(() => {}) referentials.loadCommon().catch(() => {})
// Pas d'amorce de RIB ici : un bloc vide n'apparait que si LCR est choisi // Au moins un bloc RIB toujours visible en creation : on amorce un bloc vide
// (cf. onPaymentTypeChange). // (non persiste tant qu'incomplet — RG-1.13).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}) })
</script> </script>
@@ -7,19 +7,14 @@ import {
canSelectDeliveryOrBilling, canSelectDeliveryOrBilling,
canSelectProspect, canSelectProspect,
hasAllRequiredAccountingFields, hasAllRequiredAccountingFields,
hasAtLeastOneInformationField,
hasAtLeastOneValidContact, hasAtLeastOneValidContact,
isAddressValid,
isBankRequiredForPaymentType, isBankRequiredForPaymentType,
isBillingEmailRequired, isBillingEmailRequired,
isBlankRow, isBlankRow,
isContactBlank, isContactBlank,
isContactNamed, isContactNamed,
isRibBlank, isRibBlank,
isRibComplete,
isRibRequiredForPaymentType, isRibRequiredForPaymentType,
showsRelationAndTriageFields,
type AddressValidityDraft,
type ContactDraft, type ContactDraft,
type ContactFillableDraft, type ContactFillableDraft,
} from '../clientFormRules' } from '../clientFormRules'
@@ -276,96 +271,3 @@ describe('hasAllRequiredAccountingFields (RG-1.30)', () => {
})).toBe(false) })).toBe(false)
}) })
}) })
describe('showsRelationAndTriageFields (affichage Relation + Triage selon categorie)', () => {
it('faux par defaut (aucune categorie selectionnee)', () => {
expect(showsRelationAndTriageFields([])).toBe(false)
})
it('faux si seules des categories Distributeur / Courtier sont selectionnees', () => {
expect(showsRelationAndTriageFields(['DISTRIBUTEUR'])).toBe(false)
expect(showsRelationAndTriageFields(['COURTIER'])).toBe(false)
expect(showsRelationAndTriageFields(['DISTRIBUTEUR', 'COURTIER'])).toBe(false)
})
it('vrai des qu\'une categorie ordinaire est selectionnee', () => {
expect(showsRelationAndTriageFields(['CLIENT'])).toBe(true)
expect(showsRelationAndTriageFields(['DISTRIBUTEUR', 'CLIENT'])).toBe(true)
})
})
describe('hasAtLeastOneInformationField (pas de validation a vide a la creation)', () => {
const blank = {
description: null,
competitors: null,
foundedAt: null,
employeesCount: null,
revenueAmount: null,
profitAmount: null,
directorName: null,
}
it('faux quand aucun champ n\'est rempli (onglet vierge)', () => {
expect(hasAtLeastOneInformationField(blank)).toBe(false)
expect(hasAtLeastOneInformationField({ ...blank, description: ' ' })).toBe(false)
})
it('vrai des qu\'un champ porte une valeur', () => {
expect(hasAtLeastOneInformationField({ ...blank, description: 'Acteur majeur' })).toBe(true)
expect(hasAtLeastOneInformationField({ ...blank, employeesCount: '42' })).toBe(true)
expect(hasAtLeastOneInformationField({ ...blank, foundedAt: '2020-01-01' })).toBe(true)
})
})
describe('isAddressValid (gating « + Adresse » + validation onglet)', () => {
/** Adresse de livraison valide (type + site + categorie ; pas de facturation). */
function validDelivery(): AddressValidityDraft {
return {
isProspect: false,
isDelivery: true,
isBilling: false,
categoryIris: ['/api/client_categories/1'],
siteIris: ['/api/sites/1'],
billingEmail: null,
}
}
it('vrai quand type + >= 1 site + >= 1 categorie (sans facturation)', () => {
expect(isAddressValid(validDelivery())).toBe(true)
})
it('faux si aucun drapeau (type d\'adresse non renseigne, amorce vierge)', () => {
expect(isAddressValid({ ...validDelivery(), isDelivery: false })).toBe(false)
})
it('faux si aucun site (RG-1.10)', () => {
expect(isAddressValid({ ...validDelivery(), siteIris: [] })).toBe(false)
})
it('faux si aucune categorie', () => {
expect(isAddressValid({ ...validDelivery(), categoryIris: [] })).toBe(false)
})
it('RG-1.11 : email de facturation obligatoire quand l\'adresse est de facturation', () => {
const billing: AddressValidityDraft = { ...validDelivery(), isBilling: true }
expect(isAddressValid({ ...billing, billingEmail: null })).toBe(false)
expect(isAddressValid({ ...billing, billingEmail: ' ' })).toBe(false)
expect(isAddressValid({ ...billing, billingEmail: 'facture@acme.fr' })).toBe(true)
})
})
describe('isRibComplete (gating « + RIB » + RG-1.13)', () => {
it('vrai quand label + BIC + IBAN sont remplis', () => {
expect(isRibComplete({ label: 'Compte courant', bic: 'BNPAFRPP', iban: 'FR1420041010050500013M02606' })).toBe(true)
})
it('faux si un champ manque (null ou vide apres trim)', () => {
expect(isRibComplete({ label: null, bic: 'BNPAFRPP', iban: 'FR14...' })).toBe(false)
expect(isRibComplete({ label: 'Compte', bic: ' ', iban: 'FR14...' })).toBe(false)
expect(isRibComplete({ label: 'Compte', bic: 'BNPAFRPP', iban: null })).toBe(false)
})
it('faux pour un bloc totalement vide (amorce)', () => {
expect(isRibComplete({ label: null, bic: null, iban: null })).toBe(false)
})
})
@@ -12,8 +12,10 @@
* *
* Ces helpers ne touchent ni a l'API ni a l'etat reactif. * Ces helpers ne touchent ni a l'API ni a l'etat reactif.
* *
* NOTE : l'onglet Information est facultatif pour tous les roles (RG-1.04 * NOTE RG-1.04 (Information obligatoire pour la Commerciale) : volontairement NON
* « Information obligatoire pour la Commerciale » retiree cote back). * miroitee cote front (cf. clientFormRules.ts) — /api/me n'expose pas le code de
* role et Bureau partage les permissions de Commerciale. Le back l'applique de
* maniere fiable (422) ; on laisse remonter ce 422 en toast.
*/ */
import { import {
@@ -9,9 +9,12 @@
* Le back reste la source de verite (les RG sont re-validees serveur) ; ces * Le back reste la source de verite (les RG sont re-validees serveur) ; ces
* regles ne servent qu'au feedback UI immediat (gating de boutons, visibilite). * regles ne servent qu'au feedback UI immediat (gating de boutons, visibilite).
* *
* NOTE : l'onglet Information est facultatif pour tous les roles. L'ancienne * NOTE RG-1.04 (Information obligatoire pour la Commerciale) : volontairement
* RG-1.04 (« Information obligatoire pour la Commerciale ») a ete retiree cote * NON miroite cote front pour l'instant. Le payload /api/me ne porte pas le code
* back — rien a miroiter ici. * de role (roles = IRIs opaques) et Bureau partage les memes permissions que
* Commerciale : aucun signal fiable pour distinguer le role cote front. Le back
* (ClientProcessor, via BusinessRoleAware) applique la regle de maniere fiable ;
* a rebrancher ici des qu'un code de role sera expose dans /api/me.
*/ */
/** /**
@@ -50,26 +53,6 @@ export function buildClientFormTabKeys(
return keys return keys
} }
/**
* Codes de categorie « intermediaire » : un client dont la categorie est
* Distributeur ou Courtier n'a ni relation amont (il EST le distributeur /
* courtier) ni prestation de triage. Sert a conditionner l'affichage des champs
* « Relation » et « Prestation de triage » du formulaire principal.
*/
export const DISTRIBUTOR_BROKER_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER'] as const
/**
* Vrai des qu'au moins une categorie « ordinaire » (autre que Distributeur /
* Courtier) est selectionnee. Les champs « Relation » (depend du distributeur /
* courtier) et « Prestation de triage » du formulaire principal sont masques par
* defaut et reveles uniquement dans ce cas.
*/
export function showsRelationAndTriageFields(selectedCategoryCodes: string[]): boolean {
return selectedCategoryCodes.some(
code => !(DISTRIBUTOR_BROKER_CATEGORY_CODES as readonly string[]).includes(code),
)
}
/** Sous-ensemble d'un contact necessaire aux regles de nommage (RG-1.05/1.14). */ /** Sous-ensemble d'un contact necessaire aux regles de nommage (RG-1.05/1.14). */
export interface ContactDraft { export interface ContactDraft {
firstName: string | null firstName: string | null
@@ -155,16 +138,6 @@ export function isRibBlank(rib: RibFillableDraft): boolean {
return isBlankRow([rib.label, rib.bic, rib.iban]) return isBlankRow([rib.label, rib.bic, rib.iban])
} }
/**
* RG-1.13 : un RIB est complet quand ses trois champs sont remplis (label, BIC,
* IBAN). Predicat par-bloc partage entre le gating du bouton « + RIB » (le
* dernier bloc doit etre complet avant d'en ajouter un autre) et la validation
* de l'onglet (au moins un RIB complet si reglement LCR).
*/
export function isRibComplete(rib: RibFillableDraft): boolean {
return isFilled(rib.label) && isFilled(rib.bic) && isFilled(rib.iban)
}
/** /**
* RG-1.06/07/08 : une adresse de prospection est exclusive d'une adresse de * RG-1.06/07/08 : une adresse de prospection est exclusive d'une adresse de
* livraison/facturation. Prospect n'est selectionnable que si ni Livraison ni * livraison/facturation. Prospect n'est selectionnable que si ni Livraison ni
@@ -253,31 +226,6 @@ export function addressTypeFromFlags(flags: AddressFlagsDraft): AddressType | nu
return null return null
} }
/**
* Sous-ensemble d'une adresse necessaire a sa validite par-bloc : drapeaux
* d'usage (pour le type + l'email de facturation conditionnel), sites et
* categories rattaches, email de facturation.
*/
export interface AddressValidityDraft extends AddressFlagsDraft {
categoryIris: string[]
siteIris: string[]
billingEmail: string | null
}
/**
* Validite par-bloc d'une adresse : type renseigne (RG-1.06/07/08), >= 1 site
* (RG-1.10), >= 1 categorie, et email de facturation rempli si l'adresse est de
* facturation (RG-1.11). Predicat partage entre le gating du bouton « + Adresse »
* (le dernier bloc doit etre valide avant d'en ajouter un autre) et la
* validation de l'onglet (toutes les adresses valides).
*/
export function isAddressValid(address: AddressValidityDraft): boolean {
return addressTypeFromFlags(address) !== null
&& address.siteIris.length >= 1
&& address.categoryIris.length >= 1
&& (!isBillingEmailRequired(address) || isFilled(address.billingEmail))
}
/** Code stable du type de reglement « virement » (cf. PaymentType.code, RG-1.12). */ /** Code stable du type de reglement « virement » (cf. PaymentType.code, RG-1.12). */
const PAYMENT_TYPE_TRANSFER = 'VIREMENT' const PAYMENT_TYPE_TRANSFER = 'VIREMENT'
@@ -300,36 +248,6 @@ export function isRibRequiredForPaymentType(code: string | null | undefined): bo
return code === PAYMENT_TYPE_LCR return code === PAYMENT_TYPE_LCR
} }
/** Champs saisissables de l'onglet Information (tous facultatifs). */
export interface InformationFieldsDraft {
description: string | null
competitors: string | null
foundedAt: string | null
employeesCount: string | null
revenueAmount: string | null
profitAmount: string | null
directorName: string | null
}
/**
* Vrai si au moins un champ de l'onglet Information est rempli. L'onglet est
* facultatif (aucun champ obligatoire), mais on n'autorise pas une validation
* « a vide » a la creation : sans donnee, rien a enregistrer, l'utilisateur
* passe directement a l'onglet Contact. (En edition, vider tous les champs reste
* une action legitime : ce gate n'y est pas applique.)
*/
export function hasAtLeastOneInformationField(information: InformationFieldsDraft): boolean {
return !isBlankRow([
information.description,
information.competitors,
information.foundedAt,
information.employeesCount,
information.revenueAmount,
information.profitAmount,
information.directorName,
])
}
/** Sous-ensemble du brouillon comptable portant les six champs obligatoires. */ /** Sous-ensemble du brouillon comptable portant les six champs obligatoires. */
export interface AccountingRequiredDraft { export interface AccountingRequiredDraft {
siren: string | null siren: string | null
@@ -9,6 +9,7 @@
icon-name="mdi:tune" icon-name="mdi:tune"
icon-position="left" icon-position="left"
icon-size="24" icon-size="24"
button-class="w-[184px] justify-start gap-4 text-black"
@click="openFilters" @click="openFilters"
/> />
</template> </template>
@@ -29,7 +30,7 @@
> >
<template #cell-action="{ item }"> <template #cell-action="{ item }">
<span <span
class="inline-flex items-center rounded-full px-2 py-0.5 font-medium" class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
:class="actionBadgeClass(item.action as string)" :class="actionBadgeClass(item.action as string)"
> >
{{ t(`audit.action.${item.action}`) }} {{ t(`audit.action.${item.action}`) }}
@@ -37,14 +38,15 @@
</template> </template>
<template #cell-entityType="{ item }"> <template #cell-entityType="{ item }">
<span <span
class="text-xs"
:title="item.entityType as string" :title="item.entityType as string"
>{{ formatEntityType(item.entityType as string) }}</span> >{{ formatEntityType(item.entityType as string) }}</span>
</template> </template>
<template #cell-entityId="{ item }"> <template #cell-entityId="{ item }">
<span>{{ item.entityId }}</span> <span class="font-mono text-xs">{{ item.entityId }}</span>
</template> </template>
<template #cell-summary="{ item }"> <template #cell-summary="{ item }">
<span class="text-gray-600">{{ item.summary }}</span> <span class="text-xs text-gray-600">{{ item.summary }}</span>
</template> </template>
</MalioDataTable> </MalioDataTable>
+2 -3
View File
@@ -5,7 +5,6 @@
<template #actions> <template #actions>
<MalioButton <MalioButton
v-if="can('core.roles.manage')" v-if="can('core.roles.manage')"
variant="secondary"
:label="t('admin.roles.newRole')" :label="t('admin.roles.newRole')"
icon-name="mdi:add-bold" icon-name="mdi:add-bold"
icon-position="left" icon-position="left"
@@ -29,7 +28,7 @@
@update:per-page="setItemsPerPage" @update:per-page="setItemsPerPage"
> >
<template #cell-code="{ item }"> <template #cell-code="{ item }">
<span>{{ item.code }}</span> <span class="font-mono text-xs">{{ item.code }}</span>
</template> </template>
<template #cell-permissions="{ item }"> <template #cell-permissions="{ item }">
{{ item.permissions }} {{ item.permissions }}
@@ -37,7 +36,7 @@
<template #cell-system="{ item }"> <template #cell-system="{ item }">
<span <span
v-if="item.isSystem" v-if="item.isSystem"
class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 font-medium text-blue-800" class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800"
> >
{{ t('admin.roles.table.system') }} {{ t('admin.roles.table.system') }}
</span> </span>
+1 -1
View File
@@ -19,7 +19,7 @@
<template #cell-admin="{ item }"> <template #cell-admin="{ item }">
<span <span
v-if="item.admin" v-if="item.admin"
class="inline-flex items-center rounded-full bg-purple-100 px-2.5 py-0.5 font-medium text-purple-800" class="inline-flex items-center rounded-full bg-purple-100 px-2.5 py-0.5 text-xs font-medium text-purple-800"
> >
{{ t('admin.users.table.admin') }} {{ t('admin.users.table.admin') }}
</span> </span>
@@ -62,7 +62,7 @@
<MalioInputText <MalioInputText
v-model="form.color" v-model="form.color"
placeholder="#RRGGBB" placeholder="#RRGGBB"
input-class="w-full" input-class="w-full font-mono"
required required
/> />
<!-- pb-4 sur le wrapper : simule le slot message du <!-- pb-4 sur le wrapper : simule le slot message du
+2 -3
View File
@@ -5,7 +5,6 @@
<template #actions> <template #actions>
<MalioButton <MalioButton
v-if="can('sites.manage')" v-if="can('sites.manage')"
variant="secondary"
:label="t('admin.sites.newSite')" :label="t('admin.sites.newSite')"
icon-name="mdi:add-bold" icon-name="mdi:add-bold"
icon-position="left" icon-position="left"
@@ -34,11 +33,11 @@
:style="{ backgroundColor: item.color }" :style="{ backgroundColor: item.color }"
class="inline-block size-5 rounded-full border border-neutral-200" class="inline-block size-5 rounded-full border border-neutral-200"
/> />
<span>{{ item.color }}</span> <span class="font-mono text-xs">{{ item.color }}</span>
</span> </span>
</template> </template>
<template #cell-fullAddress="{ item }"> <template #cell-fullAddress="{ item }">
<span class="line-clamp-2 text-neutral-600"> <span class="line-clamp-2 text-xs text-neutral-600">
{{ item.fullAddress }} {{ item.fullAddress }}
</span> </span>
</template> </template>
+14 -14
View File
@@ -7,7 +7,7 @@
"name": "starseed-frontend", "name": "starseed-frontend",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.7.7", "@malio/layer-ui": "^1.7.4",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
@@ -583,20 +583,20 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.11.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/wasi-threads": "1.2.2", "@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.11.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -604,9 +604,9 @@
} }
}, },
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.2", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -1866,9 +1866,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@malio/layer-ui": { "node_modules/@malio/layer-ui": {
"version": "1.7.7", "version": "1.7.4",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.7/layer-ui-1.7.7.tgz", "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.4/layer-ui-1.7.4.tgz",
"integrity": "sha512-MLHDtOzUxcCwIBGWj4FcUMLQTExtGD29uLvpU+IA6qr7gCj9kZ9fGZDu76LXxuJJdfBwzZmenuZioE7Z1qQUUw==", "integrity": "sha512-JNXwBelj5UQ35Qv5VmnassXKt8niX9jDXjM1vUSukJQiyeUXRxAiZr16QumVgBN9P9YGDyjXVKrwCHltTXvPtQ==",
"dependencies": { "dependencies": {
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
+1 -1
View File
@@ -17,7 +17,7 @@
"test:e2e:ui": "playwright test --ui" "test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.7.7", "@malio/layer-ui": "^1.7.4",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
@@ -23,7 +23,7 @@
</thead> </thead>
<tbody> <tbody>
<tr v-for="(diff, field) in updateDiff" :key="field" class="border-t border-gray-200"> <tr v-for="(diff, field) in updateDiff" :key="field" class="border-t border-gray-200">
<td class="px-2 py-1">{{ field }}</td> <td class="px-2 py-1 font-mono">{{ field }}</td>
<td class="px-2 py-1 text-red-700">{{ formatValue(diff.old) }}</td> <td class="px-2 py-1 text-red-700">{{ formatValue(diff.old) }}</td>
<td class="px-2 py-1 text-green-700">{{ formatValue(diff.new) }}</td> <td class="px-2 py-1 text-green-700">{{ formatValue(diff.new) }}</td>
</tr> </tr>
@@ -31,7 +31,7 @@
{ added: [ids], removed: [ids] } affiche + et - sur { added: [ids], removed: [ids] } affiche + et - sur
la meme ligne pour garder une colonne field unique. --> la meme ligne pour garder une colonne field unique. -->
<tr v-for="(diff, field) in collectionDiff" :key="`col-${field}`" class="border-t border-gray-200"> <tr v-for="(diff, field) in collectionDiff" :key="`col-${field}`" class="border-t border-gray-200">
<td class="px-2 py-1">{{ field }}</td> <td class="px-2 py-1 font-mono">{{ field }}</td>
<td class="px-2 py-1 text-red-700"> <td class="px-2 py-1 text-red-700">
<span v-if="diff.removed.length"> {{ diff.removed.join(', ') }}</span> <span v-if="diff.removed.length"> {{ diff.removed.join(', ') }}</span>
<span v-else class="text-gray-400"></span> <span v-else class="text-gray-400"></span>
@@ -47,7 +47,7 @@
<div v-else class="space-y-1"> <div v-else class="space-y-1">
<div v-for="(value, key) in entry.changes" :key="key" class="flex gap-2"> <div v-for="(value, key) in entry.changes" :key="key" class="flex gap-2">
<span class="text-xs text-gray-600">{{ key }}:</span> <span class="font-mono text-xs text-gray-600">{{ key }}:</span>
<span class="text-xs">{{ formatValue(value) }}</span> <span class="text-xs">{{ formatValue(value) }}</span>
</div> </div>
</div> </div>
+3 -8
View File
@@ -1,13 +1,8 @@
<template> <template>
<!-- Entete de page standard : source unique du style des titres. <!-- Entete de page standard : source unique du style des titres.
Slot par defaut = texte du titre, slot #actions = boutons a droite. Slot par defaut = texte du titre, slot #actions = boutons a droite. -->
Sticky en haut du <main> scrollable : reste visible au scroll. Fond blanc <div class="mb-[44px] flex items-center justify-between gap-4">
+ pt-[38px]/pb-[30px] (au lieu de marges) pour que le contenu defilant soit <h1 class="text-[32px] font-semibold text-primary-500">
masque sous l'entete (espaces haut ET bas compris) et que l'entete soit
collee sous le SiteSelector sans trou. pt = marge haute (38px),
pb = espace titre -> contenu (30px). z-20 < drawers/modales. -->
<div class="sticky top-0 z-20 flex items-center justify-between gap-4 bg-white pt-[38px] pb-[30px]">
<h1 class="text-[30px] font-semibold text-primary-500">
<slot/> <slot/>
</h1> </h1>
<div v-if="$slots.actions" class="shrink-0"> <div v-if="$slots.actions" class="shrink-0">
@@ -1,33 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest'
import { readHistoryTab } from '../historyTab'
const KEYS = ['information', 'contact', 'address', 'accounting']
describe('readHistoryTab', () => {
afterEach(() => {
window.history.replaceState(null, '')
})
it('retourne la cle d\'onglet quand elle est presente et valide', () => {
window.history.replaceState({ tab: 'address' }, '')
expect(readHistoryTab(KEYS)).toBe('address')
})
it('retourne null quand l\'onglet n\'est pas dans les cles valides (ex: role sans Comptabilite)', () => {
window.history.replaceState({ tab: 'accounting' }, '')
expect(readHistoryTab(['information', 'contact', 'address'])).toBeNull()
})
it('retourne null sans onglet dans l\'etat d\'historique (navigation directe / refresh)', () => {
window.history.replaceState(null, '')
expect(readHistoryTab(KEYS)).toBeNull()
window.history.replaceState({ foo: 'bar' }, '')
expect(readHistoryTab(KEYS)).toBeNull()
})
it('retourne null quand la valeur n\'est pas une chaine', () => {
window.history.replaceState({ tab: 42 }, '')
expect(readHistoryTab(KEYS)).toBeNull()
})
})
-22
View File
@@ -1,22 +0,0 @@
/**
* Onglet actif transmis d'une page a l'autre via l'etat d'historique
* (`history.state`), SANS le mettre dans l'URL. Sert a preserver l'onglet courant
* au passage consultation <-> edition d'un client (dans les deux sens).
*
* On reste donc fidele a la regle « etat d'UI local, pas dans l'URL » : l'onglet
* voyage dans l'entree d'historique de la navigation, l'URL ne change pas.
*/
/**
* Lit la cle d'onglet posee par la page precedente (`history.state.tab`) si elle
* fait partie des onglets valides pour l'utilisateur. Retourne `null` sinon :
* navigation directe / deep link, rechargement de page, ou onglet inexistant
* pour ce role (ex: Comptabilite sans la permission).
*/
export function readHistoryTab(validKeys: string[]): string | null {
if (typeof window === 'undefined') {
return null
}
const tab = (window.history.state as Record<string, unknown> | null)?.tab
return typeof tab === 'string' && validKeys.includes(tab) ? tab : null
}
+2 -4
View File
@@ -207,8 +207,7 @@ migration-migrate:
# orphelins du mapping ORM. Les index partiels (LOWER + WHERE) ne sont pas # orphelins du mapping ORM. Les index partiels (LOWER + WHERE) ne sont pas
# exprimables via les attributs Doctrine ORM (fonctionnel + partiel), donc # exprimables via les attributs Doctrine ORM (fonctionnel + partiel), donc
# ils disparaissent apres schema:update. On les recree par dbal:run-sql : # ils disparaissent apres schema:update. On les recree par dbal:run-sql :
# - `uq_category_name_active` (M0 Catalog) : unicite GLOBALE du nom parmi # - `uq_category_name_type_active` (M0 Catalog) : tests RG-1.07.
# les actifs (M:N categorie<->type), tests RG-1.07.
# - `uq_category_code` (Catalog ERP-78) : unicite du code categorie parmi # - `uq_category_code` (Catalog ERP-78) : unicite du code categorie parmi
# les actifs (slug du nom), pilote RG-1.03/1.29. # les actifs (slug du nom), pilote RG-1.03/1.29.
# - `uq_client_company_name_active` (M1 Commercial) : unicite nom societe # - `uq_client_company_name_active` (M1 Commercial) : unicite nom societe
@@ -227,10 +226,9 @@ test-db-setup:
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load $(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:sync-permissions
$(SYMFONY_CONSOLE) --env=test --no-interaction app:seed-rbac $(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_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_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_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_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: fixtures:
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load $(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
+7 -7
View File
@@ -256,13 +256,13 @@ final class Version20260601000000 extends AbstractMigration
$this->comment('client', 'distributor_id', 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.'); $this->comment('client', 'distributor_id', 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.');
$this->comment('client', 'broker_id', 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.'); $this->comment('client', 'broker_id', 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.');
$this->comment('client', 'triage_service', 'Drapeau service triage active pour le client. Faux par defaut.'); $this->comment('client', 'triage_service', 'Drapeau service triage active pour le client. Faux par defaut.');
$this->comment('client', 'description', 'Onglet Information : description libre. Facultatif.'); $this->comment('client', 'description', 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.');
$this->comment('client', 'competitors', 'Onglet Information : concurrents identifies (texte libre ≤ 255). Facultatif.'); $this->comment('client', 'competitors', 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'founded_at', 'Onglet Information : date de creation de l entreprise. Facultatif.'); $this->comment('client', 'founded_at', 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'employees_count', 'Onglet Information : effectif (entier >= 0). Facultatif.'); $this->comment('client', 'employees_count', 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'revenue_amount', 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Facultatif.'); $this->comment('client', 'revenue_amount', 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'director_name', 'Onglet Information : nom du dirigeant. Facultatif.'); $this->comment('client', 'director_name', 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'profit_amount', 'Onglet Information : resultat / benefice (NUMERIC 15,2). Facultatif.'); $this->comment('client', 'profit_amount', 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'siren', 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).'); $this->comment('client', 'siren', 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).');
$this->comment('client', 'account_number', 'Onglet Comptabilite : numero de compte comptable du client.'); $this->comment('client', 'account_number', 'Onglet Comptabilite : numero de compte comptable du client.');
$this->comment('client', 'tva_mode_id', 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.'); $this->comment('client', 'tva_mode_id', 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.');
+8 -8
View File
@@ -82,14 +82,14 @@ final class Version20260605130000 extends AbstractMigration
// Ordre inverse des dependances FK : jointures et sous-collections // Ordre inverse des dependances FK : jointures et sous-collections
// d'abord, puis supplier. Les referentiels comptables et le // d'abord, puis supplier. Les referentiels comptables et le
// CategoryType FOURNISSEUR ne sont pas touches (crees ailleurs). // CategoryType FOURNISSEUR ne sont pas touches (crees ailleurs).
$this->addSql('DROP TABLE IF EXISTS supplier_address_category'); $this->addSql('DROP TABLE supplier_address_category');
$this->addSql('DROP TABLE IF EXISTS supplier_address_contact'); $this->addSql('DROP TABLE supplier_address_contact');
$this->addSql('DROP TABLE IF EXISTS supplier_address_site'); $this->addSql('DROP TABLE supplier_address_site');
$this->addSql('DROP TABLE IF EXISTS supplier_rib'); $this->addSql('DROP TABLE supplier_rib');
$this->addSql('DROP TABLE IF EXISTS supplier_address'); $this->addSql('DROP TABLE supplier_address');
$this->addSql('DROP TABLE IF EXISTS supplier_contact'); $this->addSql('DROP TABLE supplier_contact');
$this->addSql('DROP TABLE IF EXISTS supplier_category'); $this->addSql('DROP TABLE supplier_category');
$this->addSql('DROP TABLE IF EXISTS supplier'); $this->addSql('DROP TABLE supplier');
} }
// ================================================================= // =================================================================
-149
View File
@@ -1,149 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Catalog Category multi-types : passage de la relation Category -> 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,
));
}
}
+20 -54
View File
@@ -19,18 +19,14 @@ use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
/** /**
* Categorie : referentiel metier classifiant les futurs tiers (clients, * Categorie : referentiel metier classifiant les futurs tiers (clients,
* fournisseurs, prestataires). Porte un `name` libre et un ou plusieurs * fournisseurs, prestataires). Porte un `name` libre et un `categoryType`
* `categoryTypes` (ManyToMany vers le referentiel statique CategoryType, * (FK 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 * - Soft delete via `deletedAt` (pas de hard delete) : la liste exclut par
* defaut les categories supprimees (cf. CategoryProvider, ticket 0.3). * defaut les categories supprimees (cf. CategoryProvider, ticket 0.3).
@@ -85,11 +81,12 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)] #[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)]
#[ORM\Table(name: 'category')] #[ORM\Table(name: 'category')]
// Index nommes pour matcher la migration (cf. Role/Permission/Site). Les index // Index nommes pour matcher la migration (cf. Role/Permission/Site). Les index
// uniques partiels `uq_category_name_active` (LOWER(name) WHERE deleted_at IS // uniques partiels `uq_category_name_type_active` (LOWER(name), category_type_id
// NULL — unicite GLOBALE du nom parmi les actifs) et `uq_category_code` (code // WHERE deleted_at IS NULL) et `uq_category_code` (code WHERE deleted_at IS NULL)
// WHERE deleted_at IS NULL) restent possedes par la seule migration : Doctrine // restent possedes par la seule migration : Doctrine ORM ne sait pas exprimer un
// ORM ne sait pas exprimer un index partiel via attribut. // index partiel via attribut.
#[ORM\Index(name: 'idx_category_deleted_at', columns: ['deleted_at'])] #[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_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])] #[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])]
#[Auditable] #[Auditable]
@@ -129,21 +126,11 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
#[Groups(['category:read'])] #[Groups(['category:read'])]
private ?string $code = null; private ?string $code = null;
/** #[ORM\ManyToOne(targetEntity: CategoryType::class)]
* Types de la categorie (>= 1 obligatoire, RG-1.05). ManyToMany vers le #[ORM\JoinColumn(name: 'category_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
* referentiel statique CategoryType via la jonction `category_category_type`. #[Assert\NotNull(message: 'Type de catégorie obligatoire.')]
* Cote inverse (category_type) en ON DELETE RESTRICT : un type ne peut etre
* supprime tant qu'il reste reference par une categorie.
*
* @var Collection<int, CategoryType>
*/
#[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'])] #[Groups(['category:read', 'category:write'])]
private Collection $categoryTypes; private ?CategoryType $categoryType = null;
/** /**
* Soft delete : null = active, valeur = supprimee logiquement le {date}. * Soft delete : null = active, valeur = supprimee logiquement le {date}.
@@ -154,11 +141,6 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
#[Groups(['category:read'])] #[Groups(['category:read'])]
private ?DateTimeImmutable $deletedAt = null; private ?DateTimeImmutable $deletedAt = null;
public function __construct()
{
$this->categoryTypes = new ArrayCollection();
}
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -191,42 +173,26 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
return $this; return $this;
} }
/** public function getCategoryType(): ?CategoryType
* @return Collection<int, CategoryType>
*/
public function getCategoryTypes(): Collection
{ {
return $this->categoryTypes; return $this->categoryType;
} }
public function addCategoryType(CategoryType $categoryType): static public function setCategoryType(?CategoryType $categoryType): static
{ {
if (!$this->categoryTypes->contains($categoryType)) { $this->categoryType = $categoryType;
$this->categoryTypes->add($categoryType);
}
return $this;
}
public function removeCategoryType(CategoryType $categoryType): static
{
$this->categoryTypes->removeElement($categoryType);
return $this; return $this;
} }
/** /**
* Implemente CategoryInterface : liste des codes de types rattaches a la * Implemente CategoryInterface : code du type rattache (ou null). Permet
* categorie. Permet aux modules tiers de filtrer/valider par type metier * aux modules tiers de filtrer/valider par type metier sans dependre de
* (ex: RG-2.10 « contient FOURNISSEUR ») sans dependre de Catalog. * Catalog.
*
* @return list<string>
*/ */
public function getCategoryTypeCodes(): array public function getCategoryTypeCode(): ?string
{ {
return array_values(array_filter( return $this->categoryType?->getCode();
$this->categoryTypes->map(static fn (CategoryType $t): ?string => $t->getCode())->toArray(),
));
} }
public function getDeletedAt(): ?DateTimeImmutable public function getDeletedAt(): ?DateTimeImmutable
@@ -23,26 +23,10 @@ interface CategoryRepositoryInterface
/** /**
* Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut. * Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut.
* - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08) * - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08)
* - $typeCode non null : ne garde que les categories PORTANT ce code de type * - $typeCode non null : ne garde que les categories dont le CategoryType
* (filtre `?typeCode=`, ex. FOURNISSEUR / CLIENT). Sert au multi-select * porte ce code (filtre `?typeCode=`, ex. FOURNISSEUR / CLIENT). Sert au
* Categorie du fournisseur (M2, RG-2.10). * 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). * - 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<int> $typeIds
*/ */
public function createListQueryBuilder( public function createListQueryBuilder(bool $includeDeleted = false, ?string $typeCode = null): QueryBuilder;
bool $includeDeleted = false,
?string $typeCode = null,
?string $nameSearch = null,
array $typeIds = [],
): QueryBuilder;
} }
@@ -22,8 +22,8 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
* via CategoryCodeGenerator ; puis delegation au persist_processor Doctrine * via CategoryCodeGenerator ; puis delegation au persist_processor Doctrine
* ORM. Le code est FIGE a la creation (jamais recalcule sur PATCH). Toute * ORM. Le code est FIGE a la creation (jamais recalcule sur PATCH). Toute
* UniqueConstraintViolationException remontee par Postgres (collision sur * UniqueConstraintViolationException remontee par Postgres (collision sur
* l'index partiel uq_category_name_active unicite GLOBALE du nom parmi les * l'index partiel uq_category_name_type_active) est traduite en HTTP 409 avec
* actifs) est traduite en HTTP 409 avec le message attendu par la spec (RG-1.07). * le message attendu par la spec (RG-1.07).
* - DELETE : soft delete (RG-1.12). On NE delegue PAS au remove_processor ; * - 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 * on pose deletedAt = now() puis on delegue au persist_processor pour que
* le UPDATE Doctrine parte et que le TimestampableBlamableSubscriber mette * le UPDATE Doctrine parte et que le TimestampableBlamableSubscriber mette
@@ -78,12 +78,10 @@ final class CategoryProcessor implements ProcessorInterface
try { try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context); return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) { } catch (UniqueConstraintViolationException $e) {
// RG-1.07 : doublon de nom GLOBAL (LOWER(name)) parmi les non-soft-deleted // RG-1.07 : doublon (LOWER(name), category_type_id) 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( throw new HttpException(
409, 409,
sprintf('Une catégorie nommée "%s" existe déjà.', $data->getName() ?? ''), sprintf('Une catégorie nommée "%s" existe déjà pour ce type.', $data->getName() ?? ''),
$e, $e,
); );
} }
@@ -40,12 +40,7 @@ final class CategoryProvider implements ProviderInterface
$includeDeleted = $this->readIncludeDeleted($context); $includeDeleted = $this->readIncludeDeleted($context);
if ($operation instanceof CollectionOperationInterface) { if ($operation instanceof CollectionOperationInterface) {
$qb = $this->repository->createListQueryBuilder( $qb = $this->repository->createListQueryBuilder($includeDeleted, $this->readTypeCode($context));
$includeDeleted,
$this->readTypeCode($context),
$this->readNameSearch($context),
$this->readTypeIds($context),
);
// Echappatoire ?pagination=false : retourne la collection complete sans Paginator. // Echappatoire ?pagination=false : retourne la collection complete sans Paginator.
// Utile pour les drawers Role/Permission/Site/CategoryType qui alimentent un <select>. // Utile pour les drawers Role/Permission/Site/CategoryType qui alimentent un <select>.
@@ -120,48 +115,4 @@ final class CategoryProvider implements ProviderInterface
return '' === $raw ? null : $raw; return '' === $raw ? null : $raw;
} }
/**
* Lit le filtre `?name=` (recherche partielle sur le nom, liste admin).
* Renvoie la valeur trimmee ou null si absente / vide.
*/
private function readNameSearch(array $context): ?string
{
$raw = $context['filters']['name'] ?? null;
if (!is_string($raw)) {
return null;
}
$raw = trim($raw);
return '' === $raw ? null : $raw;
}
/**
* Lit le filtre `?typeId[]=` (liste admin) : ids des types coches (OR).
* Tolere une valeur scalaire unique (`?typeId=3`) ou un tableau. Ignore
* les entrees non numeriques.
*
* @return list<int>
*/
private function readTypeIds(array $context): array
{
$raw = $context['filters']['typeId'] ?? null;
if (null === $raw) {
return [];
}
$values = is_array($raw) ? $raw : [$raw];
$ids = [];
foreach ($values as $value) {
if (is_int($value) || (is_string($value) && ctype_digit($value))) {
$ids[] = (int) $value;
}
}
return array_values(array_unique($ids));
}
} }
@@ -138,7 +138,7 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
$category = new Category(); $category = new Category();
$category->setName($name); $category->setName($name);
$category->setCode($code); $category->setCode($code);
$category->addCategoryType($type); $category->setCategoryType($type);
$manager->persist($category); $manager->persist($category);
} }
} }
@@ -48,19 +48,9 @@ class DoctrineCategoryRepository extends ServiceEntityRepository implements Cate
return [] !== $qb->getQuery()->getResult(); return [] !== $qb->getQuery()->getResult();
} }
public function createListQueryBuilder( public function createListQueryBuilder(bool $includeDeleted = false, ?string $typeCode = null): QueryBuilder
bool $includeDeleted = false, {
?string $typeCode = null,
?string $nameSearch = null,
array $typeIds = [],
): QueryBuilder {
// Eager-load de la collection categoryTypes (ManyToMany) : embarquee a la
// serialisation -> on la fetch-joine pour eviter un N+1 par categorie. Le
// provider enveloppe la requete dans un Paginator(fetchJoinCollection: true),
// compatible avec ce fetch-join to-many.
$qb = $this->createQueryBuilder('c') $qb = $this->createQueryBuilder('c')
->leftJoin('c.categoryTypes', 'cte')
->addSelect('cte')
->orderBy('c.name', 'ASC') ->orderBy('c.name', 'ASC')
; ;
@@ -68,45 +58,16 @@ class DoctrineCategoryRepository extends ServiceEntityRepository implements Cate
$qb->andWhere('c.deletedAt IS NULL'); $qb->andWhere('c.deletedAt IS NULL');
} }
// Filtre `?typeCode=` : la categorie doit PORTER ce code de type (RG-2.10, // Filtre `?typeCode=` : jointure sur le CategoryType pour ne garder que
// multi-select fournisseur). Sous-requete EXISTS correlee pour ne PAS // les categories du type demande (ex. FOURNISSEUR). La jointure reste
// restreindre la collection eager-loadee `cte` (sinon les autres types de // compatible avec le Paginator ORM (fetchJoinCollection) du provider.
// la categorie disparaitraient du JSON) et eviter les lignes dupliquees.
if (null !== $typeCode) { if (null !== $typeCode) {
$sub = $this->getEntityManager()->createQueryBuilder() $qb->join('c.categoryType', 'ct')
->select('1') ->andWhere('ct.code = :typeCode')
->from(Category::class, 'c_tc')
->join('c_tc.categoryTypes', 'ct_tc')
->where('c_tc = c')
->andWhere('ct_tc.code = :typeCode')
;
$qb->andWhere($qb->expr()->exists($sub->getDQL()))
->setParameter('typeCode', $typeCode) ->setParameter('typeCode', $typeCode)
; ;
} }
// Filtre `?typeId[]=` (liste admin) : la categorie porte AU MOINS UN des
// types coches (OR). Meme strategie EXISTS correlee que `typeCode`.
if ([] !== $typeIds) {
$sub = $this->getEntityManager()->createQueryBuilder()
->select('1')
->from(Category::class, 'c_ti')
->join('c_ti.categoryTypes', 'ct_ti')
->where('c_ti = c')
->andWhere('ct_ti.id IN (:typeIds)')
;
$qb->andWhere($qb->expr()->exists($sub->getDQL()))
->setParameter('typeIds', $typeIds)
;
}
// Filtre `?name=` (liste admin) : recherche partielle case-insensitive.
if (null !== $nameSearch && '' !== $nameSearch) {
$qb->andWhere('LOWER(c.name) LIKE :nameSearch')
->setParameter('nameSearch', '%'.mb_strtolower($nameSearch).'%')
;
}
return $qb; return $qb;
} }
} }
@@ -16,9 +16,9 @@ use Symfony\Component\Validator\ConstraintViolationList;
* Type de reglement). La banque reste conditionnelle (RG-1.12) et les RIB aussi * Type de reglement). La banque reste conditionnelle (RG-1.12) et les RIB aussi
* (RG-1.13) : ils ne sont pas couverts ici. * (RG-1.13) : ils ne sont pas couverts ici.
* *
* Parti pris : colonnes nullable en base + validateur contextuel, plutot qu'un * Calque sur ClientInformationCompletenessValidator (RG-1.04) : colonnes nullable
* Assert\NotBlank sur l'entite (qui casserait le POST de l'onglet principal, * en base + validateur contextuel, plutot qu'un Assert\NotBlank sur l'entite (qui
* lequel n'envoie aucun champ comptable). * casserait le POST de l'onglet principal, lequel n'envoie aucun champ comptable).
* *
* Invoque par le ClientProcessor uniquement quand le payload porte les six champs * Invoque par le ClientProcessor uniquement quand le payload porte les six champs
* (= une validation d'onglet), jamais sur un PATCH ciblant un seul champ comptable. * (= une validation d'onglet), jamais sur un PATCH ciblant un seul champ comptable.
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Application\Validator;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Domain\Entity\Client;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Validator metier RG-1.04 (durcie ERP-74) : pour un utilisateur portant le
* role metier Commerciale, TOUS les champs de l'onglet Information sont
* obligatoires sur POST comme sur tout PATCH, independamment des champs
* reellement envoyes.
*
* Invoque par le ClientProcessor des que l'utilisateur courant porte le role
* Commerciale (plus de condition d'intersection avec l'onglet Information).
* Pour les autres roles, ces champs restent optionnels le validator n'est
* pas appele.
*
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, par
* coherence avec les violations Symfony rendues par API Platform.
*/
final class ClientInformationCompletenessValidator
{
public function validate(Client $client): void
{
// Map champ -> valeur courante de l'onglet Information.
$fields = [
'description' => $client->getDescription(),
'competitors' => $client->getCompetitors(),
'foundedAt' => $client->getFoundedAt(),
'employeesCount' => $client->getEmployeesCount(),
'revenueAmount' => $client->getRevenueAmount(),
'directorName' => $client->getDirectorName(),
'profitAmount' => $client->getProfitAmount(),
];
$violations = new ConstraintViolationList();
foreach ($fields as $property => $value) {
if ($this->isMissing($value)) {
$violations->add(new ConstraintViolation(
sprintf('Ce champ est obligatoire pour le role Commerciale (champ "%s").', $property),
null,
[],
$client,
$property,
$value,
));
}
}
if (count($violations) > 0) {
throw new ValidationException($violations);
}
}
/**
* Une valeur est manquante si null ou, pour une chaine, vide apres trim.
* Les zeros numeriques (employeesCount = 0, profitAmount = "0.00") sont des
* valeurs valides : on ne les considere pas manquants.
*/
private function isMissing(mixed $value): bool
{
if (null === $value) {
return true;
}
return is_string($value) && '' === trim($value);
}
}
@@ -10,7 +10,7 @@ use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList; use Symfony\Component\Validator\ConstraintViolationList;
/** /**
* Validator metier RG-2.03 (completude Information cote fournisseur) : * Validator metier RG-2.03 (jumeau du ClientInformationCompletenessValidator M1) :
* pour un utilisateur portant le role metier Commerciale, TOUS les champs de * pour un utilisateur portant le role metier Commerciale, TOUS les champs de
* l'onglet Information sont obligatoires sur POST comme sur tout PATCH, * l'onglet Information sont obligatoires sur POST comme sur tout PATCH,
* independamment des champs reellement envoyes. * independamment des champs reellement envoyes.
@@ -47,10 +47,7 @@ final class SupplierInformationCompletenessValidator
foreach ($fields as $property => $value) { foreach ($fields as $property => $value) {
if ($this->isMissing($value)) { if ($this->isMissing($value)) {
$violations->add(new ConstraintViolation( $violations->add(new ConstraintViolation(
// Pas de nom de champ technique dans le message : la violation est sprintf('Ce champ est obligatoire pour le rôle Commerciale (champ "%s").', $property),
// deja rattachee au bon champ via son propertyPath (mappe inline
// cote front par useFormErrors).
'Ce champ est obligatoire pour le rôle Commerciale.',
null, null,
[], [],
$supplier, $supplier,
+4 -5
View File
@@ -20,8 +20,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
* permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de * permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de
* Timestampable/Blamable (referentiel statique whiteliste dans * Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe * EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
* `client:read:accounting` permet l'embarquement dans la reponse Client ; * `client:read:accounting` permet l'embarquement dans la reponse Client.
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 § 4.0).
*/ */
#[ApiResource( #[ApiResource(
operations: [ operations: [
@@ -48,15 +47,15 @@ class Bank
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])] #[Groups(['bank:read', 'client:read:accounting'])]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 30)] #[ORM\Column(length: 30)]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])] #[Groups(['bank:read', 'client:read:accounting'])]
private ?string $code = null; private ?string $code = null;
#[ORM\Column(length: 120)] #[ORM\Column(length: 120)]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])] #[Groups(['bank:read', 'client:read:accounting'])]
private ?string $label = null; private ?string $label = null;
#[ORM\Column(options: ['default' => 0])] #[ORM\Column(options: ['default' => 0])]
@@ -188,7 +188,7 @@ class Client implements TimestampableInterface, BlamableInterface
private ?string $description = null; private ?string $description = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'Ce champ ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client:read', 'client:write:information'])] #[Groups(['client:read', 'client:write:information'])]
private ?string $competitors = null; private ?string $competitors = null;
@@ -20,8 +20,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
* permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de * permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de
* Timestampable/Blamable (referentiel statique whiteliste dans * Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe * EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
* `client:read:accounting` permet l'embarquement dans la reponse Client ; * `client:read:accounting` permet l'embarquement dans la reponse Client.
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 § 4.0).
*/ */
#[ApiResource( #[ApiResource(
operations: [ operations: [
@@ -48,15 +47,15 @@ class PaymentDelay
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])] #[Groups(['payment_delay:read', 'client:read:accounting'])]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 30)] #[ORM\Column(length: 30)]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])] #[Groups(['payment_delay:read', 'client:read:accounting'])]
private ?string $code = null; private ?string $code = null;
#[ORM\Column(length: 120)] #[ORM\Column(length: 120)]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])] #[Groups(['payment_delay:read', 'client:read:accounting'])]
private ?string $label = null; private ?string $label = null;
#[ORM\Column(options: ['default' => 0])] #[ORM\Column(options: ['default' => 0])]
@@ -23,8 +23,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
* permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de * permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de
* Timestampable/Blamable (referentiel statique whiteliste dans * Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe * EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
* `client:read:accounting` permet l'embarquement dans la reponse Client ; * `client:read:accounting` permet l'embarquement dans la reponse Client.
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 § 4.0).
*/ */
#[ApiResource( #[ApiResource(
operations: [ operations: [
@@ -51,15 +50,15 @@ class PaymentType
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])] #[Groups(['payment_type:read', 'client:read:accounting'])]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 30)] #[ORM\Column(length: 30)]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])] #[Groups(['payment_type:read', 'client:read:accounting'])]
private ?string $code = null; private ?string $code = null;
#[ORM\Column(length: 120)] #[ORM\Column(length: 120)]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])] #[Groups(['payment_type:read', 'client:read:accounting'])]
private ?string $label = null; private ?string $label = null;
#[ORM\Column(options: ['default' => 0])] #[ORM\Column(options: ['default' => 0])]
@@ -135,9 +135,9 @@ class Supplier implements TimestampableInterface, BlamableInterface
use TimestampableBlamableTrait; use TimestampableBlamableTrait;
/** /**
* RG-2.10 : seules les categories PORTANT ce type sont autorisees sur le * RG-2.10 : seules les categories de ce type sont autorisees sur le
* fournisseur (entite principale). Miroir de SupplierAddress (ERP-88). * fournisseur (entite principale). Miroir de SupplierAddress (ERP-88).
* S'appuie sur CategoryInterface::getCategoryTypeCodes() (pas d'import du * S'appuie sur CategoryInterface::getCategoryTypeCode() (pas d'import du
* module Catalog regle ABSOLUE n°1). * module Catalog regle ABSOLUE n°1).
*/ */
private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR'; private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
@@ -181,7 +181,7 @@ class Supplier implements TimestampableInterface, BlamableInterface
private ?string $description = null; private ?string $description = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'Ce champ ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['supplier:read', 'supplier:write:information'])] #[Groups(['supplier:read', 'supplier:write:information'])]
private ?string $competitors = null; private ?string $competitors = null;
@@ -300,17 +300,16 @@ class Supplier implements TimestampableInterface, BlamableInterface
* FOURNISSEUR -> sinon 422 avec violation sur le champ `categories` * FOURNISSEUR -> sinon 422 avec violation sur le champ `categories`
* (propertyPath aligne ERP-101, message FR ERP-107). Miroir de * (propertyPath aligne ERP-101, message FR ERP-107). Miroir de
* SupplierAddress::validateCategoryType (ERP-88). S'appuie sur * SupplierAddress::validateCategoryType (ERP-88). S'appuie sur
* CategoryInterface::getCategoryTypeCodes() (multi-type la categorie est * CategoryInterface::getCategoryTypeCode() (pas d'import du module Catalog
* acceptee des qu'elle PORTE le type FOURNISSEUR ; pas d'import du module * regle ABSOLUE n°1). Joue avant la base via la validation API Platform, sur
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API * POST (categories supplier:write:main) comme sur PATCH.
* Platform, sur POST (categories supplier:write:main) comme sur PATCH.
*/ */
#[Assert\Callback] #[Assert\Callback]
public function validateCategoryType(ExecutionContextInterface $context): void public function validateCategoryType(ExecutionContextInterface $context): void
{ {
foreach ($this->categories as $category) { foreach ($this->categories as $category) {
if ($category instanceof CategoryInterface if ($category instanceof CategoryInterface
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) { && self::REQUIRED_CATEGORY_TYPE_CODE !== $category->getCategoryTypeCode()) {
$context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).') $context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).')
->atPath('categories') ->atPath('categories')
->addViolation() ->addViolation()
@@ -108,9 +108,9 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
public const array ADDRESS_TYPES = ['PROSPECT', 'DEPART', 'RENDU']; public const array ADDRESS_TYPES = ['PROSPECT', 'DEPART', 'RENDU'];
/** /**
* RG-2.10 : seules les categories PORTANT ce type sont autorisees sur une * RG-2.10 : seules les categories de ce type sont autorisees sur une adresse
* adresse fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCodes() * fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCode() (pas
* (pas d'import du module Catalog regle ABSOLUE n°1). * d'import du module Catalog regle ABSOLUE n°1).
*/ */
private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR'; private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
@@ -219,16 +219,15 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
* RG-2.10 : toute categorie posee sur une adresse fournisseur doit etre de * RG-2.10 : toute categorie posee sur une adresse fournisseur doit etre de
* type FOURNISSEUR -> sinon 422 avec violation sur le champ `categories` * type FOURNISSEUR -> sinon 422 avec violation sur le champ `categories`
* (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur * (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur
* CategoryInterface::getCategoryTypeCodes() (multi-type la categorie est * CategoryInterface::getCategoryTypeCode() (pas d'import du module Catalog
* acceptee des qu'elle PORTE le type FOURNISSEUR ; pas d'import du module * regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
*/ */
#[Assert\Callback] #[Assert\Callback]
public function validateCategoryType(ExecutionContextInterface $context): void public function validateCategoryType(ExecutionContextInterface $context): void
{ {
foreach ($this->categories as $category) { foreach ($this->categories as $category) {
if ($category instanceof CategoryInterface if ($category instanceof CategoryInterface
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) { && self::REQUIRED_CATEGORY_TYPE_CODE !== $category->getCategoryTypeCode()) {
$context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).') $context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).')
->atPath('categories') ->atPath('categories')
->addViolation() ->addViolation()
@@ -24,8 +24,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
* Referentiel statique : pas de Timestampable/Blamable (whiteliste dans * Referentiel statique : pas de Timestampable/Blamable (whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le * EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le
* groupe `client:read:accounting` permet d'embarquer le mode dans la reponse * groupe `client:read:accounting` permet d'embarquer le mode dans la reponse
* d'un Client (onglet Comptabilite) au lieu d'un IRI ; `supplier:read:accounting` * d'un Client (onglet Comptabilite) au lieu d'un IRI.
* fait de meme dans la reponse Fournisseur (M2, ERP-92 sinon IRI nu, § 4.0).
*/ */
#[ApiResource( #[ApiResource(
operations: [ operations: [
@@ -55,15 +54,15 @@ class TvaMode
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])] #[Groups(['tva_mode:read', 'client:read:accounting'])]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 30)] #[ORM\Column(length: 30)]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])] #[Groups(['tva_mode:read', 'client:read:accounting'])]
private ?string $code = null; private ?string $code = null;
#[ORM\Column(length: 120)] #[ORM\Column(length: 120)]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])] #[Groups(['tva_mode:read', 'client:read:accounting'])]
private ?string $label = null; private ?string $label = null;
#[ORM\Column(options: ['default' => 0])] #[ORM\Column(options: ['default' => 0])]
@@ -9,8 +9,11 @@ use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException; use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Application\Service\ClientFieldNormalizer; use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
use App\Module\Commercial\Application\Validator\ClientAccountingCompletenessValidator; use App\Module\Commercial\Application\Validator\ClientAccountingCompletenessValidator;
use App\Module\Commercial\Application\Validator\ClientInformationCompletenessValidator;
use App\Module\Commercial\Domain\Entity\Client; use App\Module\Commercial\Domain\Entity\Client;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Contract\CategoryInterface; use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Security\BusinessRoles;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@@ -41,8 +44,8 @@ use Symfony\Component\Validator\ConstraintViolationList;
* 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer. * 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer.
* 3. Regles metier : RG-1.03 (distributor/broker * 3. Regles metier : RG-1.03 (distributor/broker
* exclusifs + type de categorie), RG-1.12 (Virement -> banque), * exclusifs + type de categorie), RG-1.12 (Virement -> banque),
* RG-1.13 (LCR -> >= 1 RIB). L'onglet Information est entierement facultatif * RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information exigee sur POST
* (RG-1.04 retiree : plus d'obligation, y compris pour le role Commerciale). * et tout PATCH pour le role Commerciale).
* 4. Pose / retrait de archivedAt (RG-1.22 true=now, RG-1.23 false=null). * 4. Pose / retrait de archivedAt (RG-1.22 true=now, RG-1.23 false=null).
* 5. Persistance via le persist_processor Doctrine, avec traduction des * 5. Persistance via le persist_processor Doctrine, avec traduction des
* collisions d'unicite en 409 (RG-1.16 doublon de nom ; RG-1.23 conflit de * collisions d'unicite en 409 (RG-1.16 doublon de nom ; RG-1.23 conflit de
@@ -105,6 +108,7 @@ final class ClientProcessor implements ProcessorInterface
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor, private readonly ProcessorInterface $persistProcessor,
private readonly ClientFieldNormalizer $normalizer, private readonly ClientFieldNormalizer $normalizer,
private readonly ClientInformationCompletenessValidator $informationValidator,
private readonly ClientAccountingCompletenessValidator $accountingValidator, private readonly ClientAccountingCompletenessValidator $accountingValidator,
private readonly Security $security, private readonly Security $security,
private readonly RequestStack $requestStack, private readonly RequestStack $requestStack,
@@ -117,12 +121,6 @@ final class ClientProcessor implements ProcessorInterface
return $this->persistProcessor->process($data, $operation, $uriVariables, $context); return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} }
// Reinitialisation de la memoisation du payload en debut de traitement :
// le service est partage (stateful), on repart du corps de LA requete
// courante et on n'herite jamais des cles decodees d'une requete passee.
$this->decodedContent = null;
$this->decodedPayloadKeys = [];
$writableKeys = $this->writablePayloadKeys(); $writableKeys = $this->writablePayloadKeys();
$isArchiveRequest = $this->guardArchive($data, $writableKeys); $isArchiveRequest = $this->guardArchive($data, $writableKeys);
@@ -138,6 +136,7 @@ final class ClientProcessor implements ProcessorInterface
$this->validateDistributorBroker($data); $this->validateDistributorBroker($data);
$this->validateAccountingConsistency($data); $this->validateAccountingConsistency($data);
$this->validateAccountingCompleteness($data); $this->validateAccountingCompleteness($data);
$this->validateInformationCompleteness($data);
try { try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context); return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
@@ -506,9 +505,9 @@ final class ClientProcessor implements ProcessorInterface
* ce n'est pas une validation d'onglet (edition ponctuelle preservee). bank / * ce n'est pas une validation d'onglet (edition ponctuelle preservee). bank /
* RIB restent geres par validateAccountingConsistency (RG-1.12 / RG-1.13). * RIB restent geres par validateAccountingConsistency (RG-1.12 / RG-1.13).
* *
* Colonnes nullable en base + validateur contextuel : un Assert\NotBlank sur * Colonnes nullable en base + validateur contextuel (meme parti que RG-1.04) :
* l'entite casserait le POST de l'onglet principal, qui n'envoie aucun champ * un Assert\NotBlank sur l'entite casserait le POST de l'onglet principal, qui
* comptable. * n'envoie aucun champ comptable.
*/ */
private function validateAccountingCompleteness(Client $data): void private function validateAccountingCompleteness(Client $data): void
{ {
@@ -521,6 +520,21 @@ final class ClientProcessor implements ProcessorInterface
$this->accountingValidator->validate($data); $this->accountingValidator->validate($data);
} }
/**
* RG-1.04 (durcie ERP-74) : si l'utilisateur porte le role metier
* Commerciale, TOUS les champs de l'onglet Information sont obligatoires sur
* POST comme sur TOUT PATCH independamment des champs reellement envoyes
* (plus de condition d'intersection avec INFORMATION_FIELDS). Garantit qu'un
* client cree/edite par une Commerciale ne reste jamais avec un onglet
* Information incomplet.
*/
private function validateInformationCompleteness(Client $data): void
{
if ($this->currentUserIsCommerciale()) {
$this->informationValidator->validate($data);
}
}
/** /**
* Vrai si au moins une categorie du client porte le code donne. S'appuie sur * Vrai si au moins une categorie du client porte le code donne. S'appuie sur
* CategoryInterface::getCode() (pas d'import de Category regle ABSOLUE n°1). * CategoryInterface::getCode() (pas d'import de Category regle ABSOLUE n°1).
@@ -536,12 +550,21 @@ final class ClientProcessor implements ProcessorInterface
return false; return false;
} }
private function currentUserIsCommerciale(): bool
{
$user = $this->security->getUser();
return $user instanceof BusinessRoleAwareInterface
&& $user->hasBusinessRole(BusinessRoles::COMMERCIALE);
}
/** /**
* Cles ecrivables effectivement presentes dans le payload : on retire les * Cles ecrivables effectivement presentes dans le payload : on retire les
* cles JSON-LD (@id, @context, @var...) et tout champ non rattache a un * cles JSON-LD (@id, @context, @var...) et tout champ non rattache a un
* groupe d'ecriture connu. C'est la base du 422 d'archivage (RG-1.22) * groupe d'ecriture connu. C'est la base du 422 d'archivage (RG-1.22) et du
* sans elles, un PATCH « representation complete » porteur de @id ferait * declenchement conditionnel de RG-1.04 sans elles, un PATCH
* croire a une modification multi-onglets. * « representation complete » porteur de @id ferait croire a une
* modification multi-onglets.
* *
* @return list<string> * @return list<string>
*/ */
@@ -114,12 +114,6 @@ final class SupplierProcessor implements ProcessorInterface
return $this->persistProcessor->process($data, $operation, $uriVariables, $context); return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} }
// Reinitialisation de la memoisation du payload en debut de traitement :
// le service est partage (stateful), on repart du corps de LA requete
// courante et on n'herite jamais des cles decodees d'une requete passee.
$this->decodedContent = null;
$this->decodedPayloadKeys = [];
$writableKeys = $this->writablePayloadKeys(); $writableKeys = $this->writablePayloadKeys();
$isArchiveRequest = $this->guardArchive($data, $writableKeys); $isArchiveRequest = $this->guardArchive($data, $writableKeys);
@@ -57,11 +57,6 @@ final class SupplierExportController
#[IsGranted('commercial.suppliers.view')] #[IsGranted('commercial.suppliers.view')]
public function __invoke(Request $request): Response public function __invoke(Request $request): Response
{ {
// Memes filtres d'archivage que la vue liste (SupplierProvider) pour que
// l'export reflete exactement ce que l'utilisateur voit a l'ecran :
// - includeArchived : inclut les archives en plus des actifs ;
// - archivedOnly : restreint aux seules archives (prioritaire, cf.
// createListQueryBuilder).
$includeArchived = $this->readBool($request->query->get('includeArchived')); $includeArchived = $this->readBool($request->query->get('includeArchived'));
$archivedOnly = $this->readBool($request->query->get('archivedOnly')); $archivedOnly = $this->readBool($request->query->get('archivedOnly'));
$search = $request->query->getString('search') ?: null; $search = $request->query->getString('search') ?: null;
@@ -234,7 +234,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
$this->addAddress($services, ['Chatellerault'], '86100', 'Châtellerault', '15 rue du Conseil', isDelivery: true); $this->addAddress($services, ['Chatellerault'], '86100', 'Châtellerault', '15 rue du Conseil', isDelivery: true);
} }
// === Onglet Information complet (exemple de fiche renseignee) === // === Onglet Information complet (RG-1.04) ===
[$holding, $isNew] = $this->ensureClient( [$holding, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Holding Premium Invest', companyName: 'Holding Premium Invest',
@@ -1,525 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\DataFixtures;
use App\Module\Catalog\Infrastructure\DataFixtures\CategoryFixtures;
use App\Module\Commercial\Application\Service\SupplierFieldNormalizer;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Commercial\Domain\Entity\SupplierAddress;
use App\Module\Commercial\Domain\Entity\SupplierContact;
use App\Module\Commercial\Domain\Entity\SupplierRib;
use App\Module\Commercial\Domain\Entity\TvaMode;
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SiteProviderInterface;
use DateTimeImmutable;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use RuntimeException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Fixtures dev/test du module Commercial : ~13 fournisseurs de demonstration
* couvrant l'ensemble des cas metier RG-2.xx du repertoire fournisseurs (M2),
* jumelles des fixtures Client (ERP-68). Theme metier : negoce / recyclage de
* metaux (d'ou les champs `bennes` et `triageProvider` sur les adresses).
*
* Cas pivots couverts (criteres d'acceptation ERP-112) :
* - reglement VIREMENT avec banque renseignee (RG-2.07) ;
* - reglement LCR avec 1 puis 2 RIB (RG-2.08) ; CHEQUE et NON_SOUMISE sans RIB ;
* - adresses multi-types PROSPECT / DEPART / RENDU (RG-2.09) et multi-sites
* (86 / 17 / 82, RG-2.06) ; bennes + prestataire de triage ;
* - 1 a 3 contacts dont un avec telephone secondaire et un nomme par le seul
* nom (RG-2.04) ;
* - 2 fournisseurs archives (isArchived + archivedAt) pour l'exclusion de la
* liste (RG-2.17) ;
* - mono et multi-categories de type FOURNISSEUR (RG-2.10) ;
* - onglet Information complet (dont volumeForecast, specifique fournisseur).
*
* Resolution inter-modules conforme a la regle n°1 (pas d'import direct) :
* - categories resolues via le contrat Shared CategoryInterface
* (resolve_target_entities -> Category) ;
* - sites resolus via le contrat Shared SiteProviderInterface.
*
* Normalisation : les valeurs sont fournies BRUTES (casse libre, telephones
* formates) et normalisees par SupplierFieldNormalizer avant persist, exactement
* comme le ferait le SupplierProcessor via l'API (companyName UPPERCASE,
* first/last Capitalize, telephones chiffres seuls, emails lowercase).
*
* Coherence gating comptable (RG-2.16) : les scalaires comptables (siren,
* tvaMode, paymentType, bank...) et les RIB ne sont visibles qu'avec
* accounting.view. Les donnees sont posees pour que les roles SANS cette
* permission (ex. Commerciale) ne voient pas de compta support des tests
* ERP-92 et du golden path front.
*
* Idempotence : lookup par companyName normalise (coherent avec l'index unique
* partiel uq_supplier_company_name_active). Un fournisseur deja present n'est pas
* reconstruit (ses sous-collections ne sont pas redupliquees). Rejouable sans
* doublon meme si le purger Doctrine est desactive.
*
* Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by
* restent null (« Systeme » cote front), c'est attendu. Les donnees respectent
* les CHECK BDD (chk_supplier_contact_name : firstName OU lastName ;
* chk_supplier_address_type : PROSPECT | DEPART | RENDU) ET la coherence des
* validators d'entite (RG-2.07/2.08 : VIREMENT => banque, LCR => >= 1 RIB).
*
* Depend de CategoryFixtures (categories FOURNISSEUR), SitesFixtures (sites) et
* CommercialReferentialFixtures (referentiels comptables REUTILISES de M1,
* aucune nouvelle table).
*
* Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`,
* la fixture ne charge rien : les tests seedent et nettoient leurs propres
* fournisseurs et comptent sur une table `supplier` vierge y injecter 13
* fournisseurs de demo casserait les comptages de liste et les cleanups. Meme
* garde-fou que ClientFixtures / CategoryFixtures.
*/
class SupplierFixtures extends Fixture implements DependentFixtureInterface
{
/**
* Type de categorie exige pour un fournisseur et ses adresses (RG-2.10).
* Miroir de Supplier::REQUIRED_CATEGORY_TYPE_CODE (non importable regle n°1).
*/
private const string SUPPLIER_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
/** Cache des categories resolues par nom (evite des requetes repetees). */
private array $categoryCache = [];
/** Cache des sites resolus par nom. */
private array $siteCache = [];
/** ObjectManager courant, capture en debut de load (resolution categories). */
private ObjectManager $manager;
public function __construct(
private readonly SupplierFieldNormalizer $normalizer,
private readonly SiteProviderInterface $siteProvider,
#[Autowire('%kernel.environment%')]
private readonly string $environment,
) {}
/**
* @return array<int, class-string>
*/
public function getDependencies(): array
{
return [
CategoryFixtures::class,
SitesFixtures::class,
CommercialReferentialFixtures::class,
];
}
public function load(ObjectManager $manager): void
{
// Donnees de demo : dev uniquement. En test, on laisse la table vierge.
if ('test' === $this->environment) {
return;
}
$this->manager = $manager;
// === Fournisseur basique — VIREMENT + banque (RG-2.07), compta complete ===
[$negoce, $isNew] = $this->ensureSupplier($manager, 'Négoce Métaux Atlantique', ['Négociant']);
if ($isNew) {
$negoce->setSiren('841611054');
$negoce->setAccountNumber('F0001');
$negoce->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$negoce->setNTva('FR12841611054');
$negoce->setPaymentDelay($this->paymentDelay($manager, 'J30'));
$negoce->setPaymentType($this->paymentType($manager, 'VIREMENT'));
$negoce->setBank($this->bank($manager, 'SG'));
$this->addContact($negoce, 'Jean', 'Dubois', 'Responsable achats', '05 49 00 00 01', null, 'jean.dubois@negoce-metaux.fr');
$this->addAddress($negoce, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '12 rue de la Ferraille', bennes: 4, triageProvider: true, categoryNames: ['Négociant']);
}
// === LCR avec 1 RIB (RG-2.08) + 2 contacts ===
[$coop, $isNew] = $this->ensureSupplier($manager, 'Coopérative Agricole du Sud-Ouest', ['Coopérative']);
if ($isNew) {
$coop->setSiren('775680459');
$coop->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$coop->setPaymentDelay($this->paymentDelay($manager, 'J15'));
$coop->setPaymentType($this->paymentType($manager, 'LCR'));
$this->addContact($coop, 'Sophie', 'Marchand', 'Directrice', '05 56 10 20 30', '06 11 22 33 44', 'sophie.marchand@coop-so.fr', 0);
$this->addContact($coop, 'Marc', 'Girard', 'Acheteur', '05 56 10 20 31', null, 'marc.girard@coop-so.fr', 1);
$this->addAddress($coop, 'RENDU', ['Pommevic'], '82400', 'Pommevic', '8 route des Cooperateurs', bennes: 2);
$this->addRib($coop, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0);
}
// === Prospect seul (adresse PROSPECT), compta minimale ===
[$producteur, $isNew] = $this->ensureSupplier($manager, 'Producteur Bio Charente', ['Producteur']);
if ($isNew) {
$this->addContact($producteur, 'Claire', 'Moreau', 'Gérante', '05 49 21 22 23', null, 'claire.moreau@bio-charente.fr');
$this->addAddress($producteur, 'PROSPECT', ['Saint-Jean'], '17400', 'Fontenet', '1 chemin des Producteurs');
}
// === Multi-categories M2M + LCR avec 2 RIB + 3 contacts ===
[$grossiste, $isNew] = $this->ensureSupplier($manager, 'Grossiste Multi-Métaux', ['Grossiste', 'Négociant']);
if ($isNew) {
$grossiste->setSiren('552081317');
$grossiste->setAccountNumber('F0004');
$grossiste->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$grossiste->setNTva('FR45552081317');
$grossiste->setPaymentDelay($this->paymentDelay($manager, 'J30'));
$grossiste->setPaymentType($this->paymentType($manager, 'LCR'));
$this->addContact($grossiste, 'Thomas', 'Petit', 'Directeur des achats', '05 56 31 32 33', '06 01 02 03 04', 'thomas.petit@grossiste-mm.fr', 0);
$this->addContact($grossiste, 'Julie', 'Roux', 'Assistante commerciale', '05 56 31 32 34', null, 'julie.roux@grossiste-mm.fr', 1);
$this->addContact($grossiste, 'Hélène', 'Faure', 'Logistique', '05 56 31 32 35', null, 'helene.faure@grossiste-mm.fr', 2);
$this->addAddress($grossiste, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '20 zone des Activités', streetComplement: 'Bâtiment C', bennes: 6, triageProvider: true, categoryNames: ['Grossiste', 'Négociant']);
$this->addRib($grossiste, 'Compte principal', 'BNPAFRPPXXX', 'FR7630006000011234567890189', 0);
$this->addRib($grossiste, 'Compte secondaire', 'SOGEFRPPXXX', 'FR7630001007941234567890185', 1);
}
// === VIREMENT + banque, TVA intracom (importateur), multi-sites sur l'adresse ===
[$import, $isNew] = $this->ensureSupplier($manager, 'Import Recyclage International', ['Importateur']);
if ($isNew) {
$import->setSiren('409512012');
$import->setTvaMode($this->tvaMode($manager, 'INTRACOM_VENTES'));
$import->setNTva('FR90409512012');
$import->setPaymentDelay($this->paymentDelay($manager, 'J30'));
$import->setPaymentType($this->paymentType($manager, 'VIREMENT'));
$import->setBank($this->bank($manager, 'CIC'));
$this->addContact($import, 'Paul', 'Garnier', 'Import manager', '05 56 44 55 66', null, 'paul.garnier@import-recyclage.fr', 0);
$this->addContact($import, null, 'Bernard', 'Douanes', '05 56 44 55 67', null, 'douanes@import-recyclage.fr', 1);
$this->addAddress($import, 'RENDU', ['Pommevic', 'Saint-Jean'], '82400', 'Pommevic', '3 quai des Importateurs', bennes: 8);
}
// === Multi-adresses PROSPECT / DEPART / RENDU (RG-2.09) + VIREMENT/banque ===
[$ferrailleur, $isNew] = $this->ensureSupplier($manager, 'Ferrailleur Grand Ouest', ['Négociant']);
if ($isNew) {
$ferrailleur->setSiren('732829320');
$ferrailleur->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$ferrailleur->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
$ferrailleur->setPaymentType($this->paymentType($manager, 'VIREMENT'));
$ferrailleur->setBank($this->bank($manager, 'CA'));
$this->addContact($ferrailleur, 'Olivier', 'Renard', 'Responsable site', '05 49 61 62 63', null, 'olivier.renard@ferrailleur-go.fr', 0);
$this->addContact($ferrailleur, 'Nadia', 'Benali', 'Pesée', '05 49 61 62 64', null, 'nadia.benali@ferrailleur-go.fr', 1);
// Prospect (site en cours de demarchage).
$this->addAddress($ferrailleur, 'PROSPECT', ['Chatellerault'], '86100', 'Châtellerault', '5 avenue de la Prospection', position: 0);
// Depart (collecte) multi-sites avec bennes + triage.
$this->addAddress($ferrailleur, 'DEPART', ['Saint-Jean', 'Pommevic'], '17400', 'Fontenet', '4 rue de la Collecte', bennes: 5, triageProvider: true, categoryNames: ['Négociant'], position: 1);
// Rendu (livraison).
$this->addAddress($ferrailleur, 'RENDU', ['Pommevic'], '82400', 'Pommevic', '7 boulevard du Rendu', bennes: 3, position: 2);
}
// === Onglet Information complet (dont volumeForecast) + VIREMENT/banque ===
[$holding, $isNew] = $this->ensureSupplier($manager, 'Holding Recyclage Premium', ['Importateur']);
if ($isNew) {
$holding->setDescription('Holding de recyclage diversifiée, présente sur le Grand Sud-Ouest.');
$holding->setCompetitors('Groupe Atlantique Recyclage, Sud Métaux');
$holding->setFoundedAt(new DateTimeImmutable('2008-09-01'));
$holding->setEmployeesCount(180);
$holding->setRevenueAmount('24500000.00');
$holding->setDirectorName('Antoine Lefèvre');
$holding->setProfitAmount('1850000.00');
$holding->setVolumeForecast(120000);
$holding->setSiren('318471925');
$holding->setAccountNumber('F0007');
$holding->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$holding->setNTva('FR33318471925');
$holding->setPaymentDelay($this->paymentDelay($manager, 'J30'));
$holding->setPaymentType($this->paymentType($manager, 'VIREMENT'));
$holding->setBank($this->bank($manager, 'SG'));
$this->addContact($holding, 'Antoine', 'Lefèvre', 'PDG', '05 56 51 52 53', null, 'antoine.lefevre@holding-recyclage.fr');
$this->addAddress($holding, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '1 allée des Investisseurs', bennes: 5, triageProvider: true, categoryNames: ['Importateur']);
}
// === Coop minimale — contact par le seul nom (RG-2.04), sans compta ===
[$coopMin, $isNew] = $this->ensureSupplier($manager, 'Coop Métaux Réunis', ['Coopérative']);
if ($isNew) {
$this->addContact($coopMin, null, 'Caron', 'Président', '05 49 81 82 83', null, 'president@coop-metaux-reunis.fr');
$this->addAddress($coopMin, 'DEPART', ['Saint-Jean'], '17400', 'Fontenet', '6 chemin du Village');
}
// === Reglement CHEQUE (sans banque ni RIB requis) ===
[$petit, $isNew] = $this->ensureSupplier($manager, 'Petit Négoce Local', ['Négociant']);
if ($isNew) {
$petit->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$petit->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
$petit->setPaymentType($this->paymentType($manager, 'CHEQUE'));
$this->addContact($petit, 'Luc', 'Martin', 'Gérant', '05 56 71 72 73', null, 'luc.martin@petit-negoce.fr');
$this->addAddress($petit, 'RENDU', ['Chatellerault'], '86100', 'Châtellerault', '15 rue du Commerce');
}
// === Reglement NON_SOUMISE + adresse multi-sites avec triage ===
[$recup, $isNew] = $this->ensureSupplier($manager, 'Récupération Métaux Express', ['Grossiste']);
if ($isNew) {
$recup->setSiren('490212019');
$recup->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$recup->setPaymentDelay($this->paymentDelay($manager, 'J15'));
$recup->setPaymentType($this->paymentType($manager, 'NON_SOUMISE'));
$this->addContact($recup, 'Marie', 'Lemoine', 'Responsable', '05 49 77 88 99', null, 'marie.lemoine@recup-express.fr', 0);
$this->addContact($recup, 'Pierre', 'Durand', 'Chauffeur', '05 49 77 88 98', null, 'pierre.durand@recup-express.fr', 1);
$this->addAddress($recup, 'DEPART', ['Saint-Jean', 'Chatellerault'], '17400', 'Fontenet', '10 zone industrielle', bennes: 7, triageProvider: true, categoryNames: ['Grossiste']);
}
// === Centre de tri — focus bennes/triage + multi-categories ===
[$centre, $isNew] = $this->ensureSupplier($manager, 'Centre de Tri Sud', ['Producteur', 'Coopérative']);
if ($isNew) {
$centre->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
$this->addContact($centre, 'Camille', 'Faure', 'Chef de centre', '05 56 91 92 93', null, 'camille.faure@centre-tri-sud.fr');
$this->addAddress($centre, 'DEPART', ['Pommevic'], '82400', 'Pommevic', '2 route du Tri', bennes: 12, triageProvider: true, categoryNames: ['Producteur']);
}
// === Fournisseur archive #1 (RG-2.17) ===
[$ancien, $isNew] = $this->ensureSupplier($manager, 'Ancien Fournisseur Fermé', ['Producteur'], isArchived: true);
if ($isNew) {
$this->addContact($ancien, null, 'Lambert', 'Ancien contact', '05 49 99 99 99', null, 'contact@ancien-fournisseur.fr');
$this->addAddress($ancien, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '99 rue Fermée');
}
// === Fournisseur archive #2 (RG-2.17) ===
[$disparu, $isNew] = $this->ensureSupplier($manager, 'Négoce Disparu', ['Grossiste'], isArchived: true);
if ($isNew) {
$this->addContact($disparu, 'Gérard', 'Blanc', 'Ex-gérant', '05 56 00 00 00', null, 'gerard.blanc@negoce-disparu.fr');
$this->addAddress($disparu, 'RENDU', ['Saint-Jean'], '17400', 'Fontenet', '0 impasse Oubliée');
}
$manager->flush();
}
/**
* Cree un fournisseur (base normalisee + categories de type FOURNISSEUR)
* s'il n'existe pas encore, sinon retourne l'existant. Retourne
* [Supplier, isNew] : isNew=false bloque la reconstruction des
* sous-collections (idempotence sans doublon).
*
* @param list<string> $categoryNames categories de type FOURNISSEUR (RG-2.10)
*
* @return array{0: Supplier, 1: bool}
*/
private function ensureSupplier(
ObjectManager $manager,
string $companyName,
array $categoryNames,
bool $isArchived = false,
): array {
$normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName);
$existing = $manager->getRepository(Supplier::class)->findOneBy(['companyName' => $normalizedName]);
if ($existing instanceof Supplier) {
return [$existing, false];
}
$supplier = new Supplier();
$supplier->setCompanyName($normalizedName);
foreach ($categoryNames as $categoryName) {
$supplier->addCategory($this->category($manager, $categoryName));
}
if ($isArchived) {
$supplier->setIsArchived(true);
$supplier->setArchivedAt(new DateTimeImmutable());
}
$manager->persist($supplier);
return [$supplier, true];
}
/**
* Ajoute un contact normalise au fournisseur (cascade persist via
* Supplier.contacts). Au moins firstName OU lastName est toujours fourni
* (RG-2.04, chk_supplier_contact_name).
*/
private function addContact(
Supplier $supplier,
?string $firstName,
?string $lastName,
?string $jobTitle,
?string $phonePrimary,
?string $phoneSecondary,
?string $email,
int $position = 0,
): void {
$contact = new SupplierContact();
$contact->setFirstName($this->normalizer->normalizePersonName($firstName));
$contact->setLastName($this->normalizer->normalizePersonName($lastName));
$contact->setJobTitle($jobTitle);
$contact->setPhonePrimary($this->normalizer->normalizePhone($phonePrimary));
$contact->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary));
$contact->setEmail($this->normalizer->normalizeEmail($email));
$contact->setPosition($position);
$supplier->addContact($contact);
}
/**
* Ajoute une adresse au fournisseur (cascade persist via Supplier.addresses).
* Le type d'adresse est exclusif (PROSPECT | DEPART | RENDU RG-2.09,
* chk_supplier_address_type) ; au moins un site est rattache (RG-2.06) ; les
* categories d'adresse sont de type FOURNISSEUR (RG-2.10).
*
* @param list<string> $siteNames au moins un site (RG-2.06)
* @param list<string> $categoryNames categories de type FOURNISSEUR (RG-2.10)
*/
private function addAddress(
Supplier $supplier,
string $addressType,
array $siteNames,
string $postalCode,
string $city,
string $street,
?string $streetComplement = null,
?int $bennes = null,
bool $triageProvider = false,
array $categoryNames = [],
int $position = 0,
): void {
$address = new SupplierAddress();
$address->setAddressType($addressType);
$address->setPostalCode($postalCode);
$address->setCity($city);
$address->setStreet($street);
$address->setStreetComplement($streetComplement);
$address->setBennes($bennes);
$address->setTriageProvider($triageProvider);
$address->setPosition($position);
foreach ($siteNames as $siteName) {
$address->addSite($this->site($siteName));
}
foreach ($categoryNames as $categoryName) {
$address->addCategory($this->category($this->manager, $categoryName));
}
$supplier->addAddress($address);
}
/**
* Ajoute un RIB au fournisseur (cascade persist via Supplier.ribs). IBAN/BIC
* valides (Assert\Iban/Bic non rejouee sur persist direct mais donnees
* coherentes pour le golden path / les tests).
*/
private function addRib(Supplier $supplier, string $label, string $bic, string $iban, int $position = 0): void
{
$rib = new SupplierRib();
$rib->setLabel($label);
$rib->setBic($bic);
$rib->setIban($iban);
$rib->setPosition($position);
$supplier->addRib($rib);
}
/**
* Resout une categorie par son nom via le contrat Shared CategoryInterface
* (resolve_target_entities -> Category), sans importer le module Catalog
* (regle n°1). Mise en cache par nom.
*/
private function category(ObjectManager $manager, string $name): CategoryInterface
{
if (isset($this->categoryCache[$name])) {
return $this->categoryCache[$name];
}
// RG-2.10 : on garde la categorie des qu'elle PORTE le type FOURNISSEUR
// (multi-type depuis le passage en ManyToMany). Le nom etant desormais
// unique GLOBALEMENT parmi les actifs, le lookup par `name` renvoie au
// plus une categorie, mais on conserve la verification du type pour
// ecarter un homonyme qui ne porterait pas FOURNISSEUR. Le filtre type
// est porte cote PHP (findBy ne sait pas filtrer la collection categoryTypes).
$candidates = $manager->getRepository(CategoryInterface::class)->findBy([
'name' => $name,
'deletedAt' => null,
]);
foreach ($candidates as $candidate) {
if ($candidate instanceof CategoryInterface
&& in_array(self::SUPPLIER_CATEGORY_TYPE_CODE, $candidate->getCategoryTypeCodes(), true)) {
return $this->categoryCache[$name] = $candidate;
}
}
throw new RuntimeException(sprintf(
'Categorie FOURNISSEUR "%s" introuvable : CategoryFixtures doit tourner avant SupplierFixtures.',
$name,
));
}
/**
* Resout un site par son nom via le contrat Shared SiteProviderInterface,
* sans importer le module Sites (regle n°1). Mise en cache par nom.
*/
private function site(string $name): SiteInterface
{
if (isset($this->siteCache[$name])) {
return $this->siteCache[$name];
}
$site = $this->siteProvider->findByName($name);
if (!$site instanceof SiteInterface) {
throw new RuntimeException(sprintf(
'Site "%s" introuvable : SitesFixtures doit tourner avant SupplierFixtures.',
$name,
));
}
return $this->siteCache[$name] = $site;
}
private function tvaMode(ObjectManager $manager, string $code): TvaMode
{
$mode = $manager->getRepository(TvaMode::class)->findOneBy(['code' => $code]);
if (!$mode instanceof TvaMode) {
throw new RuntimeException(sprintf(
'TvaMode "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
$code,
));
}
return $mode;
}
private function paymentDelay(ObjectManager $manager, string $code): PaymentDelay
{
$delay = $manager->getRepository(PaymentDelay::class)->findOneBy(['code' => $code]);
if (!$delay instanceof PaymentDelay) {
throw new RuntimeException(sprintf(
'PaymentDelay "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
$code,
));
}
return $delay;
}
private function paymentType(ObjectManager $manager, string $code): PaymentType
{
$type = $manager->getRepository(PaymentType::class)->findOneBy(['code' => $code]);
if (!$type instanceof PaymentType) {
throw new RuntimeException(sprintf(
'PaymentType "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
$code,
));
}
return $type;
}
private function bank(ObjectManager $manager, string $code): Bank
{
$bank = $manager->getRepository(Bank::class)->findOneBy(['code' => $code]);
if (!$bank instanceof Bank) {
throw new RuntimeException(sprintf(
'Bank "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
$code,
));
}
return $bank;
}
}
@@ -36,8 +36,8 @@ final class RbacSeeder
{ {
/** /**
* Codes des roles metier (snake_case, regex Role respectee). `commerciale` * Codes des roles metier (snake_case, regex Role respectee). `commerciale`
* reference la constante Shared pour eviter tout drift : un seul litteral * reference la constante Shared deja consommee par le ClientProcessor
* pour ce code. * (RG-1.04) pour eviter tout drift : un seul litteral pour ce code.
*/ */
public const string ROLE_BUREAU = 'bureau'; public const string ROLE_BUREAU = 'bureau';
public const string ROLE_COMPTA = 'compta'; public const string ROLE_COMPTA = 'compta';
+2 -2
View File
@@ -344,8 +344,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Busines
/** /**
* Implemente BusinessRoleAwareInterface : vrai si l'un des roles RBAC * Implemente BusinessRoleAwareInterface : vrai si l'un des roles RBAC
* rattaches porte le code donne. Permet aux modules tiers de detecter un * rattaches porte le code donne. Permet aux modules tiers de detecter un
* role metier (ex: `commerciale`) sans importer cette classe. Comparaison * role metier (ex: `commerciale` pour RG-1.04 du M1 Clients) sans importer
* stricte sur Role::code. * cette classe. Comparaison stricte sur Role::code.
*/ */
public function hasBusinessRole(string $roleCode): bool public function hasBusinessRole(string $roleCode): bool
{ {
@@ -10,6 +10,7 @@ namespace App\Shared\Domain\Contract;
* App\Shared\Domain\Security\BusinessRoles). * App\Shared\Domain\Security\BusinessRoles).
* *
* Implementee par App\Module\Core\Domain\Entity\User. Permet a un module tiers * Implementee par App\Module\Core\Domain\Entity\User. Permet a un module tiers
* (ex: Commercial RG-1.04, completude Information pour le role Commerciale)
* de raisonner sur les roles metier via Security::getUser() sans importer User * de raisonner sur les roles metier via Security::getUser() sans importer User
* (regle ABSOLUE n°1 : pas d'import inter-modules). * (regle ABSOLUE n°1 : pas d'import inter-modules).
* *
@@ -35,14 +35,10 @@ interface CategoryInterface
public function getCode(): ?string; public function getCode(): ?string;
/** /**
* Codes des types de categorie rattaches (CategoryType::code), tableau vide * Code du type de categorie rattache (CategoryType::code), ou null si la
* si aucun. Depuis le passage en ManyToMany, une categorie peut porter * categorie n'a pas de type. Depuis ERP-78, le modele n'a plus qu'un seul
* plusieurs types : un module tiers teste l'appartenance via * type (CLIENT) : le filtrage metier passe desormais par getCode() ci-dessus.
* `in_array($code, $category->getCategoryTypeCodes(), true)`. Pilote, cote * Conserve pour l'affichage / la retrocompatibilite.
* M2 Commercial, la RG-2.10 (une categorie de fournisseur doit etre de type
* FOURNISSEUR).
*
* @return list<string>
*/ */
public function getCategoryTypeCodes(): array; public function getCategoryTypeCode(): ?string;
} }
+6 -8
View File
@@ -10,11 +10,9 @@ namespace App\Shared\Domain\Security;
* Distincts des roles SYSTEME (cf. App\Module\Core\Domain\Security\SystemRoles : * Distincts des roles SYSTEME (cf. App\Module\Core\Domain\Security\SystemRoles :
* `admin` / `user`). Un role metier porte une intention fonctionnelle (poste de * `admin` / `user`). Un role metier porte une intention fonctionnelle (poste de
* travail) et conditionne certaines regles de gestion au-dela des permissions * travail) et conditionne certaines regles de gestion au-dela des permissions
* RBAC pures (Commerciale et Bureau partagent par ex. les memes permissions * RBAC pures ex : RG-1.04 du M1 Clients rend l'onglet Information obligatoire
* commercial.clients.view + manage mais peuvent porter des regles de gestion * pour le seul role Commerciale, alors que Commerciale et Bureau partagent les
* propres au poste). NB : l'ancienne RG-1.04 du M1 (« Information obligatoire * memes permissions (commercial.clients.view + manage, cf. spec-back M1 § 5.2).
* pour Commerciale ») a ete retiree l'onglet Information est facultatif pour
* tous ; la machinerie de role metier reste disponible pour de futures regles.
* *
* Ces constantes vivent dans Shared (et non dans un module) pour que : * Ces constantes vivent dans Shared (et non dans un module) pour que :
* - le seed des roles cote Core (ERP-74) reference le meme code sans importer * - le seed des roles cote Core (ERP-74) reference le meme code sans importer
@@ -26,14 +24,14 @@ namespace App\Shared\Domain\Security;
* Coordination stack M1 : * Coordination stack M1 :
* - ERP-74 seede le role `commerciale` avec ce code exact. * - ERP-74 seede le role `commerciale` avec ce code exact.
* - ERP-59 / ERP-60 (declaration RBAC + tests personas) le reutilisent. * - ERP-59 / ERP-60 (declaration RBAC + tests personas) le reutilisent.
* - ERP-55 le referencait pour la completude Information (M1 RG-1.04), regle * - ERP-55 (ici) ne fait que le REFERENCER : tant qu'aucun user ne porte le
* depuis retiree ; le code reste utilise par le seed RBAC et les personas. * role `commerciale`, la validation de completude Information reste dormante.
*/ */
final class BusinessRoles final class BusinessRoles
{ {
/** /**
* Role metier « Commerciale » code de Role RBAC (champ Role::code, * Role metier « Commerciale » code de Role RBAC (champ Role::code,
* snake_case impose par la regex Role). * snake_case impose par la regex Role). Conditionne RG-1.04.
*/ */
public const string COMMERCIALE = 'commerciale'; public const string COMMERCIALE = 'commerciale';
@@ -50,11 +50,12 @@ final class ColumnCommentsCatalog
], ],
'category' => [ '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).', '_table' => 'Categories M0 — referentiel type par category_type, soft-delete via deleted_at, unicite (LOWER(name), category_type_id) parmi les actifs.',
'id' => 'Identifiant interne auto-incremente.', 'id' => 'Identifiant interne auto-incremente.',
'name' => 'Libelle de la categorie (≤ 120 caracteres) — unique GLOBALEMENT parmi les actifs (RG-1.07, uq_category_name_active).', 'name' => 'Libelle de la categorie (≤ 120 caracteres) — unique par type parmi les actifs (RG-1.06).',
'code' => 'Code technique stable (slug MAJUSCULE du nom, ≤ 50) — unique parmi les actifs (uq_category_code). Fige a la creation. DISTRIBUTEUR/COURTIER pilotent RG-1.03/1.29.', 'code' => 'Code technique stable (slug MAJUSCULE du nom, ≤ 50) — unique parmi les actifs (uq_category_code). Fige a la creation. DISTRIBUTEUR/COURTIER pilotent RG-1.03/1.29.',
'deleted_at' => 'Horodatage UTC du soft-delete (archivage logique) — null si la categorie est active.', 'category_type_id' => 'Reference au type de la categorie — FK -> category_type.id, ON DELETE RESTRICT (un type ne peut etre supprime tant qu il a des categories).',
'deleted_at' => 'Horodatage UTC du soft-delete (archivage logique) — null si la categorie est active.',
] + self::timestampableBlamableComments(), ] + self::timestampableBlamableComments(),
'category_type' => [ 'category_type' => [
@@ -64,12 +65,6 @@ final class ColumnCommentsCatalog
'label' => 'Libelle affichable du type (FR, ≤ 120 caracteres).', 'label' => 'Libelle affichable du type (FR, ≤ 120 caracteres).',
], ],
'category_category_type' => [
'_table' => 'Jointure M2M category <-> category_type (Catalog) — types portes par la categorie, au moins un obligatoire (RG-1.05).',
'category_id' => 'FK -> category.id, ON DELETE CASCADE — categorie portant le type.',
'category_type_id' => 'FK -> category_type.id, ON DELETE RESTRICT — type rattache (un type ne peut etre supprime tant qu il reste reference).',
],
'permission' => [ 'permission' => [
'_table' => 'Referentiel des permissions RBAC — codes au format module.resource[.subresource].action, synchronise par app:sync-permissions.', '_table' => 'Referentiel des permissions RBAC — codes au format module.resource[.subresource].action, synchronise par app:sync-permissions.',
'id' => 'Identifiant interne auto-incremente.', 'id' => 'Identifiant interne auto-incremente.',
@@ -180,13 +175,13 @@ final class ColumnCommentsCatalog
'distributor_id' => 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.', 'distributor_id' => 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.',
'broker_id' => 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.', 'broker_id' => 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.',
'triage_service' => 'Drapeau service triage active pour le client. Faux par defaut.', 'triage_service' => 'Drapeau service triage active pour le client. Faux par defaut.',
'description' => 'Onglet Information : description libre. Facultatif.', 'description' => 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.',
'competitors' => 'Onglet Information : concurrents identifies (texte libre ≤ 255). Facultatif.', 'competitors' => 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).',
'founded_at' => 'Onglet Information : date de creation de l entreprise. Facultatif.', 'founded_at' => 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).',
'employees_count' => 'Onglet Information : effectif (entier >= 0). Facultatif.', 'employees_count' => 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).',
'revenue_amount' => 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Facultatif.', 'revenue_amount' => 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).',
'director_name' => 'Onglet Information : nom du dirigeant. Facultatif.', 'director_name' => 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).',
'profit_amount' => 'Onglet Information : resultat / benefice (NUMERIC 15,2). Facultatif.', 'profit_amount' => 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).',
'siren' => 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).', 'siren' => 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).',
'account_number' => 'Onglet Comptabilite : numero de compte comptable du client.', 'account_number' => 'Onglet Comptabilite : numero de compte comptable du client.',
'tva_mode_id' => 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.', 'tva_mode_id' => 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.',
@@ -70,17 +70,11 @@ abstract class AbstractCatalogApiTestCase extends AbstractApiTestCase
* cleanup. Si aucun type n'est fourni, un nouveau CategoryType est cree. * cleanup. Si aucun type n'est fourni, un nouveau CategoryType est cree.
* Le flag $deletedAt permet de seeder directement une categorie * Le flag $deletedAt permet de seeder directement une categorie
* soft-deleted (pour les tests RG-1.08 / RG-1.11). * soft-deleted (pour les tests RG-1.08 / RG-1.11).
*
* Multi-types (ManyToMany) : `$type` est le type principal (cree si null) ;
* `$additionalTypes` permet d'attacher d'autres types pour les cas multi.
*
* @param list<CategoryType> $additionalTypes
*/ */
protected function createCategory( protected function createCategory(
?string $name = null, ?string $name = null,
?CategoryType $type = null, ?CategoryType $type = null,
?DateTimeImmutable $deletedAt = null, ?DateTimeImmutable $deletedAt = null,
array $additionalTypes = [],
): Category { ): Category {
$em = $this->getEm(); $em = $this->getEm();
@@ -92,10 +86,7 @@ abstract class AbstractCatalogApiTestCase extends AbstractApiTestCase
// ERP-78 : code NOT NULL + unique parmi les actifs (uq_category_code). // ERP-78 : code NOT NULL + unique parmi les actifs (uq_category_code).
// Nonce aleatoire -> unicite garantie entre seeds successifs du test. // Nonce aleatoire -> unicite garantie entre seeds successifs du test.
$category->setCode('TEST_'.strtoupper($suffix)); $category->setCode('TEST_'.strtoupper($suffix));
$category->addCategoryType($type); $category->setCategoryType($type);
foreach ($additionalTypes as $additionalType) {
$category->addCategoryType($additionalType);
}
if (null !== $deletedAt) { if (null !== $deletedAt) {
$category->setDeletedAt($deletedAt); $category->setDeletedAt($deletedAt);
} }
@@ -57,7 +57,7 @@ final class CategoryAuditTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'audit_create', 'name' => self::TEST_CATEGORY_PREFIX.'audit_create',
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
]); ]);
self::assertSame(201, $response->getStatusCode()); self::assertSame(201, $response->getStatusCode());
@@ -139,7 +139,7 @@ final class CategoryAuditTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'audit_manager', 'name' => self::TEST_CATEGORY_PREFIX.'audit_manager',
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
]); ]);
self::assertSame(201, $response->getStatusCode()); self::assertSame(201, $response->getStatusCode());
@@ -26,7 +26,7 @@ final class CategoryCodeTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'Agro-alimentaire', 'name' => self::TEST_CATEGORY_PREFIX.'Agro-alimentaire',
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
]); ]);
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(201);
@@ -48,7 +48,7 @@ final class CategoryCodeTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'readonly', 'name' => self::TEST_CATEGORY_PREFIX.'readonly',
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
// Le client tente d'imposer un code : doit etre ignore. // Le client tente d'imposer un code : doit etre ignore.
'code' => 'CLIENT_FORGED', 'code' => 'CLIENT_FORGED',
], ],
@@ -65,13 +65,13 @@ final class CategoryCodeTest extends AbstractCatalogApiTestCase
$type = $this->createCategoryType(); $type = $this->createCategoryType();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
// Deux noms differents (donc autorises par uq_category_name_active) // Deux noms differents (donc autorises par uq_category_name_type_active)
// mais qui produisent le meme slug -> codes distincts (suffixe `_2`). // mais qui produisent le meme slug -> codes distincts (suffixe `_2`).
$first = $client->request('POST', '/api/categories', [ $first = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'Agro Plus', 'name' => self::TEST_CATEGORY_PREFIX.'Agro Plus',
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
])->toArray(); ])->toArray();
@@ -79,7 +79,7 @@ final class CategoryCodeTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'Agro-Plus', 'name' => self::TEST_CATEGORY_PREFIX.'Agro-Plus',
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
])->toArray(); ])->toArray();
@@ -1,137 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
/**
* Tests des filtres de la liste admin sur GET /api/categories :
* - `?name=` : recherche partielle case-insensitive sur le nom ;
* - `?typeId[]=` : categories portant AU MOINS UN des types coches (OR), sans
* doublon meme pour une categorie multi-types ;
* - combinaison `?name=` + `?typeId[]=` (ET entre filtres).
*
* @internal
*/
final class CategoryFilterTest extends AbstractCatalogApiTestCase
{
/**
* @param array<int, array<string, mixed>> $members
*
* @return list<string>
*/
private function testNames(array $members): array
{
$names = array_map(static fn (array $m): string => $m['name'], $members);
$names = array_values(array_filter(
$names,
fn (string $n): bool => str_starts_with($n, self::TEST_CATEGORY_PREFIX),
));
sort($names);
return $names;
}
public function testNameFilterIsPartialAndCaseInsensitive(): void
{
$type = $this->createCategoryType();
$this->createCategory(self::TEST_CATEGORY_PREFIX.'Acier inox', $type);
$this->createCategory(self::TEST_CATEGORY_PREFIX.'Aluminium', $type);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories?name=ACIER&pagination=false');
self::assertSame(200, $response->getStatusCode());
self::assertSame(
[self::TEST_CATEGORY_PREFIX.'Acier inox'],
$this->testNames($response->toArray()['member']),
'Le filtre ?name= doit etre partiel et insensible a la casse.',
);
}
public function testTypeIdFilterReturnsCategoriesWithAtLeastOneType(): void
{
$typeA = $this->createCategoryType();
$typeB = $this->createCategoryType();
$typeC = $this->createCategoryType();
$this->createCategory(self::TEST_CATEGORY_PREFIX.'only_a', $typeA);
$this->createCategory(self::TEST_CATEGORY_PREFIX.'only_b', $typeB);
$this->createCategory(self::TEST_CATEGORY_PREFIX.'only_c', $typeC);
$client = $this->createAdminClient();
$response = $client->request(
'GET',
sprintf('/api/categories?typeId[]=%d&typeId[]=%d&pagination=false', $typeA->getId(), $typeB->getId()),
);
self::assertSame(200, $response->getStatusCode());
self::assertSame(
[
self::TEST_CATEGORY_PREFIX.'only_a',
self::TEST_CATEGORY_PREFIX.'only_b',
],
$this->testNames($response->toArray()['member']),
'Le filtre ?typeId[]= doit remonter les categories portant AU MOINS UN des types (OR).',
);
}
public function testMultiTypeCategoryAppearsOnceWhenFilteredByOneType(): void
{
// Une categorie portant deux types ne doit pas etre dupliquee quand on
// filtre sur l'un de ses types (la sous-requete EXISTS evite les doublons).
$typeA = $this->createCategoryType();
$typeB = $this->createCategoryType();
$this->createCategory(
self::TEST_CATEGORY_PREFIX.'multi',
$typeA,
null,
[$typeB],
);
$client = $this->createAdminClient();
$response = $client->request(
'GET',
sprintf('/api/categories?typeId[]=%d&pagination=false', $typeA->getId()),
);
self::assertSame(200, $response->getStatusCode());
$members = $response->toArray()['member'];
self::assertSame(
[self::TEST_CATEGORY_PREFIX.'multi'],
$this->testNames($members),
'La categorie multi-types ne doit apparaitre qu une seule fois.',
);
// Les deux types restent embarques (la collection n'est pas tronquee).
$multi = array_values(array_filter(
$members,
fn (array $m): bool => $m['name'] === self::TEST_CATEGORY_PREFIX.'multi',
))[0];
self::assertCount(2, $multi['categoryTypes'], 'Les 2 types doivent rester embarques malgre le filtre.');
}
public function testNameAndTypeIdFiltersCombine(): void
{
$typeA = $this->createCategoryType();
$typeB = $this->createCategoryType();
$this->createCategory(self::TEST_CATEGORY_PREFIX.'steel_a', $typeA);
$this->createCategory(self::TEST_CATEGORY_PREFIX.'steel_b', $typeB);
$this->createCategory(self::TEST_CATEGORY_PREFIX.'wood_a', $typeA);
$client = $this->createAdminClient();
$response = $client->request(
'GET',
sprintf('/api/categories?name=steel&typeId[]=%d&pagination=false', $typeA->getId()),
);
self::assertSame(200, $response->getStatusCode());
self::assertSame(
[self::TEST_CATEGORY_PREFIX.'steel_a'],
$this->testNames($response->toArray()['member']),
'Les filtres ?name= et ?typeId[]= doivent se combiner (ET).',
);
}
}
@@ -66,7 +66,7 @@ final class CategoryPermissionsTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'forbidden', 'name' => self::TEST_CATEGORY_PREFIX.'forbidden',
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
]); ]);
@@ -81,7 +81,7 @@ final class CategoryPermissionsTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'anon', 'name' => self::TEST_CATEGORY_PREFIX.'anon',
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
]); ]);
@@ -96,7 +96,7 @@ final class CategoryPermissionsTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'admin_create', 'name' => self::TEST_CATEGORY_PREFIX.'admin_create',
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
]); ]);
@@ -112,7 +112,7 @@ final class CategoryPermissionsTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'view_only', 'name' => self::TEST_CATEGORY_PREFIX.'view_only',
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
]); ]);
@@ -69,7 +69,7 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'tsb_admin', 'name' => self::TEST_CATEGORY_PREFIX.'tsb_admin',
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
]); ]);
self::assertSame(201, $response->getStatusCode()); self::assertSame(201, $response->getStatusCode());
@@ -140,7 +140,7 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'tsb_patch', 'name' => self::TEST_CATEGORY_PREFIX.'tsb_patch',
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
]); ]);
self::assertSame(201, $response->getStatusCode()); self::assertSame(201, $response->getStatusCode());
@@ -220,7 +220,7 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'tsb_delete', 'name' => self::TEST_CATEGORY_PREFIX.'tsb_delete',
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
]); ]);
self::assertSame(201, $response->getStatusCode()); self::assertSame(201, $response->getStatusCode());
@@ -47,10 +47,9 @@ final class CategoryTypeCodeFilterTest extends AbstractCatalogApiTestCase
'Le filtre ?typeCode= doit ne renvoyer QUE les categories du type demande.', 'Le filtre ?typeCode= doit ne renvoyer QUE les categories du type demande.',
); );
// Chaque categorie remontee doit PORTER le type filtre (multi-types : // Tous les types embarques doivent etre le type filtre.
// la collection categoryTypes embarquee contient le code demande).
foreach ($members as $member) { foreach ($members as $member) {
self::assertContains('TEST_FOURNISSEUR', array_column($member['categoryTypes'], 'code')); self::assertSame('TEST_FOURNISSEUR', $member['categoryType']['code']);
} }
} }
@@ -69,7 +68,7 @@ final class CategoryTypeCodeFilterTest extends AbstractCatalogApiTestCase
self::assertArrayHasKey('member', $data); self::assertArrayHasKey('member', $data);
foreach ($data['member'] as $member) { foreach ($data['member'] as $member) {
self::assertContains('TEST_FOURNISSEUR', array_column($member['categoryTypes'], 'code')); self::assertSame('TEST_FOURNISSEUR', $member['categoryType']['code']);
} }
} }
+37 -37
View File
@@ -5,22 +5,22 @@ declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api; namespace App\Tests\Module\Catalog\Api;
/** /**
* Tests RG-1.07 : unicite case-insensitive du nom GLOBALEMENT (LOWER(name)) * Tests RG-1.07 : unicite case-insensitive de (LOWER(name), category_type_id)
* parmi les categories non soft-deleted. Depuis le passage en ManyToMany, * parmi les categories non soft-deleted. L'index Postgres partiel
* l'unicite n'est plus liee au type. L'index Postgres partiel * `uq_category_name_type_active` est traduit en 409 Conflict par le
* `uq_category_name_active` est traduit en 409 Conflict par le CategoryProcessor. * CategoryProcessor.
* *
* Cas couverts : * Cas couverts :
* - doublon strict (meme name) 409 ; * - doublon strict (meme name + meme type) 409 ;
* - doublon case-insensitive (Vis / VIS) 409 ; * - doublon case-insensitive (Vis / vis sur meme type) 409 ;
* - meme name avec des types differents 409 (unicite GLOBALE) ; * - meme name sur 2 types differents les deux passent (pas de doublon) ;
* - recreation apres soft delete 201 (l'index partiel libere le nom). * - recreation apres soft delete 201 (l'index partiel libere le couple).
* *
* @internal * @internal
*/ */
final class CategoryUniqueTest extends AbstractCatalogApiTestCase final class CategoryUniqueTest extends AbstractCatalogApiTestCase
{ {
public function testDuplicateNameReturns409(): void public function testDuplicateNameSameTypeReturns409(): void
{ {
$type = $this->createCategoryType(); $type = $this->createCategoryType();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
@@ -29,29 +29,29 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [ $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'unique', 'name' => self::TEST_CATEGORY_PREFIX.'unique',
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
]); ]);
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(201);
// 2eme POST : meme name → doublon (unicite globale). // 2eme POST : meme name + meme type → doublon strict.
$response = $client->request('POST', '/api/categories', [ $response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'unique', 'name' => self::TEST_CATEGORY_PREFIX.'unique',
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
]); ]);
self::assertSame(409, $response->getStatusCode()); self::assertSame(409, $response->getStatusCode());
// Message attendu par la spec RG-1.07 (reformulee, sans "pour ce type"). // Message attendu par la spec RG-1.07.
$payload = $response->toArray(false); $payload = $response->toArray(false);
$description = $payload['description'] ?? $payload['detail'] ?? $payload['hydra:description'] ?? ''; $description = $payload['description'] ?? $payload['detail'] ?? $payload['hydra:description'] ?? '';
self::assertStringContainsString( self::assertStringContainsString(
'existe déjà', 'existe déjà pour ce type',
$description, $description,
'Le message d\'erreur 409 doit citer la spec ("existe deja").', 'Le message d\'erreur 409 doit citer la spec ("existe deja pour ce type").',
); );
} }
@@ -64,8 +64,8 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [ $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'Vis', 'name' => self::TEST_CATEGORY_PREFIX.'Vis',
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
]); ]);
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(201);
@@ -74,17 +74,17 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
// Meme prefix mais variation de casse → meme LOWER → collision. // Meme prefix mais variation de casse → meme LOWER → collision.
'name' => self::TEST_CATEGORY_PREFIX.'VIS', 'name' => self::TEST_CATEGORY_PREFIX.'VIS',
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
]); ]);
self::assertSame(409, $response->getStatusCode()); self::assertSame(409, $response->getStatusCode());
} }
public function testSameNameDifferentTypeReturns409(): void public function testSameNameDifferentTypeAllowed(): void
{ {
// RG-1.07 (reformulee) : l'unicite du nom est desormais GLOBALE — le // RG-1.07 : la contrainte est SUR (name, type), pas sur name seul.
// meme nom sur deux types differents est un doublon. // Le meme nom doit etre acceptable sur deux types differents.
$type1 = $this->createCategoryType(); $type1 = $this->createCategoryType();
$type2 = $this->createCategoryType(); $type2 = $this->createCategoryType();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
@@ -92,27 +92,27 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [ $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'shared', 'name' => self::TEST_CATEGORY_PREFIX.'shared',
'categoryTypes' => ['/api/category_types/'.$type1->getId()], 'categoryType' => '/api/category_types/'.$type1->getId(),
], ],
]); ]);
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(201);
$response = $client->request('POST', '/api/categories', [ $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'shared', 'name' => self::TEST_CATEGORY_PREFIX.'shared',
'categoryTypes' => ['/api/category_types/'.$type2->getId()], 'categoryType' => '/api/category_types/'.$type2->getId(),
], ],
]); ]);
self::assertSame(409, $response->getStatusCode()); self::assertResponseStatusCodeSame(201);
} }
public function testRecreateAfterSoftDeleteAllowed(): void public function testRecreateAfterSoftDeleteAllowed(): void
{ {
// RG-1.07 : l'index Postgres est partiel (WHERE deleted_at IS NULL). // RG-1.07 : l'index Postgres est partiel (WHERE deleted_at IS NULL).
// Apres un soft delete, le nom est libere et un nouveau POST identique // Apres un soft delete, le couple (name, type) est libere et un
// doit reussir. // nouveau POST identique doit reussir.
$type = $this->createCategoryType(); $type = $this->createCategoryType();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
@@ -120,8 +120,8 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
$response = $client->request('POST', '/api/categories', [ $response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'recreate', 'name' => self::TEST_CATEGORY_PREFIX.'recreate',
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
]); ]);
self::assertSame(201, $response->getStatusCode()); self::assertSame(201, $response->getStatusCode());
@@ -131,12 +131,12 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
$client->request('DELETE', '/api/categories/'.$created['id']); $client->request('DELETE', '/api/categories/'.$created['id']);
self::assertResponseStatusCodeSame(204); self::assertResponseStatusCodeSame(204);
// 3) recreation : meme name → autorise (nom libere par l'archivage). // 3) recreation : meme name + meme type → autorise (couple libere).
$client->request('POST', '/api/categories', [ $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'recreate', 'name' => self::TEST_CATEGORY_PREFIX.'recreate',
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
]); ]);
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(201);
@@ -11,8 +11,8 @@ use App\Module\Catalog\Domain\Entity\Category;
* - RG-1.02 : `name` obligatoire (NotBlank) ; * - RG-1.02 : `name` obligatoire (NotBlank) ;
* - RG-1.03 : `name` trim cote serveur via CategoryProcessor ; * - RG-1.03 : `name` trim cote serveur via CategoryProcessor ;
* - RG-1.04 : `name` longueur 2..120 (Length) ; * - RG-1.04 : `name` longueur 2..120 (Length) ;
* - RG-1.05 : `categoryTypes` au moins un type (Count min 1) ; * - RG-1.05 : `categoryType` obligatoire ;
* - RG-1.06 : chaque IRI de `categoryTypes` doit pointer un type existant. * - RG-1.06 : `categoryType` doit pointer un type existant.
* *
* @internal * @internal
*/ */
@@ -27,7 +27,7 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [ $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
// name absent // name absent
], ],
]); ]);
@@ -42,8 +42,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [ $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => '', 'name' => '',
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
]); ]);
@@ -59,8 +59,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [ $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => ' ', 'name' => ' ',
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
]); ]);
@@ -79,8 +79,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [ $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => $payloadName, 'name' => $payloadName,
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
]); ]);
@@ -103,8 +103,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [ $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => 'A', 'name' => 'A',
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
]); ]);
@@ -118,8 +118,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [ $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => str_repeat('a', 121), 'name' => str_repeat('a', 121),
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
]); ]);
@@ -140,74 +140,71 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [ $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => $name, 'name' => $name,
'categoryTypes' => ['/api/category_types/'.$type->getId()], 'categoryType' => '/api/category_types/'.$type->getId(),
], ],
]); ]);
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(201);
} }
// ============ RG-1.05 — au moins un type (Count min 1) ============ // ============ RG-1.05 — categoryType obligatoire ============
public function testCategoryTypesRequiredReturns422(): void public function testCategoryTypeRequiredReturns422(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$client->request('POST', '/api/categories', [ $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'no_type', 'name' => self::TEST_CATEGORY_PREFIX.'no_type',
// categoryTypes absent -> collection vide -> Count(min:1) viole. // categoryType absent
], ],
]); ]);
self::assertResponseStatusCodeSame(422); self::assertResponseStatusCodeSame(422);
} }
public function testCategoryTypesEmptyReturns422(): void public function testCategoryTypeNullIsRejected(): void
{ {
// Tableau vide explicite : Assert\Count(min: 1) doit declencher 422 avec // `categoryType: null` echoue a la deserialization IRI (API Platform
// une violation sur le propertyPath `categoryTypes` (consommable inline). // renvoie 400) bien avant la validation Assert\NotNull. La spec § 4.3
// accepte les deux : on assert le contrat fort "ne passe pas en BDD".
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$response = $client->request('POST', '/api/categories', [ $response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'empty_types', 'name' => self::TEST_CATEGORY_PREFIX.'null_type',
'categoryTypes' => [], 'categoryType' => null,
], ],
]); ]);
self::assertSame(422, $response->getStatusCode());
$payload = $response->toArray(false);
$violations = $payload['violations'] ?? $payload['hydra:violations'] ?? [];
$paths = array_column($violations, 'propertyPath');
self::assertContains( self::assertContains(
'categoryTypes', $response->getStatusCode(),
$paths, [400, 422],
'La violation Count doit porter le propertyPath `categoryTypes`.', 'categoryType=null doit etre rejete (400 deserialization ou 422 validation).',
); );
} }
// ============ RG-1.06 — chaque type doit exister ============ // ============ RG-1.06 — categoryType doit exister ============
public function testCategoryTypeMustExistReturns4xx(): void public function testCategoryTypeMustExistReturns4xx(): void
{ {
// IRI vers un id qui n'existe pas. API Platform peut renvoyer 400 // IRI vers un id qui n'existe pas. API Platform peut renvoyer 400
// (resolution IRI echouee) ou 422 (validation declenchee). La spec § 4.3 // (resolution IRI echouee) ou 422 (validation NotNull declenchee).
// accepte les deux : on assert le contrat "ne passe pas". // La spec § 4.3 accepte les deux : on assert le contrat "ne passe pas".
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$response = $client->request('POST', '/api/categories', [ $response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [ 'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'ghost_type', 'name' => self::TEST_CATEGORY_PREFIX.'ghost_type',
'categoryTypes' => ['/api/category_types/9999999'], 'categoryType' => '/api/category_types/9999999',
], ],
]); ]);
self::assertContains( self::assertContains(
$response->getStatusCode(), $response->getStatusCode(),
[400, 404, 422], [400, 404, 422],
'IRI categoryTypes inexistante doit etre rejetee (400/404/422 selon API Platform).', 'IRI categoryType inexistante doit etre rejetee (400/404/422 selon API Platform).',
); );
} }
} }
@@ -107,7 +107,7 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
$category = new Category(); $category = new Category();
$category->setName($name); $category->setName($name);
$category->setCode($effectiveCode); $category->setCode($effectiveCode);
$category->addCategoryType($this->clientCategoryType()); $category->setCategoryType($this->clientCategoryType());
$em->persist($category); $em->persist($category);
$em->flush(); $em->flush();
@@ -1,339 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Entity\CategoryType;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Commercial\Domain\Entity\SupplierAddress;
use App\Module\Commercial\Domain\Entity\SupplierContact;
use App\Module\Commercial\Domain\Entity\SupplierRib;
use App\Module\Commercial\Domain\Entity\TvaMode;
use App\Module\Sites\Domain\Entity\Site;
use DateTimeImmutable;
/**
* Base des tests fonctionnels du repertoire fournisseurs (M2). Jumelle de la base
* clients (M1), elle ajoute les factories specifiques fournisseur au-dessus de
* {@see AbstractCommercialApiTestCase} (qui apporte deja createCategory sous le
* type CLIENT, createUserWithPermission, authenticatedClient...).
*
* Donnees (RETEX M1 pas de fixtures globales pour les tests) : chaque test seede
* ses fournisseurs en base via les helpers ci-dessous, puis le tearDown les purge.
* Les referentiels comptables (tva_mode / payment_delay / payment_type / bank) et
* les categories FOURNISSEUR (Negociant, Cooperative...) sont seedes par les
* fixtures applicatives (make test-db-setup) ; on les recupere par code.
*
* Categories : `supplierCategory('NEGOCIANT')` fetch-or-create une categorie de
* type FOURNISSEUR (requis par RG-2.10) fetch-or-create par code pour rester
* idempotent et auto-suffisant (ne depend pas du seed, que d'autres tests de la
* suite peuvent purger). Pour fabriquer une categorie d'un AUTRE type (test de
* rejet RG-2.10), utiliser `createCategory()` du parent, qui cree sous CLIENT.
*
* Cleanup : le tearDown purge les fournisseurs AVANT le parent (qui supprime les
* categories `test_cli_cat_*`) : la jointure supplier_category est ON DELETE
* CASCADE cote supplier mais RESTRICT cote category le DELETE DQL sur Supplier
* declenche le cascade BDD sur supplier_category / _contact / _address, liberant
* les categories pour la purge du parent.
*
* @internal
*/
abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
{
protected const string LD = 'application/ld+json';
protected const string MERGE = 'application/merge-patch+json';
/** IBAN/BIC valides (Assert\Iban / Assert\Bic) reutilises par les seeds. */
protected const string VALID_IBAN = 'FR1420041010050500013M02606';
protected const string VALID_BIC = 'BNPAFRPPXXX';
protected function tearDown(): void
{
$this->getEm()->createQuery('DELETE FROM '.Supplier::class)->execute();
parent::tearDown();
}
/**
* Fetch-or-create une categorie de type FOURNISSEUR par code (defaut
* Negociant). Type FOURNISSEUR exige par RG-2.10 : un POST fournisseur portant
* cette categorie passe la validation. Idempotent (lookup par code, aligne sur
* l'index unique partiel uq_category_code) et auto-suffisant : ne depend pas du
* seed CategoryFixtures (que d'autres tests de la suite peuvent purger). Une
* categorie creee ici porte le prefixe de nom de test -> purgee par le parent.
*/
protected function supplierCategory(string $code = 'NEGOCIANT'): Category
{
$em = $this->getEm();
$existing = $em->getRepository(Category::class)->findOneBy(['code' => $code, 'deletedAt' => null]);
if (null !== $existing) {
return $existing;
}
$category = new Category();
$category->setName(self::TEST_CATEGORY_PREFIX.'fr_'.strtolower($code));
$category->setCode($code);
$category->addCategoryType($this->supplierCategoryType());
$em->persist($category);
$em->flush();
return $category;
}
/**
* Recupere (ou cree) le type FOURNISSEUR. Idempotent : la contrainte d'unicite
* sur category_type.code interdit les doublons.
*/
protected function supplierCategoryType(): CategoryType
{
$em = $this->getEm();
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'FOURNISSEUR']);
if (null !== $existing) {
return $existing;
}
$type = new CategoryType();
$type->setCode('FOURNISSEUR');
$type->setLabel('Fournisseur');
$em->persist($type);
$em->flush();
return $type;
}
/**
* Seede directement un Supplier minimal (sans passer par l'API), pour les
* tests de liste / archivage / serialisation. Nom stocke en MAJUSCULES pour
* refleter l'etat normalise (RG-2.12) qu'aurait produit le SupplierProcessor.
* Porte une categorie FOURNISSEUR (defaut Negociant).
*/
protected function seedSupplier(string $companyName, bool $isArchived = false, string $categoryCode = 'NEGOCIANT'): Supplier
{
$em = $this->getEm();
$supplier = new Supplier();
$supplier->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
$supplier->addCategory($this->supplierCategory($categoryCode));
$supplier->setIsArchived($isArchived);
if ($isArchived) {
$supplier->setArchivedAt(new DateTimeImmutable());
}
$em->persist($supplier);
$em->flush();
return $supplier;
}
/**
* Seede un fournisseur COMPLET (sans passer par l'API validations
* applicatives non rejouees mais CHECK BDD respectes) : onglet Information
* rempli, bloc comptable non nul (SIREN + refs), >= 1 RIB, >= 1 adresse
* multi-sites (>= 2 sites, triageProvider=true) avec >= 1 categorie
* FOURNISSEUR, >= 1 contact, >= 1 categorie sur le fournisseur. Sert de socle
* au contrat de serialisation et a la DoD (§ 4.0.bis).
*
* @param string $paymentTypeCode code du type de reglement a poser (defaut LCR,
* coherent avec le RIB seede ; RG-2.08)
*/
protected function seedCompleteSupplier(string $companyName, string $paymentTypeCode = 'LCR'): Supplier
{
$em = $this->getEm();
// Nom unique parmi les actifs (index partiel uq_supplier_company_name_active).
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
$supplier = new Supplier();
$supplier->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8'));
$supplier->addCategory($this->supplierCategory('NEGOCIANT'));
// Onglet Information complet (RG-2.03 : exige pour la Commerciale).
$supplier->setDescription('Fournisseur de test complet.');
$supplier->setCompetitors('Concurrent A, Concurrent B');
$supplier->setFoundedAt(new DateTimeImmutable('2008-04-01'));
$supplier->setEmployeesCount(42);
$supplier->setRevenueAmount('1500000.00');
$supplier->setDirectorName('Jean Dupont');
$supplier->setProfitAmount('120000.00');
$supplier->setVolumeForecast(8000);
// Bloc comptable non nul (gating par omission cote Commerciale).
$supplier->setSiren('123456789');
$supplier->setAccountNumber('F0001');
$supplier->setNTva('FR00123456789');
$supplier->setTvaMode($this->tvaMode('FRANCE_VENTES'));
$supplier->setPaymentDelay($this->paymentDelay('J30'));
$supplier->setPaymentType($this->paymentType($paymentTypeCode));
if ('VIREMENT' === $paymentTypeCode) {
$supplier->setBank($this->bank('SG'));
}
$em->persist($supplier);
// >= 2 sites fixtures pour une adresse multi-sites (RG-2.06).
$sites = $em->getRepository(Site::class)->findBy([], null, 2);
self::assertGreaterThanOrEqual(2, count($sites), 'Au moins 2 sites fixtures requis (SitesFixtures).');
$contact = new SupplierContact();
$contact->setSupplier($supplier);
$contact->setFirstName('Marie');
$contact->setLastName('Martin');
$contact->setJobTitle('Responsable achats');
$contact->setPhonePrimary('0612345678');
$contact->setEmail('marie.martin@seed.test');
$supplier->addContact($contact);
$em->persist($contact);
$address = new SupplierAddress();
$address->setSupplier($supplier);
$address->setAddressType('DEPART');
$address->setPostalCode('86000');
$address->setCity('Poitiers');
$address->setStreet('12 rue des Acacias');
$address->setBennes(3);
// triageProvider=true : prouve qu'un booleen `true` est bien serialise
// (piege n°3 du M1 — la cle etait droppee).
$address->setTriageProvider(true);
foreach ($sites as $site) {
$address->addSite($site);
}
$address->addCategory($this->supplierCategory('NEGOCIANT'));
$address->addContact($contact);
$supplier->addAddress($address);
$em->persist($address);
$rib = new SupplierRib();
$rib->setSupplier($supplier);
$rib->setLabel('Compte principal');
$rib->setBic(self::VALID_BIC);
$rib->setIban(self::VALID_IBAN);
$supplier->addRib($rib);
$em->persist($rib);
$em->flush();
return $supplier;
}
/**
* Ajoute un contact a un fournisseur deja persiste (seed direct).
*/
protected function addContact(
Supplier $supplier,
?string $firstName = 'Marie',
?string $lastName = 'Martin',
?string $phonePrimary = null,
?string $email = null,
int $position = 0,
): SupplierContact {
$contact = new SupplierContact();
$contact->setSupplier($supplier);
$contact->setFirstName($firstName);
$contact->setLastName($lastName);
$contact->setPhonePrimary($phonePrimary);
$contact->setEmail($email);
$contact->setPosition($position);
$supplier->addContact($contact);
$this->getEm()->persist($contact);
$this->getEm()->flush();
return $contact;
}
/**
* Ajoute un RIB a un fournisseur deja persiste (seed direct).
*/
protected function addRib(Supplier $supplier, string $label = 'Compte principal'): SupplierRib
{
$rib = new SupplierRib();
$rib->setSupplier($supplier);
$rib->setLabel($label);
$rib->setBic(self::VALID_BIC);
$rib->setIban(self::VALID_IBAN);
$supplier->addRib($rib);
$this->getEm()->persist($rib);
$this->getEm()->flush();
return $rib;
}
/**
* Payload minimal valide de l'onglet principal (companyName + 1 categorie
* FOURNISSEUR). Si $categoryId est null, la categorie Negociant seedee est
* utilisee.
*
* @return array<string, mixed>
*/
protected function validMainPayload(string $companyName, ?int $categoryId = null): array
{
$categoryId ??= $this->supplierCategory('NEGOCIANT')->getId();
return [
'companyName' => $companyName,
'categories' => ['/api/categories/'.$categoryId],
];
}
protected function paymentType(string $code): PaymentType
{
return $this->referential(PaymentType::class, $code);
}
protected function paymentDelay(string $code): PaymentDelay
{
return $this->referential(PaymentDelay::class, $code);
}
protected function tvaMode(string $code): TvaMode
{
return $this->referential(TvaMode::class, $code);
}
protected function bank(string $code): Bank
{
return $this->referential(Bank::class, $code);
}
/**
* Recupere un referentiel comptable seede (CommercialReferentialFixtures) par
* code. Echoue explicitement si absent (fixtures non chargees).
*
* @template T of object
*
* @param class-string<T> $entityClass
*
* @return T
*/
private function referential(string $entityClass, string $code): object
{
$entity = $this->getEm()->getRepository($entityClass)->findOneBy(['code' => $code]);
self::assertNotNull(
$entity,
sprintf('Referentiel %s "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $entityClass, $code),
);
return $entity;
}
/**
* Indexe les violations d'un corps de reponse 422 par propertyPath. Permet
* d'asserter qu'un 422 porte bien sur le champ attendu (et n'est pas un 422
* orthogonal) : un test qui se contente du code 422 passerait meme si la RG
* visee etait cassee pour une autre raison.
*
* @param array<string, mixed> $body corps decode de la reponse (toArray(false))
*
* @return array<string, string> propertyPath => message
*/
protected function violationsByPath(array $body): array
{
$byPath = [];
foreach ($body['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
return $byPath;
}
}
@@ -14,8 +14,8 @@ use App\Module\Sites\Domain\Entity\Site;
* *
* Authentifies en ADMIN (bypass RBAC via isAdmin) : on valide ici les regles * Authentifies en ADMIN (bypass RBAC via isAdmin) : on valide ici les regles
* METIER (normalisation, unicite, distributor/broker, archivage, liste). Le * METIER (normalisation, unicite, distributor/broker, archivage, liste). Le
* gating par permission (accounting.manage / archive / RG-1.28 strict) est * gating par permission (accounting.manage / archive / RG-1.28 strict, RG-1.04
* couvert par les tests unitaires du ClientProcessor : il * Commerciale) est couvert par les tests unitaires du ClientProcessor : il
* exige des users non-admin portant des permissions `commercial.clients.*` qui * exige des users non-admin portant des permissions `commercial.clients.*` qui
* ne sont declarees qu'en ERP-59 (tests RBAC complets en ERP-60). * ne sont declarees qu'en ERP-59 (tests RBAC complets en ERP-60).
* *
@@ -14,7 +14,8 @@ use Symfony\Component\Console\Output\NullOutput;
/** /**
* Matrice RBAC complete du repertoire clients par role metier (spec-back M1 * Matrice RBAC complete du repertoire clients par role metier (spec-back M1
* § 2.7 + cahier ERP-74). Valide 200/403 par verbe et par onglet pour * § 2.7 + cahier ERP-74). Valide 200/403 par verbe et par onglet pour
* bureau / compta / commerciale / usine. * bureau / compta / commerciale / usine, plus le durcissement RG-1.04
* (Commerciale) au POST.
* *
* Les comptes demo et la matrice sont seedes via la commande reelle * Les comptes demo et la matrice sont seedes via la commande reelle
* `app:seed-rbac --with-demo-users` (le MEME chemin qu'en recette), idempotente. * `app:seed-rbac --with-demo-users` (le MEME chemin qu'en recette), idempotente.
@@ -173,14 +174,14 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
$client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]); $client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200); self::assertResponseStatusCodeSame(200);
// manage : la creation passe la security d'operation et aboutit -> 201 // manage : la creation passe la security d'operation (pas un 403 comme
// (l'onglet Information est facultatif pour tous depuis le retrait de // Compta) mais bute sur RG-1.04 (onglet Information incomplet) -> 422.
// RG-1.04). C'est la preuve que Commerciale porte `manage` (sinon 403). // C'est la preuve que Commerciale porte `manage` (sinon 403).
$client->request('POST', '/api/clients', [ $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Commerciale Post'), 'json' => $this->validMainPayload('Commerciale Post'),
]); ]);
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(422);
// PAS accounting : edition onglet Comptabilite refusee // PAS accounting : edition onglet Comptabilite refusee
$client->request('PATCH', '/api/clients/'.$seed->getId(), [ $client->request('PATCH', '/api/clients/'.$seed->getId(), [
@@ -197,6 +198,27 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(403); self::assertResponseStatusCodeSame(403);
} }
public function testRG104CommercialePostIncompleteIs422AdminIs201(): void
{
$cat = $this->createCategory('SECTEUR');
// RG-1.04 durcie : Commerciale POST sans onglet Information complet -> 422.
$commerciale = $this->authAs('commerciale');
$commerciale->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('RG104 Commerciale', $cat->getId()),
]);
self::assertResponseStatusCodeSame(422);
// Meme payload par un Admin (non gate par RG-1.04) -> 201.
$admin = $this->createAdminClient();
$admin->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('RG104 Admin', $cat->getId()),
]);
self::assertResponseStatusCodeSame(201);
}
public function testComptaFullRepresentationPatchWithUnchangedCategoriesIsNotForbidden(): void public function testComptaFullRepresentationPatchWithUnchangedCategoriesIsNotForbidden(): void
{ {
// FIX review MR #40 : un Compta (accounting.manage, PAS manage) faisant un // FIX review MR #40 : un Compta (accounting.manage, PAS manage) faisant un
@@ -12,8 +12,8 @@ namespace App\Tests\Module\Commercial\Api;
* - 403 si l'utilisateur authentifie ne porte pas `commercial.clients.view`. * - 403 si l'utilisateur authentifie ne porte pas `commercial.clients.view`.
* *
* La matrice RBAC differenciee par role metier (bureau / compta / commerciale * La matrice RBAC differenciee par role metier (bureau / compta / commerciale
* / usine) est DELEGUEE a ERP-74 (#493) : elle exige les roles seedes apres le * / usine) et le test fonctionnel RG-1.04 sont DELEGUES a ERP-74 (#493) : ils
* merge de la stack. NE PAS l'ajouter ici. * exigent les roles seedes apres le merge de la stack. NE PAS les ajouter ici.
* *
* @internal * @internal
*/ */
@@ -1,81 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Tests fonctionnels des RG comptables inter-champs portees par les Assert\Callback
* de l'entite Supplier (M2, RG-2.07 / RG-2.08), via le PATCH de l'onglet
* Comptabilite (groupe supplier:write:accounting). On asserte le code HTTP et le
* propertyPath de la violation (consommable par extractApiViolations cote front,
* ERP-101). Complete les tests unitaires SupplierValidationTest par la preuve HTTP.
*
* @internal
*/
final class SupplierAccountingApiTest extends AbstractSupplierApiTestCase
{
// === RG-2.07 : Virement impose une banque ===
public function testVirementWithoutBankReturns422OnBankPath(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Virement No Bank');
$response = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD],
'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('VIREMENT')->getId()],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('bank', $this->violationsByPath($response->toArray(false)));
}
public function testVirementWithBankReturns200(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Virement With Bank');
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => [
'paymentType' => '/api/payment_types/'.$this->paymentType('VIREMENT')->getId(),
'bank' => '/api/banks/'.$this->bank('SG')->getId(),
],
]);
self::assertResponseStatusCodeSame(200);
}
// === RG-2.08 : LCR impose au moins un RIB ===
public function testLcrWithoutRibReturns422OnRibsPath(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Lcr No Rib');
$response = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD],
'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('LCR')->getId()],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('ribs', $this->violationsByPath($response->toArray(false)));
}
public function testLcrWithRibReturns200(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Lcr With Rib');
$this->addRib($seed);
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('LCR')->getId()],
]);
self::assertResponseStatusCodeSame(200);
}
// violationsByPath() : helper mutualise dans AbstractSupplierApiTestCase.
}
@@ -1,180 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Supplier;
/**
* Tests fonctionnels du formulaire principal fournisseur (M2, spec § 4.3 / § 4.4)
* sur le CORPS JSON : creation (companyName + categories), normalisation serveur
* (RG-2.12 UPPERCASE), categorie de type FOURNISSEUR (RG-2.10), unicite du nom
* (RG-2.11) et archivage nominal (RG-2.14). Jumeau de ClientApiTest (M1).
*
* @internal
*/
final class SupplierApiTest extends AbstractSupplierApiTestCase
{
// === POST formulaire principal ===
public function testPostMainFormUppercasesCompanyName(): void
{
$client = $this->createAdminClient();
$cat = $this->supplierCategory('NEGOCIANT');
$data = $client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'recycla sas',
'categories' => ['/api/categories/'.$cat->getId()],
],
])->toArray();
self::assertResponseStatusCodeSame(201);
// RG-2.12 : companyName normalise en MAJUSCULES sur la valeur RENVOYEE.
self::assertSame('RECYCLA SAS', $data['companyName']);
// Embed categorie : code/name presents (category:read dans le contexte).
self::assertSame('NEGOCIANT', $data['categories'][0]['code']);
}
public function testPostMainFormHasNoInlineContactFields(): void
{
// refonte-contact V0.2 : plus aucun champ de contact inline au POST.
$client = $this->createAdminClient();
$cat = $this->supplierCategory('NEGOCIANT');
$data = $client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'No Inline Co',
// Champs historiques : ignores par le denormaliseur.
'firstName' => 'Ignored',
'lastName' => 'Ignored',
'phonePrimary' => '0612345678',
'email' => 'ignored@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
],
])->toArray();
self::assertResponseStatusCodeSame(201);
foreach (['firstName', 'lastName', 'phonePrimary', 'phoneSecondary', 'email'] as $key) {
self::assertArrayNotHasKey($key, $data);
}
}
// === RG-2.10 : categorie de type FOURNISSEUR ===
public function testPostWithNonFournisseurCategoryReturns422OnCategoriesPath(): void
{
$client = $this->createAdminClient();
// createCategory() (parent) cree une categorie de type CLIENT -> interdite.
$clientTypedCategory = $this->createCategory('SECTEUR');
$response = $client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'companyName' => 'Wrong Cat Type',
'categories' => ['/api/categories/'.$clientTypedCategory->getId()],
],
]);
self::assertResponseStatusCodeSame(422);
$byPath = [];
foreach ($response->toArray(false)['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
// ERP-101 : la violation porte propertyPath=categories (mapping inline front).
self::assertArrayHasKey('categories', $byPath);
self::assertSame('Type de catégorie non autorisé (FOURNISSEUR attendu).', $byPath['categories']);
}
// === RG-2.11 : unicite du nom de societe ===
public function testPostDuplicateCompanyNameReturns409(): void
{
$client = $this->createAdminClient();
$this->seedSupplier('Dup Name Co');
$client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Dup Name Co'),
]);
// RG-2.11 : doublon parmi les actifs -> 409 (index uq_supplier_company_name_active).
self::assertResponseStatusCodeSame(409);
}
public function testPostSameNameAfterArchivingPreviousReturns201(): void
{
$client = $this->createAdminClient();
// L'homonyme est archive -> hors index partiel : le nom redevient disponible.
$this->seedSupplier('Reuse After Archive', true);
$client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Reuse After Archive'),
]);
self::assertResponseStatusCodeSame(201);
}
// === RG-2.14 : archivage (admin) ===
public function testAdminArchiveSetsArchivedAt(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Archive Me');
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(200);
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(Supplier::class)->find($seed->getId());
self::assertNotNull($reloaded);
self::assertTrue($reloaded->isArchived());
self::assertNotNull($reloaded->getArchivedAt(), 'RG-2.14 : archivedAt doit etre rempli a l\'archivage.');
}
public function testArchiveWithOtherFieldReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Archive Plus Field');
// RG-2.14 : une requete d'archivage ne modifie aucun autre champ.
$response = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true, 'companyName' => 'Renamed While Archiving'],
]);
self::assertResponseStatusCodeSame(422);
// Le 422 doit etre celui de RG-2.14 (archivage exclusif) et non un 422
// orthogonal : on verifie le message porte par l'exception.
self::assertStringContainsString('archivage', $response->getContent(false));
}
public function testRestoreSetsArchivedAtNull(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Restore Me', true);
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => false],
]);
self::assertResponseStatusCodeSame(200);
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(Supplier::class)->find($seed->getId());
self::assertNotNull($reloaded);
self::assertFalse($reloaded->isArchived());
self::assertNull($reloaded->getArchivedAt(), 'RG-2.15 : archivedAt repasse a null a la restauration.');
}
}
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Tests d'archivage / restauration fournisseur trou 409 de restauration en
* conflit d'unicite (M2, RG-2.15). Le nominal RG-2.14 (archive pose archivedAt)
* et le 422 « archive + autre champ » sont couverts par SupplierApiTest. Jumeau
* de ClientArchiveTest (M1).
*
* @internal
*/
final class SupplierArchiveTest extends AbstractSupplierApiTestCase
{
/**
* RG-2.15 : restaurer un fournisseur archive dont le nom a ete repris par un
* fournisseur actif entre-temps doit echouer en 409 (index partiel
* uq_supplier_company_name_active : un seul actif portant ce nom).
*/
public function testRestoreConflictReturns409(): void
{
$client = $this->createAdminClient();
$archived = $this->seedSupplier('Acme Conflict', true);
$this->seedSupplier('Acme Conflict', false);
$client->request('PATCH', '/api/suppliers/'.$archived->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => false],
]);
self::assertResponseStatusCodeSame(409);
}
}
@@ -1,140 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use Doctrine\DBAL\Connection;
/**
* Tests Audit du repertoire fournisseurs (M2, spec § 6). Couvre :
* - POST / PATCH / archivage -> ligne audit_log entity_type='commercial.Supplier'
* avec l'action et le diff attendus ;
* - RIB : `#[Auditable]` SANS `#[AuditIgnore]` sur iban/bic -> ces champs sensibles
* DOIVENT apparaitre dans le diff audite (decision § 2.7, miroir M1).
*
* @internal
*/
final class SupplierAuditTest extends AbstractSupplierApiTestCase
{
private const string SUPPLIER_TYPE = 'commercial.Supplier';
private const string RIB_TYPE = 'commercial.SupplierRib';
private ?Connection $auditConnection = null;
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
/** @var Connection $conn */
$conn = self::getContainer()->get('doctrine.dbal.audit_connection');
$this->auditConnection = $conn;
}
protected function tearDown(): void
{
if (null !== $this->auditConnection) {
$this->auditConnection->close();
}
parent::tearDown();
}
public function testPostSupplierIsAudited(): void
{
$admin = $this->createAdminClient();
$cat = $this->supplierCategory('NEGOCIANT');
$created = $admin->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Audit Created Co',
'categories' => ['/api/categories/'.$cat->getId()],
],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertGreaterThanOrEqual(
1,
$this->countAudit(self::SUPPLIER_TYPE, (string) $created['id'], 'create'),
'Un audit_log "create" doit etre genere pour le fournisseur.',
);
}
public function testPatchSupplierIsAudited(): void
{
$admin = $this->createAdminClient();
$seed = $this->seedSupplier('Audit Patch Co');
$admin->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Audit Patch Renamed'],
]);
self::assertResponseStatusCodeSame(200);
self::assertGreaterThanOrEqual(
1,
$this->countAudit(self::SUPPLIER_TYPE, (string) $seed->getId(), 'update'),
'Un audit_log "update" doit etre genere pour le PATCH.',
);
}
public function testArchiveSupplierIsAudited(): void
{
$admin = $this->createAdminClient();
$seed = $this->seedSupplier('Audit Archive Co');
$admin->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(200);
$rows = $this->auditConnection->fetchAllAssociative(
'SELECT changes FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action ORDER BY performed_at DESC',
['type' => self::SUPPLIER_TYPE, 'id' => (string) $seed->getId(), 'action' => 'update'],
);
self::assertGreaterThanOrEqual(1, count($rows));
/** @var array<string, mixed> $changes */
$changes = json_decode((string) $rows[0]['changes'], true, flags: JSON_THROW_ON_ERROR);
self::assertArrayHasKey('isArchived', $changes, 'Le diff d\'archivage doit tracer isArchived.');
}
public function testRibCreateAuditIncludesIbanAndBic(): void
{
$admin = $this->createAdminClient();
$seed = $this->seedSupplier('Rib Audit Host');
$rib = $admin->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'label' => 'Compte audite',
'bic' => self::VALID_BIC,
'iban' => self::VALID_IBAN,
],
])->toArray();
self::assertResponseStatusCodeSame(201);
$rows = $this->auditConnection->fetchAllAssociative(
'SELECT changes FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action ORDER BY performed_at DESC',
['type' => self::RIB_TYPE, 'id' => (string) $rib['id'], 'action' => 'create'],
);
self::assertGreaterThanOrEqual(1, count($rows), 'Un audit_log "create" doit etre genere pour le RIB.');
/** @var array<string, mixed> $changes */
$changes = json_decode((string) $rows[0]['changes'], true, flags: JSON_THROW_ON_ERROR);
self::assertArrayHasKey('iban', $changes, 'iban doit figurer dans le diff audite (pas d\'AuditIgnore).');
self::assertArrayHasKey('bic', $changes, 'bic doit figurer dans le diff audite (pas d\'AuditIgnore).');
self::assertSame(self::VALID_IBAN, $changes['iban']);
self::assertSame(self::VALID_BIC, $changes['bic']);
}
private function countAudit(string $type, string $id, string $action): int
{
return (int) $this->auditConnection->fetchOne(
'SELECT COUNT(*) FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action',
['type' => $type, 'id' => $id, 'action' => $action],
);
}
}
@@ -4,8 +4,11 @@ declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api; namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Commercial\Domain\Entity\SupplierAddress; use App\Module\Commercial\Domain\Entity\SupplierAddress;
use App\Module\Commercial\Domain\Entity\SupplierContact;
use App\Module\Sites\Domain\Entity\Site; use App\Module\Sites\Domain\Entity\Site;
use DateTimeImmutable;
use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\IOFactory;
/** /**
@@ -15,17 +18,29 @@ use PhpOffice\PhpSpreadsheet\IOFactory;
* Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des * Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des
* archives par defaut, respect du filtre ?search, peuplement des colonnes * archives par defaut, respect du filtre ?search, peuplement des colonnes
* contact principal / categories / sites, gating de la colonne SIREN selon * contact principal / categories / sites, gating de la colonne SIREN selon
* commercial.suppliers.accounting.view (admin ET user minimal a permission * commercial.suppliers.accounting.view, 403 sans commercial.suppliers.view,
* explicite), dedup F3 (fournisseur multi-categories rendu sur une seule ligne), * 401 anonyme.
* 403 sans commercial.suppliers.view, 401 anonyme.
* *
* @internal * @internal
*/ */
final class SupplierExportControllerTest extends AbstractSupplierApiTestCase final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
{ {
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
private const string EXPORT_URL = '/api/suppliers/export.xlsx'; private const string EXPORT_URL = '/api/suppliers/export.xlsx';
/**
* Les fournisseurs doivent etre purges AVANT les categories de test (le parent
* supprime les categories `test_cli_cat_*`) : la jointure supplier_category est
* en ON DELETE CASCADE cote supplier mais RESTRICT cote category. Le DELETE DQL
* sur Supplier declenche le cascade BDD sur supplier_category / _contact /
* _address (et leurs sous-jointures), liberant les categories pour le parent.
*/
protected function tearDown(): void
{
$this->getEm()->createQuery('DELETE FROM '.Supplier::class)->execute();
parent::tearDown();
}
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();
@@ -95,13 +110,9 @@ final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
$supplier = $this->seedSupplier('Contact Co'); $supplier = $this->seedSupplier('Contact Co');
// position 1 (secondaire) insere en premier... // position 1 (secondaire) insere en premier...
$this->addContact($supplier, 'Bob', 'Secondaire', '0600000001', 'bob@contact.co', 1); $this->addContact($supplier, 'Secondaire', 'Bob', 1, '0600000001', '0600000002', 'bob@contact.co');
// ...position 0 (principal) insere ensuite : c'est lui qui doit gagner. // ...position 0 (principal) insere ensuite : c'est lui qui doit gagner.
$principal = $this->addContact($supplier, 'Alice', 'Principal', '0612345678', 'alice@contact.co', 0); $this->addContact($supplier, 'Principal', 'Alice', 0, '0612345678', '0698765432', 'alice@contact.co');
// Le telephone secondaire n'est pas porte par le helper de base : on le pose
// directement sur le contact principal pour alimenter la colonne dediee.
$principal->setPhoneSecondary('0698765432');
$this->getEm()->flush();
$row = $this->rowFor($client->request('GET', self::EXPORT_URL)->getContent(), 'CONTACT CO'); $row = $this->rowFor($client->request('GET', self::EXPORT_URL)->getContent(), 'CONTACT CO');
@@ -138,10 +149,8 @@ final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent())); $flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
// Colonne « Catégories » : libelle de la categorie FOURNISSEUR du fournisseur // Colonne « Catégories » : libelle de la categorie du fournisseur (getName()).
// (getName()). On le derive du helper de base (idempotent) plutot que de self::assertStringContainsString('test_cli_cat_negociant', $flat);
// hardcoder le prefixe de nom de test.
self::assertStringContainsString((string) $this->supplierCategory('NEGOCIANT')->getName(), $flat);
// Colonne « Sites » : site agrege depuis l'adresse (RG-2.06). // Colonne « Sites » : site agrege depuis l'adresse (RG-2.06).
self::assertStringContainsString((string) $site->getName(), $flat); self::assertStringContainsString((string) $site->getName(), $flat);
} }
@@ -179,60 +188,6 @@ final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
self::assertStringNotContainsString('987654321', $this->flatten($grid)); self::assertStringNotContainsString('987654321', $this->flatten($grid));
} }
/**
* Gating SIREN prouve via une permission EXPLICITE (et non le bypass admin) :
* un user minimal portant uniquement commercial.suppliers.view +
* commercial.suppliers.accounting.view voit bien la colonne SIREN et sa
* valeur. Complement de testSirenColumnPresentWithAccountingView (admin), qui
* ne prouve pas que accounting.view SEULE suffit (l'admin bypasse le RBAC).
* Le pendant negatif (sans accounting.view -> colonne absente) est couvert par
* testSirenColumnAbsentWithoutAccountingView.
*/
public function testSirenColumnPresentForMinimalUserWithAccountingView(): void
{
// Seed via admin, puis relecture par un user non-admin a 2 permissions.
$this->createAdminClient();
$supplier = $this->seedSupplier('Gated Siren Co');
$em = $this->getEm();
$supplier->setSiren('456789123');
$em->flush();
$creds = $this->createUserWithPermissions([
'commercial.suppliers.view',
'commercial.suppliers.accounting.view',
]);
$viewer = $this->authenticatedClient($creds['username'], $creds['password']);
$grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('SIREN', $grid[0]);
self::assertStringContainsString('456789123', $this->flatten($grid));
}
/**
* Dedup F3 : un fournisseur portant >= 2 categories FOURNISSEUR est multiplie
* par la jointure (selection/hydratation des collections) ; l'export doit le
* rendre sur UNE SEULE ligne. On seede un fournisseur a 2 categories et on
* assert qu'il n'apparait qu'une fois dans la colonne « Nom fournisseur ».
*/
public function testExportDeduplicatesSupplierWithMultipleCategories(): void
{
$client = $this->createAdminClient();
$supplier = $this->seedSupplier('Multi Cat Co', false, 'NEGOCIANT');
// 2e categorie FOURNISSEUR sur le meme fournisseur (RG-2.10).
$supplier->addCategory($this->supplierCategory('GROSSISTE'));
$this->getEm()->flush();
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
$occurrences = count(array_filter($names, static fn (string $name): bool => 'MULTI CAT CO' === $name));
self::assertSame(
1,
$occurrences,
'Un fournisseur multi-categories doit apparaitre sur une seule ligne (dedup F3).',
);
}
public function testForbiddenWithoutSuppliersViewPermission(): void public function testForbiddenWithoutSuppliersViewPermission(): void
{ {
$creds = $this->createUserWithPermission('core.users.view'); $creds = $this->createUserWithPermission('core.users.view');
@@ -251,6 +206,50 @@ final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
self::assertResponseStatusCodeSame(401); self::assertResponseStatusCodeSame(401);
} }
/**
* Seede directement un Supplier en base (sans passer par l'API), pour les
* tests de liste / archivage. Stocke le nom en MAJUSCULES pour refleter l'etat
* normalise (RG-2.12) qu'aurait produit le SupplierProcessor via l'API.
*/
private function seedSupplier(string $companyName, bool $isArchived = false, string $categoryCode = 'SECTEUR'): Supplier
{
$em = $this->getEm();
$supplier = new Supplier();
$supplier->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
$supplier->addCategory($this->createCategory($categoryCode));
$supplier->setIsArchived($isArchived);
if ($isArchived) {
$supplier->setArchivedAt(new DateTimeImmutable());
}
$em->persist($supplier);
$em->flush();
return $supplier;
}
private function addContact(
Supplier $supplier,
string $lastName,
string $firstName,
int $position,
?string $phonePrimary = null,
?string $phoneSecondary = null,
?string $email = null,
): void {
$contact = new SupplierContact();
$contact->setSupplier($supplier);
$contact->setLastName($lastName);
$contact->setFirstName($firstName);
$contact->setPosition($position);
$contact->setPhonePrimary($phonePrimary);
$contact->setPhoneSecondary($phoneSecondary);
$contact->setEmail($email);
$supplier->addContact($contact);
$this->getEm()->persist($contact);
$this->getEm()->flush();
}
/** /**
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules. * Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
* *
@@ -285,7 +284,7 @@ final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
/** /**
* Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName. * Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName.
* *
* @return null|array<int, mixed> * @return array<int, mixed>|null
*/ */
private function rowFor(string $binary, string $companyName): ?array private function rowFor(string $binary, string $companyName): ?array
{ {
@@ -1,118 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Tests fonctionnels de la liste fournisseurs (M2, spec § 4.1 + RG-2.17 + règle
* ABSOLUE n°13) : exclusion des archives par défaut, ?includeArchived, tri
* companyName ASC, enveloppe Hydra (member/totalItems/view), échappatoire
* ?pagination=false, et ANTI N+1 (le nombre de requêtes SQL de la liste ne croît
* pas avec le nombre de lignes fetch-joins/hydratation batchée § 2.12).
*
* @internal
*/
final class SupplierListTest extends AbstractSupplierApiTestCase
{
public function testListExcludesArchivedByDefaultAndIncludesWithFlag(): void
{
$http = $this->createAdminClient();
$token = $this->token();
$this->seedSupplier($token.' Active');
$this->seedSupplier($token.' Archived', true);
$default = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertSame(1, $default['totalItems'], 'RG-2.17 : archives exclus par defaut.');
$all = $http->request('GET', '/api/suppliers?search='.$token.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertSame(2, $all['totalItems'], 'RG-2.17 : ?includeArchived reintegre les archives.');
}
public function testListIsSortedByCompanyNameAsc(): void
{
$http = $this->createAdminClient();
$token = $this->token();
// Inseres dans le desordre ; le tri par defaut doit remonter ALPHA avant ZETA.
$this->seedSupplier($token.' Zeta');
$this->seedSupplier($token.' Alpha');
$names = array_map(
static fn (array $m): string => (string) $m['companyName'],
$http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray()['member'],
);
self::assertCount(2, $names);
self::assertStringContainsString('ALPHA', $names[0], 'RG-2.17 : tri companyName ASC.');
self::assertStringContainsString('ZETA', $names[1]);
}
public function testPaginationDisabledReturnsFullCollection(): void
{
$http = $this->createAdminClient();
$token = $this->token();
for ($i = 0; $i < 3; ++$i) {
$this->seedSupplier($token.' Item'.$i);
}
// ?pagination=false : echappatoire pour alimenter un <select> (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);
}
}
@@ -1,68 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Tests de structure / migration M2 (§ 8.1). Vérifie au niveau du schéma Postgres :
* - l'unique index partiel fonctionnel uq_supplier_company_name_active existe
* (LOWER(company_name), partiel sur actifs non archivés / non supprimés
* RG-2.11), seule unicité de nom conservée ; pas d'index unique siren/email ;
* - le type de catégorie FOURNISSEUR est présent (seedé migration + fixture).
*
* @internal
*/
final class SupplierMigrationTest extends AbstractSupplierApiTestCase
{
public function testCompanyNameActivePartialIndexExistsExactlyOnce(): void
{
$rows = $this->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<array{indexname: string, indexdef: string}>
*/
private function supplierIndexes(): array
{
self::bootKernel();
/** @var list<array{indexname: string, indexdef: string}> $rows */
return $this->getEm()->getConnection()->fetchAllAssociative(
"SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = 'supplier'",
);
}
}
@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Supplier;
/**
* Mode strict PATCH multi-groupes fournisseur (M2, RG-2.16) preuve fonctionnelle
* HTTP, SANS dependre d'un role metier : un user portant
* `commercial.suppliers.manage` mais PAS `commercial.suppliers.accounting.manage`
* qui envoie un PATCH melant un champ principal (companyName) et un champ
* comptable (siren) recoit 403 sur TOUT le payload aucun champ applique (pas de
* filtrage silencieux). Jumeau de ClientPatchStrictTest (M1).
*
* @internal
*/
final class SupplierPatchStrictTest extends AbstractSupplierApiTestCase
{
public function testMixedGroupsPatchWithoutAccountingPermissionIsForbidden(): void
{
$seed = $this->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());
}
}
@@ -1,303 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/**
* Matrice RBAC complete du repertoire fournisseurs par role metier (spec-back M2
* § 2.9 + ERP-90). Valide 200/403 par verbe et par onglet pour
* bureau / compta / commerciale / usine, le gating des champs comptables en
* lecture (omission de cle) et le durcissement RG-2.03 (Commerciale) au POST/PATCH.
*
* Les comptes demo et la matrice sont seedes via la commande reelle
* `app:seed-rbac --with-demo-users` (le MEME chemin qu'en recette), idempotente
* pas de mock de role. Jumeau de ClientRBACMatrixTest (M1).
*
* Matrice § 2.9 (ERP-90) rappel :
* - bureau : suppliers.view + manage (ni accounting, ni archive)
* - compta : suppliers.view + accounting.view + accounting.manage (PAS manage)
* - commerciale : suppliers.view + manage (PAS accounting), durcie RG-2.03
* - usine : aucune permission (403 partout)
* - archive : admin seul (aucun role metier)
*
* @internal
*/
final class SupplierRBACMatrixTest extends AbstractSupplierApiTestCase
{
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
protected function setUp(): void
{
parent::setUp();
// Seed idempotent via la commande applicative (roles + matrice § 2.9 +
// comptes demo). Exerce aussi le chemin de code prod.
self::bootKernel();
$application = new Application(self::$kernel);
$application->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);
}
}
@@ -1,371 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Tests anti-regression du CONTRAT DE SERIALISATION du repertoire fournisseurs
* (M2, spec-back § 4.0 / § 4.0.bis / § 4.0.ter). Jumeau du
* {@see ClientSerializationContractTest} (M1), il reverifie sur le JSON reel les
* 4 pieges silencieux constates en prod sur le M1 :
* - #4 : fuite RIB (IBAN/BIC) vers un user sans accounting.view -> 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<string, mixed> $collection
*
* @return array<string, mixed>|null
*/
private function memberById(array $collection, int $id): ?array
{
foreach ($collection['member'] ?? [] as $member) {
if (($member['id'] ?? null) === $id) {
return $member;
}
}
return null;
}
}
@@ -1,361 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Sites\Domain\Entity\Site;
/**
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du fournisseur
* (M2, spec § 4.5). Couvrent : normalisation contact (RG-2.12), RG-2.04 (prenom
* OU nom), RG-2.05 (code postal), RG-2.06 (>= 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();
}
}
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Supplier;
/**
* Tests d'unicite fournisseur (M2, RG-2.11). Le doublon de companyName (409) est
* couvert par {@see SupplierApiTest::testPostDuplicateCompanyNameReturns409}. Ce
* fichier prouve l'envers de la decision § 2.6 : SIREN NON unique (etablissements
* multiples). Jumeau de ClientUniquenessTest (M1).
*
* @internal
*/
final class SupplierUniquenessTest extends AbstractSupplierApiTestCase
{
public function testDuplicateSirenIsAllowed(): void
{
self::bootKernel();
$em = $this->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());
}
}
@@ -55,17 +55,6 @@ final class SupplierValidationTest extends TestCase
self::assertContains('categories', $this->violationPaths($supplier)); 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 === // === RG-2.07 : Virement impose une banque ===
public function testVirementWithoutBankIsRejectedOnBankPath(): void public function testVirementWithoutBankIsRejectedOnBankPath(): void
@@ -142,17 +131,13 @@ final class SupplierValidationTest extends TestCase
} }
/** /**
* Double minimal de CategoryInterface (pas d'acces base) PORTANT les codes de * Double minimal de CategoryInterface (pas d'acces base) renvoyant le code de
* type voulus seul element regarde par validateCategoryType. Variadic pour * type de categorie voulu seul element regarde par validateCategoryType.
* couvrir le cas multi-types (ManyToMany).
*
* @return list<string> n'est pas le type de retour : helper renvoyant un double
*/ */
private function category(string ...$typeCodes): CategoryInterface private function category(string $typeCode): CategoryInterface
{ {
return new class(array_values($typeCodes)) implements CategoryInterface { return new class($typeCode) implements CategoryInterface {
/** @param list<string> $typeCodes */ public function __construct(private readonly string $typeCode) {}
public function __construct(private readonly array $typeCodes) {}
public function getId(): ?int public function getId(): ?int
{ {
@@ -169,10 +154,9 @@ final class SupplierValidationTest extends TestCase
return 'TEST'; return 'TEST';
} }
/** @return list<string> */ public function getCategoryTypeCode(): ?string
public function getCategoryTypeCodes(): array
{ {
return $this->typeCodes; return $this->typeCode;
} }
}; };
} }
@@ -9,6 +9,7 @@ use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException; use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Application\Service\ClientFieldNormalizer; use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
use App\Module\Commercial\Application\Validator\ClientAccountingCompletenessValidator; use App\Module\Commercial\Application\Validator\ClientAccountingCompletenessValidator;
use App\Module\Commercial\Application\Validator\ClientInformationCompletenessValidator;
use App\Module\Commercial\Domain\Entity\Bank; use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\Client; use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Entity\ClientRib; use App\Module\Commercial\Domain\Entity\ClientRib;
@@ -16,6 +17,8 @@ use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType; use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\TvaMode; use App\Module\Commercial\Domain\Entity\TvaMode;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientProcessor; use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientProcessor;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Security\BusinessRoles;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\UnitOfWork; use Doctrine\ORM\UnitOfWork;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -24,11 +27,13 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Security\Core\User\UserInterface;
/** /**
* Tests unitaires du ClientProcessor : gating par permission (accounting.manage * Tests unitaires du ClientProcessor : gating par permission (accounting.manage
* / archive / RG-1.28 strict) et regles metier non testables en HTTP admin * / archive / RG-1.28 strict) et regles metier non testables en HTTP admin
* (RG-1.12 Virement, RG-1.13 LCR), grace a un Security et un RequestStack stubbes. * (RG-1.04 Commerciale, RG-1.12 Virement, RG-1.13 LCR), grace a un Security et
* un RequestStack stubbes.
* *
* @internal * @internal
*/ */
@@ -337,6 +342,62 @@ final class ClientProcessorTest extends TestCase
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation())); self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
} }
public function testCommercialeIncompleteInformationIsUnprocessable(): void
{
// RG-1.04 : role Commerciale + onglet Information incomplet -> 422.
$client = $this->minimalClient();
$client->setDescription('Une description'); // les autres champs Information restent null
$processor = $this->makeProcessor(
granted: [],
payload: ['description' => 'Une description'],
user: $this->commercialeUser(),
);
$this->expectException(ValidationException::class);
$processor->process($client, $this->operation());
}
public function testCommercialeIncompleteInformationOnNonInformationPatchIsUnprocessable(): void
{
// RG-1.04 durcie (ERP-74) : pour une Commerciale, la completude de
// l'onglet Information est exigee meme quand le payload ne touche PAS
// l'onglet Information (ici seulement companyName). L'ancienne condition
// d'intersection avec INFORMATION_FIELDS a ete retiree.
$client = $this->minimalClient();
$client->setCompanyName('Renamed Co'); // onglet principal uniquement, Information vide
$processor = $this->makeProcessor(
granted: ['commercial.clients.manage'],
payload: ['companyName' => 'Renamed Co'],
user: $this->commercialeUser(),
managed: true,
originalData: [
'companyName' => 'TEST CO',
'triageService' => false,
'isArchived' => false,
],
);
$this->expectException(ValidationException::class);
$processor->process($client, $this->operation());
}
public function testNonCommercialeSkipsInformationCompleteness(): void
{
// Meme payload incomplet, mais user non-Commerciale -> aucun blocage.
$client = $this->minimalClient();
$client->setDescription('Une description');
$processor = $this->makeProcessor(
granted: [],
payload: ['description' => 'Une description'],
user: null,
);
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
}
/** /**
* @param list<string> $granted Permissions accordees a l'utilisateur courant * @param list<string> $granted Permissions accordees a l'utilisateur courant
* @param array<string, mixed> $payload Corps JSON simule de la requete * @param array<string, mixed> $payload Corps JSON simule de la requete
@@ -346,6 +407,7 @@ final class ClientProcessorTest extends TestCase
private function makeProcessor( private function makeProcessor(
array $granted, array $granted,
array $payload, array $payload,
?UserInterface $user = null,
bool $managed = false, bool $managed = false,
array $originalData = [], array $originalData = [],
): ClientProcessor { ): ClientProcessor {
@@ -360,6 +422,7 @@ final class ClientProcessorTest extends TestCase
$security->method('isGranted')->willReturnCallback( $security->method('isGranted')->willReturnCallback(
static fn (mixed $attribute): bool => is_string($attribute) && in_array($attribute, $granted, true), static fn (mixed $attribute): bool => is_string($attribute) && in_array($attribute, $granted, true),
); );
$security->method('getUser')->willReturn($user);
$requestStack = new RequestStack(); $requestStack = new RequestStack();
$requestStack->push(new Request([], [], [], [], [], [], json_encode($payload, JSON_THROW_ON_ERROR))); $requestStack->push(new Request([], [], [], [], [], [], json_encode($payload, JSON_THROW_ON_ERROR)));
@@ -377,6 +440,7 @@ final class ClientProcessorTest extends TestCase
return new ClientProcessor( return new ClientProcessor(
$persist, $persist,
new ClientFieldNormalizer(), new ClientFieldNormalizer(),
new ClientInformationCompletenessValidator(),
new ClientAccountingCompletenessValidator(), new ClientAccountingCompletenessValidator(),
$security, $security,
$requestStack, $requestStack,
@@ -429,4 +493,26 @@ final class ClientProcessorTest extends TestCase
{ {
return $this->createStub(Operation::class); return $this->createStub(Operation::class);
} }
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';
}
};
}
} }
@@ -101,24 +101,6 @@ final class SupplierProcessorTest extends TestCase
self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation())); 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<string> $granted Permissions accordees a l'utilisateur courant * @param list<string> $granted Permissions accordees a l'utilisateur courant
* @param array<string, mixed> $payload Corps JSON simule de la requete * @param array<string, mixed> $payload Corps JSON simule de la requete
@@ -193,33 +175,6 @@ final class SupplierProcessorTest extends TestCase
return $this->createStub(Operation::class); 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 private function commercialeUser(): UserInterface
{ {
return new class implements UserInterface, BusinessRoleAwareInterface { return new class implements UserInterface, BusinessRoleAwareInterface {
+12 -36
View File
@@ -90,26 +90,6 @@ abstract class AbstractApiTestCase extends ApiTestCase
* @return array{username: string, password: string} Les identifiants pour authenticatedClient() * @return array{username: string, password: string} Les identifiants pour authenticatedClient()
*/ */
protected function createUserWithPermission(string $permissionCode): array 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<string> $permissionCodes codes des permissions a accorder
*
* @return array{username: string, password: string} identifiants pour authenticatedClient()
*/
protected function createUserWithPermissions(array $permissionCodes): array
{ {
if (!self::$kernel) { if (!self::$kernel) {
self::bootKernel(); self::bootKernel();
@@ -117,6 +97,17 @@ abstract class AbstractApiTestCase extends ApiTestCase
$em = $this->getEm(); $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); $suffix = substr(bin2hex(random_bytes(4)), 0, 8);
$username = 'testuser_'.$suffix; $username = 'testuser_'.$suffix;
$password = 'testpass'; $password = 'testpass';
@@ -125,22 +116,7 @@ abstract class AbstractApiTestCase extends ApiTestCase
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class); $hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$role = new Role('test_'.$suffix, 'Test Role '.$suffix, false); $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); $em->persist($role);
$user = new User(); $user = new User();