Compare commits

..

10 Commits

Author SHA1 Message Date
gitea-actions 26b1f2c39b chore: bump version to v0.1.101
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 56s
2026-06-09 19:47:49 +00:00
tristan 8490de99da ERP-119 : revue validation front clients + évolutions écran client (types d'adresse, 2e email, saisies manuelles, redirection) (#80)
Auto Tag Develop / tag (push) Successful in 7s
## Contexte
Branche ERP-119 — revue de la validation des formulaires clients (déclencheur : écran « Ajouter un client »), accompagnée de plusieurs évolutions de l'écran client (M1).

## Contenu

### Validation front (clients)
- Boutons « Valider » toujours actifs (retrait du gating de validité) : c'est le back qui renvoie les 422, mappées en rouge par champ.
- Champs requis adossés à une colonne non-nullable : la clé est omise du payload si vide (companyName, RIB, adresse) → 422 NotBlank au lieu d'un 400 de type.
- Onglet Contact : au moins un contact requis (l'amorce vide est soumise → 422 RG-1.05).
- Onglet Adresse : affichage inline des erreurs type / sites / catégories + RG back « au moins un type d'adresse obligatoire ».

### Nouveaux types d'adresse
- Courtier / Distributeur, types autonomes exclusifs : colonnes `is_broker` / `is_distributor` (migration + CHECK miroir d'exclusivité), entité + Callback, et front (select, drapeaux, payloads).

### Saisies manuelles
- Adresse : `allow-create` sur le champ Adresse → saisie libre si la BAN ne propose rien.
- Date de création : `MalioDate :editable` → saisie clavier JJ/MM/AAAA en plus du calendrier.

### 2e email de facturation
- Colonne `billing_email_secondary` (optionnel, max 2), miroir du téléphone secondaire. Bump `@malio/layer-ui` 1.7.8 (prop `addable`).

### Fin d'ajout d'un client
- Redirection vers la liste à la validation du dernier onglet remplissable par le rôle (Adresse pour Bureau/Commerciale, Comptabilité pour Admin) + toast « Client ajouté ». Dérivé de `tabKeys`, sans règle RBAC custom.

## Vérifications
- Back : suites Module/Commercial + Architecture vertes (Client : 124/124). Migrations appliquées (dev + test).
- Front : Vitest vert (272), ESLint OK.

> Note : le hook pré-commit flake aléatoirement (JWT 401 / timeout DB) sur des tests sans rapport (Supplier) ; les commits ont été faits après vérification des suites concernées en isolation.

Reviewed-on: #80
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-09 19:47:40 +00:00
gitea-actions b3ab23ee8f chore: bump version to v0.1.100
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 36s
2026-06-09 08:44:19 +00:00
tristan 222338e5a4 fix(commercial) : validation onglet compta LCR + controle croise BIC/IBAN (ERP-118) (#78)
Auto Tag Develop / tag (push) Successful in 7s
## ERP-118 — Validation onglet Comptabilité (LCR / RIB)

### 1. Fix — 422 « Au moins un RIB est obligatoire pour le type de règlement LCR »

L'onglet Comptabilité envoyait le `PATCH /clients/{id}` des scalaires (`paymentType=LCR`) **avant** le `POST /clients/{id}/ribs`. Or le back valide RG-1.13 (LCR ⟹ ≥1 RIB persisté) sur ce PATCH, en lisant les RIB en base — vides à ce stade. Résultat : 422, et le `return` empêchait la création des RIB. Premier passage en LCR impossible (deadlock).

**Correctif :** inverser l'ordre — RIB d'abord, puis PATCH des scalaires.
- `new.vue` : `POST/PATCH RIB` → `PATCH scalaires`.
- `[id]/edit.vue` : ordre universel `CREATE/UPDATE RIB` → `PATCH scalaires` → `DELETE RIB retirés` (suppressions après le PATCH : le guard back n'autorise la suppression du dernier RIB qu'une fois quitté LCR). Corrige au passage un 409 latent sur le swap du dernier RIB en LCR.

### 2. Feat — contrôle croisé pays BIC/IBAN

`Assert\Bic(ibanPropertyPath: 'iban')` sur `ClientRib` et `SupplierRib` : le pays du BIC (positions 5-6) doit correspondre au pays de l'IBAN (positions 1-2). Un BIC et un IBAN valides isolément mais de pays différents → 422, violation portée par le champ `bic` avec message FR (`ibanMessage`), mappée inline côté front. Aucune modif front nécessaire.

### Tests

- Tests fonctionnels du mismatch (BIC DE + IBAN FR → 422 sur `propertyPath=bic`, message FR) côté client et fournisseur.
- Suite back complète au vert (garde-fou `EntityConstraintsHaveFrenchMessageTest` inclus), suite front Vitest au vert.

### Points d'attention

- **Durcissement de RG** (cross-check BIC/IBAN) hors spec initiale : des RIB existants avec BIC/IBAN de pays différents deviendraient non modifiables sans correction.
- L'orchestration de submit n'est pas couverte par un test unitaire (pas d'infra de test composant sur ces écrans) — vérification golden path recommandée.

Reviewed-on: #78
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-09 08:44:12 +00:00
gitea-actions d4a5df50a7 chore: bump version to v0.1.99
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 38s
2026-06-09 06:07:03 +00:00
tristan 191fd42406 Correctifs frontend ecran categories + alignement boutons admin (ERP-117) (#77)
Auto Tag Develop / tag (push) Successful in 9s
## Contexte
ERP-117 — correctifs frontend sur l'ecran de gestion des categories et alignement des boutons d'action des ecrans admin.

## Changements
### Drawer categories
- Titre stable « Modifier la categorie » (plus de bascule view → edit selon l'etat « dirty »), aligne sur les drawers simples du projet.
- Bouton Enregistrer toujours actif : il sauvegarde a tout moment, meme sans modification (PATCH du payload complet `name` + `categoryTypes`, comme `SiteDrawer`).
- Champ « Types de categorie » : suppression du label « Selectionner un ou plusieurs types ».

### Alignement des boutons admin
- Ecran Categories : ordre des boutons Filtres avant Ajouter + gap reduit (`gap-8`), comme le repertoire client.
- Boutons d'ajout admin (categories, roles, sites) passes en `variant=secondary`.
- Boutons Filtres (categories, audit-log, clients) en `tertiary` simple : suppression des surcharges de classe, icone a gauche 24px.

## Tests
- `useCategoryForm` mis a jour (PATCH payload complet).
- `make nuxt-test` : 256/256 OK.
- `make nuxt-lint` : OK.

Reviewed-on: #77
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-09 06:06:52 +00:00
gitea-actions edfb2b1619 chore: bump version to v0.1.98
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 37s
2026-06-08 14:53:01 +00:00
tristan c5c650c599 style(front) : marges PageHeader (38px haut / 30px bas) + ordre boutons Filtres avant Ajouter (repertoire client)
Auto Tag Develop / tag (push) Successful in 7s
2026-06-08 16:52:53 +02:00
gitea-actions e598a92f94 chore: bump version to v0.1.97
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 58s
2026-06-08 14:40:27 +00:00
tristan b8dc3cb696 Correctifs écran Client (ERP-115) (#76)
Auto Tag Develop / tag (push) Successful in 7s
Lot de correctifs sur l'écran Client (M1), + un retrait de règle métier et une petite fonctionnalité.

## Formulaire client (création / édition)
- Boutons « ajouter un bloc » (Adresse, RIB) désactivés tant que le dernier bloc n'est pas valide.
- Onglet Information : bouton Valider désactivé si aucun champ rempli (création) ; onglet Contact accessible dès la création (Information facultatif).
- Champs « Relation » (Distributeur/Courtier) et « Prestation de triage » masqués par défaut, révélés seulement si une catégorie ordinaire (≠ Distributeur/Courtier) est sélectionnée.
- Bloc RIB affiché uniquement si le type de règlement est LCR (création, édition, consultation) ; plus de RIB fantôme soumis.
- Alignement du bas du textarea « Description » sur les autres champs.

## Recherche d'adresse (BAN)
- Une erreur de l'API ne bloque plus définitivement la recherche : chaque frappe réessaie (le mode dégradé restait verrouillé).
- Garde minimum 3 caractères avant l'appel à l'API.

## Répertoire client
- Titres de colonne en noir 16px, corps + tags de site en 14px.

## Navigation
- L'onglet actif est conservé au passage consultation ↔ édition (via history.state, hors URL).

## Règle métier
- Retrait de RG-1.04 : l'onglet Information n'est plus obligatoire pour le rôle Commerciale — facultatif pour tous (back + tests + docs).

Tests : suites front (Vitest) et back (PHPUnit) vertes hormis flakes d'infra connus.
Reviewed-on: #76
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-08 14:40:18 +00:00
64 changed files with 1821 additions and 776 deletions
+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 (Information obligatoire — RG-1.04) | | `commerciale` | `demo` | ROLE_USER | clients : view + manage |
| `usine` | `demo` | ROLE_USER | aucun accès clients | | `usine` | `demo` | ROLE_USER | aucun accès clients |
--- ---
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.96' app.version: '0.1.101'
+4 -5
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 + RG-1.04 des **rôles métier** (matrice RBAC bureau/compta/commerciale/usine) sont
fonctionnel) sont **délégués à ERP-74 (#493)** : ces rôles n'existent qu'après le **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 | Onglet Information obligatoire pour rôle Commerciale | `ClientProcessorTest::testCommercialeIncompleteInformationIsUnprocessable` ; `::testNonCommercialeSkipsInformationCompleteness` (unit, dormant). **Test fonctionnel + durcissement → ERP-74** | ERP-55 / **ERP-74** | | ~~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.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,8 +60,7 @@ 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** : PATCH onglet Information par une Commerciale avec - ~~**RG-1.04 fonctionnel**~~ : _(SUPPRIMÉE)_ l'onglet Information est désormais facultatif pour tous les rôles.
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
+4 -5
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 (Commerciale obligatoire — RG-1.04 — null sinon) -- Onglet Information (facultatif pour tous — RG-1.04 supprimée)
description TEXT, description TEXT,
competitors VARCHAR(255), competitors VARCHAR(255),
founded_at DATE, founded_at DATE,
@@ -864,8 +864,7 @@ Cf. § 2.6. Pattern Shared standard.
### Onglet Information ### Onglet Information
- **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. - ~~**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.
- **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
@@ -883,7 +882,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` — 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.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.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 ».
@@ -938,7 +937,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`)
- [ ] **RG-1.04** : PATCH onglet Information par un user Commerciale avec champs incomplets → 422 ; même PATCH par Admin → 200 - [x] ~~**RG-1.04**~~ _(SUPPRIMÉE)_ : onglet Information facultatif pour tous les rôles (plus de validation de complétude)
- [ ] **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.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] 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]
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>` | Conditionnel | RG-1.04 (obligatoire pour rôle Commerciale) | | **Description** | `<MalioInputTextArea>` | Non | — _(onglet facultatif, RG-1.04 supprimée)_ |
| **Concurrents** | `<MalioInputText>` | Conditionnel | RG-1.04 | | **Concurrents** | `<MalioInputText>` | Non | — |
| **Date de création** (de l'entreprise) | `<input type="date">` (exception Malio — pas de composant date couvert) | Conditionnel | RG-1.04 | | **Date de création** (de l'entreprise) | `<input type="date">` (exception Malio — pas de composant date couvert) | Non | — |
| **Nombre de salariés** | `<MalioInputNumber>` | Conditionnel | RG-1.04 | | **Nombre de salariés** | `<MalioInputNumber>` | Non | — |
| **CA €** | `<MalioInputAmount>` | Conditionnel | RG-1.04 | | **CA €** | `<MalioInputAmount>` | Non | — |
| **Dirigeant** | `<MalioInputText>` | Conditionnel | RG-1.04 | | **Dirigeant** | `<MalioInputText>` | Non | — |
| **Résultat €** | `<MalioInputAmount>` | Conditionnel | RG-1.04 | | **Résultat €** | `<MalioInputAmount>` | Non | — |
**Action** : « Valider » → PATCH partiel `/api/clients/{id}` (groupe `client:write:information`). **Action** : « Valider » → PATCH partiel `/api/clients/{id}` (groupe `client:write:information`).
+3 -4
View File
@@ -2,7 +2,9 @@
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])
- bande blanche sticky sous la navbar : 47px (h-[47px]) La marge haute du contenu (44px) vit desormais DANS l'entete (PageHeader,
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>
@@ -25,9 +27,6 @@
<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>
+7 -3
View File
@@ -88,6 +88,7 @@
"toast": { "toast": {
"createSuccess": "Client créé avec succès", "createSuccess": "Client créé avec succès",
"updateSuccess": "Client mis à jour avec succès", "updateSuccess": "Client mis à jour avec succès",
"addComplete": "Client ajouté",
"archiveSuccess": "Client archivé avec succès", "archiveSuccess": "Client archivé avec succès",
"restoreSuccess": "Client restauré avec succès", "restoreSuccess": "Client restauré avec succès",
"error": "Une erreur est survenue. Réessayez.", "error": "Une erreur est survenue. Réessayez.",
@@ -173,15 +174,20 @@
"addressTypeDelivery": "Livraison", "addressTypeDelivery": "Livraison",
"addressTypeBilling": "Facturation", "addressTypeBilling": "Facturation",
"addressTypeDeliveryBilling": "Adresse + Facturation", "addressTypeDeliveryBilling": "Adresse + Facturation",
"addressTypeBroker": "Adresse Courtier",
"addressTypeDistributor": "Adresse Distributeur",
"categories": "Catégorie", "categories": "Catégorie",
"country": "Pays", "country": "Pays",
"postalCode": "Code postal", "postalCode": "Code postal",
"city": "Ville", "city": "Ville",
"street": "Adresse", "street": "Adresse",
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
"streetComplement": "Adresse complémentaire", "streetComplement": "Adresse complémentaire",
"sites": "Sites", "sites": "Sites",
"contacts": "Contact(s) rattaché(s)", "contacts": "Contact(s) rattaché(s)",
"billingEmail": "Email de facturation", "billingEmail": "Email de facturation",
"billingEmailSecondary": "Email de facturation secondaire",
"addBillingEmail": "Ajouter un email",
"remove": "Supprimer l'adresse", "remove": "Supprimer l'adresse",
"add": "Nouvelle adresse", "add": "Nouvelle adresse",
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre." "degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
@@ -416,7 +422,6 @@
"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",
@@ -431,8 +436,7 @@
}, },
"form": { "form": {
"name": "Nom", "name": "Nom",
"types": "Types de catégorie", "types": "Types de catégorie"
"typesPlaceholder": "Sélectionner un ou plusieurs types"
}, },
"validation": { "validation": {
"nameRequired": "Le nom est obligatoire.", "nameRequired": "Le nom est obligatoire.",
@@ -31,7 +31,6 @@
v-model="form.categoryTypeIds.value" v-model="form.categoryTypeIds.value"
:options="typeOptions" :options="typeOptions"
:label="t('admin.categories.form.types')" :label="t('admin.categories.form.types')"
:empty-option-label="t('admin.categories.form.typesPlaceholder')"
:error="form.errors.categoryTypes" :error="form.errors.categoryTypes"
:display-tag="true" :display-tag="true"
:disabled="loadingTypes" :disabled="loadingTypes"
@@ -91,28 +90,17 @@ const emit = defineEmits<{
delete: [] delete: []
}>() }>()
/** // Mode du drawer : creation (pas de category prop, POST au save) ou
* Mode du drawer (dérivé du composable `useCategoryForm`) : // modification d'une categorie existante (PATCH au save). Pas de distinction
* - 'create' : pas de category prop, formulaire vide, POST au save. // view/edit : comme les autres drawers, le titre et le bouton Enregistrer sont
* - 'view' : category prop set, formulaire pre-rempli, save MASQUE // stables quel que soit l'etat « dirty » du formulaire.
* 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 mode = computed<DrawerMode>(() => { const headerLabel = computed(() =>
if (isCreateMode.value) return 'create' isCreateMode.value
return form.isDirty.value ? 'edit' : 'view' ? t('admin.categories.createCategory')
}) : 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
@@ -121,10 +109,12 @@ const canShowDelete = computed(
() => !isCreateMode.value && can('catalog.categories.manage'), () => !isCreateMode.value && can('catalog.categories.manage'),
) )
// Save : visible en creation, ou en edition (apres modification d'un champ). // Save : visible en creation, et en consultation/edition d'une categorie
// Masque en view tant que rien n'a change. // existante (l'utilisateur doit pouvoir enregistrer sans qu'un champ ait
// 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(
() => mode.value === 'create' || mode.value === 'edit', () => isCreateMode.value || can('catalog.categories.manage'),
) )
const typeOptions = computed(() => const typeOptions = computed(() =>
@@ -154,18 +144,18 @@ watch(
) )
/** /**
* Sauvegarde : delegue au composable (POST en mode create, PATCH en mode * Sauvegarde : delegue au composable (POST en creation, PATCH en modification).
* edit). Le toast succes + mapping erreur 409/422 est gere par le composable. * Le toast succes + mapping erreur 409/422 est gere par le composable. Le PATCH
* En cas de succes, on ferme le drawer et on previent le parent pour qu'il * envoie le payload complet, donc le bouton Enregistrer sauvegarde a tout
* refresh la liste. * moment (meme sans modification). En cas de succes, on ferme le drawer et on
* previent le parent pour qu'il refresh la liste.
*/ */
async function handleSave(): Promise<void> { async function handleSave(): Promise<void> {
let result: Category | null = null const result = isCreateMode.value
if (mode.value === 'create') { ? await form.submitCreate()
result = await form.submitCreate() : props.category
} else if (mode.value === 'edit' && props.category) { ? await form.submitUpdate(props.category.id)
result = await form.submitUpdate(props.category.id) : null
}
if (result) { if (result) {
emit('saved') emit('saved')
emit('update:modelValue', false) emit('update:modelValue', false)
@@ -346,7 +346,7 @@ describe('useCategoryForm', () => {
}) })
describe('submitUpdate', () => { describe('submitUpdate', () => {
it('appelle PATCH /categories/{id} uniquement avec les champs modifies', async () => { it('appelle PATCH /categories/{id} avec le payload complet (name + categoryTypes)', 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)
@@ -354,9 +354,11 @@ describe('useCategoryForm', () => {
await form.submitUpdate(42) 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( expect(mockPatch).toHaveBeenCalledWith(
'/categories/42', '/categories/42',
{ name: 'Vis V2' }, // pas de categoryTypes car non modifies { name: 'Vis V2', categoryTypes: ['/api/category_types/1'] },
{ toast: false }, { toast: false },
) )
}) })
@@ -371,20 +373,25 @@ describe('useCategoryForm', () => {
expect(mockPatch).toHaveBeenCalledWith( expect(mockPatch).toHaveBeenCalledWith(
'/categories/42', '/categories/42',
{ categoryTypes: ['/api/category_types/1', '/api/category_types/2'] }, { name: CAT.name, categoryTypes: ['/api/category_types/1', '/api/category_types/2'] },
{ toast: false }, { toast: false },
) )
}) })
it('court-circuite l appel API si aucun champ n a change', async () => { it('envoie un PATCH complet meme sans modification (save a tout moment)', async () => {
mockPatch.mockResolvedValueOnce(CAT)
const form = useCategoryForm() const form = useCategoryForm()
form.loadFrom(CAT) form.loadFrom(CAT)
// Aucune modification — isDirty=false, patch payload vide. // Aucune modification : le PATCH part quand meme avec le payload complet.
const result = await form.submitUpdate(42) const result = await form.submitUpdate(42)
expect(mockPatch).not.toHaveBeenCalled() expect(mockPatch).toHaveBeenCalledWith(
expect(result).toBeNull() '/categories/42',
{ 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)
}) })
@@ -174,26 +174,18 @@ export function useCategoryForm() {
} }
/** /**
* PATCH /api/categories/{id}. Envoie uniquement les champs modifies pour * PATCH /api/categories/{id}. Envoie le payload complet (name +
* coller a la semantique merge-patch (Content-Type pose par useApi). * categoryTypes), comme les autres drawers du projet : le bouton
* Renvoie la categorie mise a jour, ou `null` en cas d'echec. * Enregistrer sauvegarde a tout moment, meme sans modification, et renvoie
* 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> = {
if (name.value !== initialName.value) { name: name.value.trim(),
payload.name = name.value.trim() categoryTypes: categoryTypeIds.value.map(id => `/api/category_types/${id}`),
}
if (!sameIds(categoryTypeIds.value, initialCategoryTypeIds.value)) {
payload.categoryTypes = categoryTypeIds.value.map(id => `/api/category_types/${id}`)
}
// Garde-fou : un PATCH sans changement ne sert a rien. Theoriquement
// empeche par le drawer (bouton Enregistrer masque si !isDirty) mais
// 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, {
@@ -3,17 +3,10 @@
<PageHeader> <PageHeader>
{{ t('admin.categories.title') }} {{ t('admin.categories.title') }}
<template #actions> <template #actions>
<!-- gap-12 = 48px d'espacement entre Ajouter et Filtres (meme <!-- gap-8 = 32px d'espacement entre Filtres et Ajouter (meme
design que le Repertoire Clients). --> design que le Repertoire Clients). -->
<div class="flex items-center gap-12"> <div class="flex items-center gap-8">
<MalioButton <!-- Bouton Filtres a GAUCHE d'Ajouter. Le compteur reflete
v-if="canManage"
:label="t('admin.categories.newCategory')"
icon-name="mdi:add-bold"
icon-position="left"
@click="openCreateDrawer"
/>
<!-- Bouton Filtres a DROITE d'Ajouter. Le compteur reflete
les filtres actifs. --> les filtres actifs. -->
<MalioButton <MalioButton
variant="tertiary" variant="tertiary"
@@ -21,9 +14,16 @@
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"
/> />
<MalioButton
v-if="canManage"
variant="secondary"
:label="t('admin.categories.newCategory')"
icon-name="mdi:add-bold"
icon-position="left"
@click="openCreateDrawer"
/>
</div> </div>
</template> </template>
</PageHeader> </PageHeader>
@@ -14,12 +14,15 @@
remplacant les 3 cases. Les options encodent les combinaisons valides remplacant les 3 cases. Les options encodent les combinaisons valides
(exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les (exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les
drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). --> drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). -->
<!-- Erreur portee sur `isProspect` cote back (Callback type obligatoire +
exclusivite prospect) -> affichee sous le select Type d'adresse. -->
<MalioSelect <MalioSelect
:model-value="addressType" :model-value="addressType"
:options="addressTypeOptions" :options="addressTypeOptions"
:label="t('commercial.clients.form.address.addressType')" :label="t('commercial.clients.form.address.addressType')"
:readonly="readonly" :readonly="readonly"
:required="true" :required="true"
:error="errors?.isProspect"
@update:model-value="onAddressTypeChange" @update:model-value="onAddressTypeChange"
/> />
@@ -31,6 +34,7 @@
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:required="true" :required="true"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/> />
@@ -43,9 +47,10 @@
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/> />
<!-- Email de facturation : ligne 1 colonne 4, visible/obligatoire <!-- Email(s) de facturation : visible/obligatoire seulement si Facturation
seulement si Facturation (RG-1.11). Sinon un filler comble la (RG-1.11). Le « + » revele un 2e email optionnel (max 2, pendant du
colonne pour que Categorie reparte au debut de la ligne 2. --> telephone secondaire) qui coule dans la grille. Sinon un filler comble
la colonne pour que Categorie reparte au debut de la ligne suivante. -->
<MalioInputEmail <MalioInputEmail
v-if="isBillingEmailRequired(model)" v-if="isBillingEmailRequired(model)"
:model-value="model.billingEmail" :model-value="model.billingEmail"
@@ -54,10 +59,23 @@
:readonly="readonly" :readonly="readonly"
:lowercase="true" :lowercase="true"
:error="errors?.billingEmail" :error="errors?.billingEmail"
:addable="!model.hasSecondaryBillingEmail && !readonly"
:add-button-label="t('commercial.clients.form.address.addBillingEmail')"
@update:model-value="(v: string) => update('billingEmail', v)" @update:model-value="(v: string) => update('billingEmail', v)"
@add="revealSecondaryBillingEmail"
/> />
<div v-else aria-hidden="true" /> <div v-else aria-hidden="true" />
<MalioInputEmail
v-if="isBillingEmailRequired(model) && model.hasSecondaryBillingEmail"
:model-value="model.billingEmailSecondary"
:label="t('commercial.clients.form.address.billingEmailSecondary')"
:readonly="readonly"
:lowercase="true"
:error="errors?.billingEmailSecondary"
@update:model-value="(v: string) => update('billingEmailSecondary', v)"
/>
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="model.categoryIris" :model-value="model.categoryIris"
:options="categoryOptions" :options="categoryOptions"
@@ -65,6 +83,7 @@
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:required="true" :required="true"
:error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/> />
@@ -87,8 +106,9 @@
@update:model-value="onPostalCodeChange" @update:model-value="onPostalCodeChange"
/> />
<!-- Ville : MalioSelect alimente par le code postal (BAN). En mode <!-- Ville : MalioSelect alimente par le code postal (BAN). Si la BAN est
degrade (service indisponible), bascule en saisie libre. --> indisponible, bascule en saisie libre — recuperable : re-saisir le
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"
@@ -115,11 +135,14 @@
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 en <!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple
mode degrade OU en lecture seule (MalioInputAutocomplete ne reaffiche seulement en lecture seule (MalioInputAutocomplete ne reaffiche pas
pas sa valeur liee, il n'afficherait rien en readonly). --> sa valeur liee, il n'afficherait rien en readonly). allow-create :
si la BAN ne propose rien (ou erreur), le texte saisi est CONSERVE au
blur/Entree (saisie manuelle) — sinon il serait efface. La ville reste
pilotee par le code postal ; choisir une suggestion remplit rue+ville+CP. -->
<MalioInputAutocomplete <MalioInputAutocomplete
v-if="!degraded && !readonly" v-if="!readonly"
:model-value="model.street" :model-value="model.street"
:options="addressOptions" :options="addressOptions"
:loading="addressLoading" :loading="addressLoading"
@@ -128,6 +151,8 @@
:readonly="readonly" :readonly="readonly"
:required="true" :required="true"
:error="errors?.street" :error="errors?.street"
:allow-create="true"
:no-results-text="t('commercial.clients.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))" @update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch" @search="onAddressSearch"
@select="onAddressSelect" @select="onAddressSelect"
@@ -143,7 +168,7 @@
/> />
</div> </div>
<div class="col-span-2"> <div class="col-span-1">
<MalioInputText <MalioInputText
:model-value="model.streetComplement" :model-value="model.streetComplement"
:label="t('commercial.clients.form.address.streetComplement')" :label="t('commercial.clients.form.address.streetComplement')"
@@ -209,6 +234,8 @@ const addressTypeOptions = computed<RefOption[]>(() => [
{ value: 'delivery', label: t('commercial.clients.form.address.addressTypeDelivery') }, { value: 'delivery', label: t('commercial.clients.form.address.addressTypeDelivery') },
{ value: 'billing', label: t('commercial.clients.form.address.addressTypeBilling') }, { value: 'billing', label: t('commercial.clients.form.address.addressTypeBilling') },
{ value: 'delivery_billing', label: t('commercial.clients.form.address.addressTypeDeliveryBilling') }, { value: 'delivery_billing', label: t('commercial.clients.form.address.addressTypeDeliveryBilling') },
{ value: 'broker', label: t('commercial.clients.form.address.addressTypeBroker') },
{ value: 'distributor', label: t('commercial.clients.form.address.addressTypeDistributor') },
]) ])
/** Applique le type choisi en repercutant les 3 drapeaux back (immutabilite). */ /** Applique le type choisi en repercutant les 3 drapeaux back (immutabilite). */
@@ -217,8 +244,12 @@ 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) })
} }
// Mode degrade : service BAN indisponible → Ville/Adresse en saisie libre. // Repli saisie libre de la VILLE quand la BAN est indisponible (recuperable :
// 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).
@@ -258,10 +289,15 @@ function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDr
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
} }
/** Bascule définitivement en mode degrade et previent le parent (toast unique). */ /** Revele le 2e champ email de facturation (clic sur le « + »). */
function enterDegraded(): void { function revealSecondaryBillingEmail(): void {
if (!degraded.value) { emit('update:modelValue', { ...props.modelValue, hasSecondaryBillingEmail: true })
degraded.value = true }
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
function notifyUnavailable(): void {
if (!unavailableNotified) {
unavailableNotified = true
emit('degraded') emit('degraded')
} }
} }
@@ -270,9 +306,6 @@ function enterDegraded(): 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
@@ -280,15 +313,22 @@ 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 {
enterDegraded() // BAN indispo : Ville en saisie libre (recuperable au prochain essai).
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> {
if (degraded.value) { // La BAN exige au moins 3 caracteres : on n'envoie rien en deca (evite un 400)
// et on vide les suggestions devenues obsoletes.
if (query.trim().length < 3) {
banAddressOptions.value = []
return return
} }
addressLoading.value = true addressLoading.value = true
@@ -299,7 +339,10 @@ 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 {
enterDegraded() // Erreur transitoire : on vide les suggestions, la prochaine frappe reessaie
// (pas de bascule definitive — c'etait le bug). Avertissement une seule fois.
banAddressOptions.value = []
notifyUnavailable()
} }
finally { finally {
addressLoading.value = false addressLoading.value = false
@@ -1,16 +1,21 @@
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils' import { mount, flushPromises } 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'
// Le composable BAN est mocke : aucun appel reseau, aucune suggestion chargee. // Mocks controlables du composable BAN (hoisted) : chaque test configure le
// On reproduit ainsi l'etat « adresse persistee, mais liste de suggestions // comportement de searchCity / searchAddress (succes, rejet, rejet-puis-succes).
// vide » (remontage apres validation / edition d'une adresse existante). // Par defaut ils renvoient undefined (aucune suggestion) — etat « adresse
// 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: vi.fn(), searchCity: searchCityMock,
searchAddress: vi.fn(), searchAddress: searchAddressMock,
}), }),
})) }))
@@ -31,6 +36,7 @@ const MalioInputAutocompleteStub = defineComponent({
minSearchLength: { type: Number, default: 0 }, minSearchLength: { type: Number, default: 0 },
label: { type: String, default: '' }, label: { type: String, default: '' },
readonly: { type: Boolean, default: false }, readonly: { type: Boolean, default: false },
allowCreate: { type: Boolean, default: false },
}, },
emits: ['update:modelValue', 'search', 'select'], emits: ['update:modelValue', 'search', 'select'],
setup(props) { setup(props) {
@@ -73,6 +79,14 @@ describe('ClientAddressBlock — affichage de l\'adresse persistee', () => {
expect(values).toContain('8 Boulevard du Port') expect(values).toContain('8 Boulevard du Port')
}) })
// ERP-119 : saisie manuelle possible quand la BAN ne trouve rien -> allow-create
// (sans cette prop, MalioInputAutocomplete efface le texte non selectionne au blur).
it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => {
const wrapper = mountBlock(null)
expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true)
})
}) })
/** /**
@@ -129,4 +143,84 @@ 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.')
}) })
// ERP-119 : type d'adresse (propertyPath back `isProspect`), sites et
// categories sont obligatoires ; leurs violations 422 doivent s'afficher sous
// le champ correspondant (bindings :error de ClientAddressBlock).
it('affiche l\'erreur serveur sur type d\'adresse (propertyPath isProspect)', () => {
const wrapper = mountWithErrors({ isProspect: 'Le type d\'adresse est obligatoire.' })
const field = wrapper.findAll('malio-select-stub').find(
el => el.attributes('label') === 'commercial.clients.form.address.addressType',
)
expect(field?.attributes('error')).toBe('Le type d\'adresse est obligatoire.')
})
it('affiche les erreurs serveur sur sites et categories', () => {
const wrapper = mountWithErrors({
sites: 'Au moins un site est obligatoire.',
categories: 'Au moins une catégorie est obligatoire.',
})
const checkboxes = wrapper.findAll('malio-select-checkbox-stub')
const sitesField = checkboxes.find(el => el.attributes('label') === 'commercial.clients.form.address.sites')
const categoriesField = checkboxes.find(el => el.attributes('label') === 'commercial.clients.form.address.categories')
expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.')
expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.')
})
})
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,7 +99,9 @@ export function useClientReferentials() {
*/ */
async function loadCommon(): Promise<void> { async function loadCommon(): Promise<void> {
await Promise.allSettled([ await Promise.allSettled([
fetchAll<CategoryMember>('/categories') // Taxonomie multi-types (ERP-84) : un client ne porte que des 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"> <div class="flex items-center gap-3 pt-11">
<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-[32px] font-bold text-m-primary">{{ headerTitle }}</h1> <h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
</div> </div>
<!-- Etats de chargement / introuvable. --> <!-- Etats de chargement / introuvable. -->
@@ -41,6 +41,7 @@
@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')"
@@ -49,7 +50,7 @@
@update:model-value="onRelationChange" @update:model-value="onRelationChange"
/> />
<MalioSelect <MalioSelect
v-if="main.relationType === 'courtier'" v-if="showRelationAndTriage && 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')"
@@ -59,7 +60,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="main.relationType === 'distributeur'" v-if="showRelationAndTriage && 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')"
@@ -69,6 +70,7 @@
@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"
@@ -80,7 +82,7 @@
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('commercial.clients.edit.save')" :label="t('commercial.clients.edit.save')"
:disabled="!isMainValid || mainSubmitting" :disabled="mainSubmitting"
@click="submitMain" @click="submitMain"
/> />
</div> </div>
@@ -90,11 +92,14 @@
<!-- 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" group-class="row-span-2 pt-1 pb-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"
@@ -109,6 +114,7 @@
v-model="information.foundedAt" v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')" :label="t('commercial.clients.form.information.foundedAt')"
:readonly="businessReadonly" :readonly="businessReadonly"
:editable="true"
:error="informationErrors.errors.foundedAt" :error="informationErrors.errors.foundedAt"
/> />
<MalioInputText <MalioInputText
@@ -173,7 +179,7 @@
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('commercial.clients.edit.save')" :label="t('commercial.clients.edit.save')"
:disabled="!canValidateContacts || tabSubmitting" :disabled="tabSubmitting"
@click="submitContacts" @click="submitContacts"
/> />
</div> </div>
@@ -205,12 +211,13 @@
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
variant="primary" variant="primary"
:label="t('commercial.clients.edit.save')" :label="t('commercial.clients.edit.save')"
:disabled="!canValidateAddresses || tabSubmitting" :disabled="tabSubmitting"
@click="submitAddresses" @click="submitAddresses"
/> />
</div> </div>
@@ -289,9 +296,9 @@
</div> </div>
</div> </div>
<!-- Blocs RIB (0..n) — obligatoires si type de reglement = LCR (RG-1.13). --> <!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13). -->
<div <div
v-for="(rib, index) in ribs" v-for="(rib, index) in visibleRibs"
: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)]"
> >
@@ -330,16 +337,18 @@
<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
variant="primary" variant="primary"
:label="t('commercial.clients.edit.save')" :label="t('commercial.clients.edit.save')"
:disabled="!canValidateAccounting || tabSubmitting" :disabled="tabSubmitting"
@click="submitAccounting" @click="submitAccounting"
/> />
</div> </div>
@@ -379,7 +388,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref, watch } 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'
@@ -410,16 +419,16 @@ import {
type MainFormDraft, type MainFormDraft,
} from '~/modules/commercial/utils/clientEdit' } from '~/modules/commercial/utils/clientEdit'
import { import {
addressTypeFromFlags,
buildClientFormTabKeys, buildClientFormTabKeys,
hasAllRequiredAccountingFields, isAddressValid,
hasAtLeastOneValidContact,
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,
@@ -430,6 +439,7 @@ 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 = '#########'
@@ -500,7 +510,9 @@ 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())
if (ribs.value.length === 0) ribs.value.push(emptyRib()) // RIB : amorce un bloc vide seulement si le type de reglement est une LCR
// (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(() => {})
@@ -551,6 +563,28 @@ 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
@@ -592,11 +626,13 @@ const tabs = computed(() => tabKeys.value.map(key => ({
icon: TAB_ICONS[key], icon: TAB_ICONS[key],
}))) })))
const activeTab = ref('information') // Onglet initial : repris de la consultation (history.state), sinon 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(`/clients/${clientId}`) router.push({ path: `/clients/${clientId}`, state: { tab: activeTab.value } })
} }
/** /**
@@ -636,17 +672,6 @@ const {
} = useClientFormErrors() } = useClientFormErrors()
// ── Bloc principal ─────────────────────────────────────────────────────────── // ── Bloc principal ───────────────────────────────────────────────────────────
const isMainValid = computed(() => {
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
const relationValid
= main.relationType === null
|| (main.relationType === 'distributeur' && filled(main.distributorIri))
|| (main.relationType === 'courtier' && filled(main.brokerIri))
return filled(main.companyName)
&& main.categoryIris.length >= 1
&& relationValid
})
async function onRelationChange(value: string | number | null): Promise<void> { async function onRelationChange(value: string | number | null): Promise<void> {
const relation = (value === null || value === '') ? null : (String(value) as 'distributeur' | 'courtier') const relation = (value === null || value === '') ? null : (String(value) as 'distributeur' | 'courtier')
main.relationType = relation main.relationType = relation
@@ -660,7 +685,7 @@ async function onRelationChange(value: string | number | null): Promise<void> {
/** PATCH /clients/{id} — groupe client:write:main UNIQUEMENT (mode strict). */ /** PATCH /clients/{id} — groupe client:write:main UNIQUEMENT (mode strict). */
async function submitMain(): Promise<void> { async function submitMain(): Promise<void> {
if (businessReadonly.value || !isMainValid.value || mainSubmitting.value) return if (businessReadonly.value || mainSubmitting.value) return
mainSubmitting.value = true mainSubmitting.value = true
mainErrors.clearErrors() mainErrors.clearErrors()
try { try {
@@ -713,9 +738,6 @@ const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1] const last = contacts.value[contacts.value.length - 1]
return last === undefined || isContactNamed(last) return last === undefined || isContactNamed(last)
}) })
// RG-1.14 : au moins un contact nomme pour finaliser l'onglet.
const canValidateContacts = computed(() => hasAtLeastOneValidContact(contacts.value))
function addContact(): void { function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact()) if (canAddContact.value) contacts.value.push(emptyContact())
} }
@@ -737,7 +759,7 @@ function askRemoveContact(index: number): void {
* collection contacts (endpoints client_contact dedies). * collection contacts (endpoints client_contact dedies).
*/ */
async function submitContacts(): Promise<void> { async function submitContacts(): Promise<void> {
if (businessReadonly.value || !canValidateContacts.value || tabSubmitting.value) return if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
contactErrors.value = [] contactErrors.value = []
try { try {
@@ -746,6 +768,11 @@ async function submitContacts(): Promise<void> {
} }
removedContactIds.value = [] removedContactIds.value = []
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des
// amorces neuves vides (ex. tous les contacts existants supprimes), on ne
// les skippe pas -> le back renvoie la 422 RG-1.05 « prénom ou nom
// obligatoire » inline (la RG-1.14 n'a pas d'equivalent back au POST).
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
// On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls // On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls
// les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli // les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli
// sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc. // sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc.
@@ -768,10 +795,10 @@ async function submitContacts(): Promise<void> {
} }
}, },
error => showError(error), error => showError(error),
// On ne saute QUE les amorces neuves (id null) totalement vides. Un // On ne saute une amorce neuve (id null) totalement vide QUE si un autre
// bloc existant vide est soumis -> 422 RG-1.05 inline (sinon la modif // bloc sera soumis : sinon on la soumet pour declencher la 422 RG-1.05
// serait perdue en silence avec un faux toast de succes). // (un onglet Contact vide ne doit pas passer en faux succes).
contact => contact.id === null && isContactBlank(contact), contact => hasSubmittableContact && contact.id === null && isContactBlank(contact),
) )
// Tant qu'un bloc reste en erreur : pas de toast succes. // Tant qu'un bloc reste en erreur : pas de toast succes.
if (hasError) return if (hasError) return
@@ -786,19 +813,14 @@ async function submitContacts(): Promise<void> {
} }
// ── Onglet Adresse ─────────────────────────────────────────────────────────── // ── Onglet Adresse ───────────────────────────────────────────────────────────
const canValidateAddresses = computed(() => // « + Adresse » desactive tant que la derniere adresse n'est pas valide.
addresses.value.length > 0 const canAddAddress = computed(() => {
&& addresses.value.every((a) => { const last = addresses.value[addresses.value.length - 1]
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== '' return last !== undefined && isAddressValid(last)
return addressTypeFromFlags(a) !== null })
&& a.siteIris.length >= 1
&& a.categoryIris.length >= 1
&& (!isBillingEmailRequired(a) || filledBillingEmail)
}),
)
function addAddress(): void { function addAddress(): void {
addresses.value.push(emptyAddress()) if (canAddAddress.value) addresses.value.push(emptyAddress())
} }
function askRemoveAddress(index: number): void { function askRemoveAddress(index: number): void {
@@ -823,7 +845,7 @@ function onAddressDegraded(): void {
/** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */ /** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */
async function submitAddresses(): Promise<void> { async function submitAddresses(): Promise<void> {
if (businessReadonly.value || !canValidateAddresses.value || tabSubmitting.value) return if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
addressErrors.value = [] addressErrors.value = []
try { try {
@@ -870,25 +892,35 @@ 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.
if (isRibRequired.value) {
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}
else {
for (const rib of ribs.value) {
if (rib.id != null) removedRibIds.value.push(rib.id)
}
ribs.value = []
ribErrors.value = []
}
} }
function ribIsComplete(rib: { label: string | null, bic: string | null, iban: string | null }): boolean { // « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
const filled = (v: string | null) => v !== null && v.trim() !== '' const canAddRib = computed(() => {
return filled(rib.label) && filled(rib.bic) && filled(rib.iban) const last = ribs.value[ribs.value.length - 1]
} return last !== undefined && isRibComplete(last)
const canValidateAccounting = computed(() => {
if (!hasAllRequiredAccountingFields(accounting)) return false
if (isBankRequired.value && accounting.bankIri === null) return false
if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false
return true
}) })
function addRib(): void { function addRib(): void {
ribs.value.push(emptyRib()) if (canAddRib.value) ribs.value.push(emptyRib())
} }
function askRemoveRib(index: number): void { function askRemoveRib(index: number): void {
@@ -903,35 +935,21 @@ function askRemoveRib(index: number): void {
} }
/** /**
* Valide l'onglet Comptabilite : PATCH des scalaires (groupe client:write:accounting, * Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
* exige accounting.manage cote back) PUIS DELETE/POST/PATCH des RIB sur la * PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
* sous-ressource. Aucun champ main/information dans le payload (mode strict * back) PUIS DELETE des RIB retires. Les RIB crees d'abord : le back valide RG-1.13
* RG-1.28 : sinon 403 sur tout le payload). * (LCR => au moins un RIB persiste) sur le PATCH scalaires ; les suppressions en
* dernier (le guard back n'autorise la suppression du dernier RIB qu'une fois quitte
* LCR). Aucun champ main/information dans le payload (mode strict RG-1.28 : sinon
* 403 sur tout le payload).
*/ */
async function submitAccounting(): Promise<void> { async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return if (accountingReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
accountingErrors.clearErrors() accountingErrors.clearErrors()
// Reset des erreurs RIB des le debut : l'etape 1 (PATCH scalaires) peut
// echouer et `return` avant submitRows (qui porte sinon le reset), laissant
// des erreurs de RIB obsoletes affichees sous les blocs.
ribErrors.value = []
try { try {
// 1) PATCH des scalaires comptables (erreurs inline sur leurs champs). // 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs
try { // tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2.
await api.patch(`/clients/${clientId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false })
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
return
}
for (const id of removedRibIds.value) {
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
}
removedRibIds.value = []
// 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes).
// Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex. // Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex.
// IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline. // IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline.
const ribHasError = await submitRows( const ribHasError = await submitRows(
@@ -958,6 +976,23 @@ async function submitAccounting(): Promise<void> {
rib => rib.id === null && isRibBlank(rib), rib => rib.id === null && isRibBlank(rib),
) )
if (ribHasError) return if (ribHasError) return
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try {
await api.patch(`/clients/${clientId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false })
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
return
}
// 3) DELETE des RIB retires : APRES le PATCH scalaires (si on quitte LCR, le
// guard back n'autorise la suppression du dernier RIB qu'une fois le type change).
for (const id of removedRibIds.value) {
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
}
removedRibIds.value = []
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (e) { catch (e) {
@@ -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"> <div class="flex items-center gap-3 pt-11">
<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-[32px] font-bold text-m-primary">{{ headerTitle }}</h1> <h1 class="text-[30px] font-semibold 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,11 +88,14 @@
<!-- 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" group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg" text-input="h-full text-lg"
readonly readonly
/> />
@@ -278,6 +281,7 @@
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,
@@ -293,7 +297,7 @@ import {
type ClientDetail, type ClientDetail,
type SelectOption, type SelectOption,
} from '~/modules/commercial/utils/clientConsultation' } from '~/modules/commercial/utils/clientConsultation'
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/clientForm' import { emptyAddress, emptyContact } 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 = '#########'
@@ -350,10 +354,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: [] }]
}) })
const ribs = computed(() => { // Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le
const list = (client.value?.ribs ?? []).map(mapRibToDraft) // client n'en a pas (un RIB n'existe que pour un reglement LCR — RG-1.13). Pas
return list.length ? list : [emptyRib()] // de bloc vierge fantome en consultation.
}) 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)))
@@ -413,15 +417,17 @@ const tabs = computed(() => tabKeys.value.map(key => ({
icon: TAB_ICONS[key], icon: TAB_ICONS[key],
}))) })))
const activeTab = ref('information') // Onglet initial : repris de l'edition au retour (history.state), sinon 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(`/clients/${clientId}/edit`) router.push({ path: `/clients/${clientId}/edit`, state: { tab: activeTab.value } })
} }
// ── Archivage / Restauration ──────────────────────────────────────────────── // ── Archivage / Restauration ────────────────────────────────────────────────
@@ -3,18 +3,9 @@
<PageHeader> <PageHeader>
{{ t('commercial.clients.title') }} {{ t('commercial.clients.title') }}
<template #actions> <template #actions>
<!-- gap-12 = 48px d'espacement entre Ajouter et Filtres. --> <!-- gap-8 = 32px d'espacement entre Filtres et Ajouter. -->
<div class="flex items-center gap-12"> <div class="flex items-center gap-8">
<MalioButton <!-- Bouton Filtres a GAUCHE d'Ajouter. Le compteur reflete les filtres actifs. -->
v-if="canManage"
variant="secondary"
:label="t('commercial.clients.add')"
icon-name="mdi:add-bold"
icon-position="left"
@click="goToCreate"
/>
<!-- Bouton Filtres a DROITE d'Ajouter : meme design que
l'audit-log. Le compteur reflete les filtres actifs. -->
<MalioButton <MalioButton
v-if="canView" v-if="canView"
variant="tertiary" variant="tertiary"
@@ -22,9 +13,16 @@
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"
/> />
<MalioButton
v-if="canManage"
variant="secondary"
:label="t('commercial.clients.add')"
icon-name="mdi:add-bold"
icon-position="left"
@click="goToCreate"
/>
</div> </div>
</template> </template>
</PageHeader> </PageHeader>
@@ -39,7 +37,7 @@
:per-page="itemsPerPage" :per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions" :per-page-options="itemsPerPageOptions"
row-clickable row-clickable
table-class="table-fixed" table-class="table-fixed clients-table"
:empty-message="t('commercial.clients.empty')" :empty-message="t('commercial.clients.empty')"
@row-click="onRowClick" @row-click="onRowClick"
@update:page="goToPage" @update:page="goToPage"
@@ -56,7 +54,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 text-xs font-medium text-white" class="inline-flex items-center rounded-full px-2 py-0.5 font-medium text-white"
:style="{ backgroundColor: site.color }" :style="{ backgroundColor: site.color }"
> >
{{ site.name }} {{ site.name }}
@@ -70,7 +68,7 @@
</template> </template>
</MalioDataTable> </MalioDataTable>
<div class="flex justify-center mt-6"> <div class="flex justify-center mt-4">
<MalioButton <MalioButton
v-if="canView" v-if="canView"
variant="primary" variant="primary"
@@ -350,7 +348,9 @@ 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',
{ pagination: 'false' }, // Taxonomie multi-types (ERP-84) : le filtre du repertoire client ne
// 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,3 +419,16 @@ 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>
+150 -132
View File
@@ -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"> <div class="flex items-center gap-3 pt-11">
<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-[32px] font-bold text-m-primary">{{ t('commercial.clients.form.title') }}</h1> <h1 class="text-[30px] font-semibold text-m-primary">{{ t('commercial.clients.form.title') }}</h1>
</div> </div>
<!-- Formulaire principal (pre-onglets) <!-- Formulaire principal (pre-onglets)
@@ -35,6 +35,7 @@
@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')"
@@ -43,7 +44,7 @@
@update:model-value="onRelationChange" @update:model-value="onRelationChange"
/> />
<MalioSelect <MalioSelect
v-if="main.relationType === 'courtier'" v-if="showRelationAndTriage && 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')"
@@ -53,7 +54,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="main.relationType === 'distributeur'" v-if="showRelationAndTriage && 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')"
@@ -63,6 +64,7 @@
@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"
@@ -74,7 +76,7 @@
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('commercial.clients.form.submit')" :label="t('commercial.clients.form.submit')"
:disabled="!isMainValid || mainSubmitting" :disabled="mainSubmitting"
@click="submitMain" @click="submitMain"
/> />
</div> </div>
@@ -84,13 +86,15 @@
<!-- 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 : aligne le bord superieur du textarea sur celui des <!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs, dont
inputs (centres dans un conteneur h-12, soit ~4px de retrait haut). --> le champ de 40px est centre dans un conteneur h-12 (~4px de
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" group-class="row-span-2 pt-1 pb-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"
@@ -105,6 +109,7 @@
v-model="information.foundedAt" v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')" :label="t('commercial.clients.form.information.foundedAt')"
:readonly="isValidated('information')" :readonly="isValidated('information')"
:editable="true"
:error="informationErrors.errors.foundedAt" :error="informationErrors.errors.foundedAt"
/> />
<MalioInputText <MalioInputText
@@ -134,9 +139,10 @@
/> />
</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 l'utilisateur clique trop tot (le panneau avant le POST si clic trop tot, Information etant l'onglet
Information est l'onglet actif par defaut). --> actif par defaut). Onglet facultatif : un enregistrement a
vide reste possible, c'est le back qui valide. -->
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('commercial.clients.form.submit')" :label="t('commercial.clients.form.submit')"
@@ -172,7 +178,7 @@
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('commercial.clients.form.submit')" :label="t('commercial.clients.form.submit')"
:disabled="!canValidateContacts || tabSubmitting" :disabled="tabSubmitting"
@click="submitContacts" @click="submitContacts"
/> />
</div> </div>
@@ -204,12 +210,13 @@
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
variant="primary" variant="primary"
:label="t('commercial.clients.form.submit')" :label="t('commercial.clients.form.submit')"
:disabled="!canValidateAddresses || tabSubmitting" :disabled="tabSubmitting"
@click="submitAddresses" @click="submitAddresses"
/> />
</div> </div>
@@ -287,9 +294,9 @@
</div> </div>
</div> </div>
<!-- Blocs RIB (0..n) — obligatoires si type de reglement = LCR. --> <!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13). -->
<div <div
v-for="(rib, index) in ribs" v-for="(rib, index) in visibleRibs"
: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)]"
> >
@@ -329,16 +336,18 @@
<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
variant="primary" variant="primary"
:label="t('commercial.clients.form.submit')" :label="t('commercial.clients.form.submit')"
:disabled="!canValidateAccounting || tabSubmitting" :disabled="tabSubmitting"
@click="submitAccounting" @click="submitAccounting"
/> />
</div> </div>
@@ -380,18 +389,24 @@ 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, isAddressValid,
hasAtLeastOneValidContact,
isBankRequiredForPaymentType, isBankRequiredForPaymentType,
isBillingEmailRequired, isBillingEmailRequired,
isContactBlank, isContactBlank,
isContactNamed, isContactNamed,
isRibBlank, isRibBlank,
isRibComplete,
isRibRequiredForPaymentType, isRibRequiredForPaymentType,
lastFillableTabKey,
showsRelationAndTriageFields,
} from '~/modules/commercial/utils/clientFormRules' } from '~/modules/commercial/utils/clientFormRules'
import {
buildAddressPayload,
buildMainPayload,
buildRibPayload,
} from '~/modules/commercial/utils/clientEdit'
import { import {
emptyAddress, emptyAddress,
emptyContact, emptyContact,
@@ -483,23 +498,26 @@ const relationOptions = computed<RefOption[]>(() => [
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') }, { value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
]) ])
// Validation du formulaire principal (gate le bouton « Valider ») : // Codes des categories selectionnees (resolus depuis les IRI du brouillon).
// - companyName / >= 1 categorie obligatoires ; const selectedCategoryCodes = computed(() =>
// - relation Distributeur/Courtier optionnelle, mais le nom correspondant main.categoryIris
// devient requis si l'un des deux est choisi (spec fonctionnelle). .map(iri => referentials.categories.value.find(c => c.value === iri)?.code)
// Les coordonnees de contact ne sont plus saisies ici : elles vivent dans .filter((code): code is string => code !== undefined),
// l'onglet Contacts (RG-1.05/1.14 garantissent >= 1 contact valide). )
const isMainValid = computed(() => {
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== '' // « Relation » + « Prestation de triage » masques par defaut, reveles des qu'une
// Relation Distributeur/Courtier OPTIONNELLE ; mais si « Depend du // categorie ordinaire (autre que Distributeur/Courtier) est selectionnee.
// distributeur/courtier » est choisi, le nom correspondant devient requis. const showRelationAndTriage = computed(() => showsRelationAndTriageFields(selectedCategoryCodes.value))
const relationValid
= main.relationType === null // Masquer ces champs reinitialise leurs valeurs : pas de relation/triage fantome
|| (main.relationType === 'distributeur' && filled(main.distributorIri)) // soumis pour un client Distributeur/Courtier.
|| (main.relationType === 'courtier' && filled(main.brokerIri)) watch(showRelationAndTriage, (visible) => {
return filled(main.companyName) if (!visible) {
&& main.categoryIris.length >= 1 main.relationType = null
&& relationValid main.distributorIri = null
main.brokerIri = null
main.triageService = false
}
}) })
async function onRelationChange(value: string | number | null): Promise<void> { async function onRelationChange(value: string | number | null): Promise<void> {
@@ -517,18 +535,13 @@ async function onRelationChange(value: string | number | null): Promise<void> {
/** POST /clients (groupe client:write:main). Au succes : verrouille + bascule Information. */ /** POST /clients (groupe client:write:main). Au succes : verrouille + bascule Information. */
async function submitMain(): Promise<void> { async function submitMain(): Promise<void> {
if (!isMainValid.value || mainSubmitting.value) return if (mainSubmitting.value) return
mainSubmitting.value = true mainSubmitting.value = true
mainErrors.clearErrors() mainErrors.clearErrors()
try { try {
const payload: Record<string, unknown> = { // Payload partage avec l'edition (buildMainPayload) : meme logique
companyName: main.companyName, // d'omission des requis vides et meme envoi de relationType (ERP-119).
categories: main.categoryIris, const created = await api.post<ClientResponse>('/clients', buildMainPayload(main), {
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
broker: main.relationType === 'courtier' ? main.brokerIri : null,
triageService: main.triageService,
}
const created = await api.post<ClientResponse>('/clients', payload, {
headers: { Accept: 'application/ld+json' }, headers: { Accept: 'application/ld+json' },
toast: false, toast: false,
}) })
@@ -538,7 +551,9 @@ async function submitMain(): Promise<void> {
main.companyName = created.companyName ?? main.companyName main.companyName = created.companyName ?? main.companyName
mainLocked.value = true mainLocked.value = true
unlockedIndex.value = 0 // Information est facultatif : on deverrouille jusqu'a Contact (index 1)
// 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') })
} }
@@ -570,6 +585,12 @@ const validated = reactive<Record<string, boolean>>({})
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value)) const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value))
// Dernier onglet REMPLISSABLE par le role (cf. lastFillableTabKey) : deja role-aware
// via tabKeys (accounting present ssi accounting.view, et a la creation « present » =
// « editable » : aucun role createur n'a la Compta en lecture seule). Sa validation
// cloture l'ajout -> redirection vers la liste.
const lastFillableTab = computed(() => lastFillableTabKey(tabKeys.value))
// Icone (Iconify) affichee dans l'onglet, par cle. A ajuster librement. // Icone (Iconify) affichee dans l'onglet, par cle. A ajuster librement.
const TAB_ICONS: Record<string, string> = { const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline', information: 'mdi:account-outline',
@@ -597,12 +618,23 @@ function tabIndex(key: string): number {
return tabKeys.value.indexOf(key) return tabKeys.value.indexOf(key)
} }
/** Marque l'onglet valide, deverrouille et avance automatiquement au suivant. */ /**
function completeTab(key: string): void { * Marque l'onglet valide. Si c'est le dernier onglet remplissable, l'ajout est
* termine : toast final + redirection vers la liste, et on retourne true pour que
* l'appelant n'affiche pas son toast « mis a jour ». Sinon, deverrouille et avance
* a l'onglet suivant, et retourne false.
*/
function completeTab(key: string): boolean {
validated[key] = true validated[key] = true
if (key === lastFillableTab.value) {
toast.success({ title: t('commercial.clients.toast.addComplete') })
router.push('/clients')
return true
}
const next = tabKeys.value[tabIndex(key) + 1] const next = tabKeys.value[tabIndex(key) + 1]
unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1) unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1)
if (next) activeTab.value = next if (next) activeTab.value = next
return false
} }
// Passage automatique sur les onglets coquille (Transport, Stats, Rapports, Echanges). // Passage automatique sur les onglets coquille (Transport, Stats, Rapports, Echanges).
@@ -640,7 +672,7 @@ async function submitInformation(): Promise<void> {
profitAmount: information.profitAmount || null, profitAmount: information.profitAmount || null,
directorName: information.directorName || null, directorName: information.directorName || null,
}, { toast: false }) }, { toast: false })
completeTab('information') if (completeTab('information')) return
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (error) { catch (error) {
@@ -662,9 +694,6 @@ const canAddContact = computed(() => {
return last !== undefined && isContactNamed(last) return last !== undefined && isContactNamed(last)
}) })
// RG-1.14 : au moins un contact nomme pour finaliser l'onglet.
const canValidateContacts = computed(() => hasAtLeastOneValidContact(contacts.value))
function addContact(): void { function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact()) if (canAddContact.value) contacts.value.push(emptyContact())
} }
@@ -678,9 +707,14 @@ function askRemoveContact(index: number): void {
/** POST/PATCH des contacts sur la sous-ressource /clients/{id}/contacts. */ /** POST/PATCH des contacts sur la sous-ressource /clients/{id}/contacts. */
async function submitContacts(): Promise<void> { async function submitContacts(): Promise<void> {
if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return if (clientId.value === null || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
try { try {
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des
// amorces neuves vides, on ne les skippe pas -> le bloc vide est POSTe et
// le back renvoie la 422 RG-1.05 « prénom ou nom obligatoire » inline (la
// RG-1.14 n'a pas d'equivalent back au POST, on la materialise via RG-1.05).
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
// On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls // On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls
// les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli // les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli
// sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc. // sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc.
@@ -710,14 +744,14 @@ async function submitContacts(): Promise<void> {
} }
}, },
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }), error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
// On ne saute QUE les amorces neuves (id null) totalement vides. Un // On ne saute une amorce neuve (id null) totalement vide QUE si un autre
// bloc existant vide est soumis -> 422 RG-1.05 inline (sinon la modif // bloc sera soumis : sinon on la soumet pour declencher la 422 RG-1.05
// serait perdue en silence avec un faux toast de succes). // (un onglet Contact vide ne doit pas passer en faux succes).
contact => contact.id === null && isContactBlank(contact), contact => hasSubmittableContact && contact.id === null && isContactBlank(contact),
) )
// Tant qu'un bloc reste en erreur : pas de validation d'onglet ni de toast succes. // Tant qu'un bloc reste en erreur : pas de validation d'onglet ni de toast succes.
if (hasError) return if (hasError) return
completeTab('contact') if (completeTab('contact')) return
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
finally { finally {
@@ -750,21 +784,14 @@ const countryOptions: RefOption[] = [
{ value: 'Espagne', label: 'Espagne' }, { value: 'Espagne', label: 'Espagne' },
] ]
// Type d'adresse (Select) obligatoire + RG-1.10 (>= 1 site) + RG-1.11 (email // « + Adresse » desactive tant que la derniere adresse n'est pas valide.
// facturation si Facturation) sur chaque adresse. const canAddAddress = computed(() => {
const canValidateAddresses = computed(() => const last = addresses.value[addresses.value.length - 1]
addresses.value.length > 0 return last !== undefined && isAddressValid(last)
&& 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)
}),
)
function addAddress(): void { function addAddress(): void {
addresses.value.push(emptyAddress()) if (canAddAddress.value) addresses.value.push(emptyAddress())
} }
function askRemoveAddress(index: number): void { function askRemoveAddress(index: number): void {
@@ -786,7 +813,7 @@ function onAddressDegraded(): void {
/** POST des adresses sur la sous-ressource /clients/{id}/addresses. */ /** POST des adresses sur la sous-ressource /clients/{id}/addresses. */
async function submitAddresses(): Promise<void> { async function submitAddresses(): Promise<void> {
if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return if (clientId.value === null || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
try { try {
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110). // On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
@@ -794,20 +821,8 @@ async function submitAddresses(): Promise<void> {
addresses.value, addresses.value,
addressErrors, addressErrors,
async (address) => { async (address) => {
const body = { // Payload partage avec l'edition (buildAddressPayload, ERP-119).
isProspect: address.isProspect, const body = buildAddressPayload(address, isBillingEmailRequired(address))
isDelivery: address.isDelivery,
isBilling: address.isBilling,
country: address.country,
postalCode: address.postalCode || null,
city: address.city || null,
street: address.street || null,
streetComplement: address.streetComplement || null,
categories: address.categoryIris,
sites: address.siteIris,
contacts: address.contactIris,
billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null,
}
if (address.id === null) { if (address.id === null) {
const created = await api.post<{ id: number }>( const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/addresses`, `/clients/${clientId.value}/addresses`,
@@ -823,7 +838,7 @@ async function submitAddresses(): Promise<void> {
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }), error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
) )
if (hasError) return if (hasError) return
completeTab('address') if (completeTab('address')) return
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
finally { finally {
@@ -853,29 +868,32 @@ 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) {
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}
else {
ribs.value = []
ribErrors.value = []
}
} }
function ribIsComplete(rib: RibFormDraft): boolean { // « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
const filled = (v: string | null) => v !== null && v.trim() !== '' const canAddRib = computed(() => {
return filled(rib.label) && filled(rib.bic) && filled(rib.iban) const last = ribs.value[ribs.value.length - 1]
} return last !== undefined && isRibComplete(last)
// RG-1.30 : les 6 champs scalaires obligatoires (comme les onglets Contact /
// Adresse, le bouton reste desactive tant que l'onglet n'est pas complet).
// RG-1.12 : banque requise si VIREMENT. RG-1.13 : >= 1 RIB complet si LCR.
const canValidateAccounting = computed(() => {
if (!hasAllRequiredAccountingFields(accounting)) return false
if (isBankRequired.value && (accounting.bankIri === null)) return false
if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false
return true
}) })
function addRib(): void { function addRib(): void {
ribs.value.push(emptyRib()) if (canAddRib.value) ribs.value.push(emptyRib())
} }
function askRemoveRib(index: number): void { function askRemoveRib(index: number): void {
@@ -888,44 +906,28 @@ function askRemoveRib(index: number): void {
} }
/** /**
* Valide l'onglet Comptabilite : PATCH des scalaires (groupe client:write:accounting) * Valide l'onglet Comptabilite : POST/PATCH des RIB sur /clients/{id}/ribs PUIS
* PUIS POST des RIB sur /clients/{id}/ribs. Deux appels distincts (mode strict * PATCH des scalaires (groupe client:write:accounting). Les RIB d'abord : le back
* RG-1.28 : il n'existe pas d'endpoint /accounting, cf. recon back). * valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires, les RIB
* doivent donc exister en base AVANT (sinon 422 « Au moins un RIB est obligatoire
* pour le type de reglement LCR »). Deux appels distincts (mode strict RG-1.28 :
* il n'existe pas d'endpoint /accounting, cf. recon back).
*/ */
async function submitAccounting(): Promise<void> { async function submitAccounting(): Promise<void> {
if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return if (clientId.value === null || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
accountingErrors.clearErrors() accountingErrors.clearErrors()
// Reset des erreurs RIB des le debut : l'etape 1 (PATCH scalaires) peut
// echouer et `return` avant submitRows (qui porte sinon le reset), laissant
// des erreurs de RIB obsoletes affichees sous les blocs.
ribErrors.value = []
try { try {
// 1) PATCH des scalaires comptables (erreurs inline sur leurs champs). // 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs
try { // tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2.
await api.patch(`/clients/${clientId.value}`, {
siren: accounting.siren || null,
accountNumber: accounting.accountNumber || null,
tvaMode: accounting.tvaModeIri,
nTva: accounting.nTva || null,
paymentDelay: accounting.paymentDelayIri,
paymentType: accounting.paymentTypeIri,
bank: isBankRequired.value ? accounting.bankIri : null,
}, { toast: false })
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
return
}
// 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes).
// Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex. // Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex.
// IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline. // IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline.
const ribHasError = await submitRows( const ribHasError = await submitRows(
ribs.value, ribs.value,
ribErrors, ribErrors,
async (rib) => { async (rib) => {
const body = { label: rib.label, bic: rib.bic, iban: rib.iban } // Payload partage avec l'edition (buildRibPayload, ERP-119).
const body = buildRibPayload(rib)
if (rib.id === null) { if (rib.id === null) {
const created = await api.post<{ id: number }>( const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/ribs`, `/clients/${clientId.value}/ribs`,
@@ -946,7 +948,24 @@ async function submitAccounting(): Promise<void> {
) )
if (ribHasError) return if (ribHasError) return
completeTab('accounting') // 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try {
await api.patch(`/clients/${clientId.value}`, {
siren: accounting.siren || null,
accountNumber: accounting.accountNumber || null,
tvaMode: accounting.tvaModeIri,
nTva: accounting.nTva || null,
paymentDelay: accounting.paymentDelayIri,
paymentType: accounting.paymentTypeIri,
bank: isBankRequired.value ? accounting.bankIri : null,
}, { toast: false })
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
return
}
if (completeTab('accounting')) return
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
finally { finally {
@@ -987,8 +1006,7 @@ 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(() => {})
// Au moins un bloc RIB toujours visible en creation : on amorce un bloc vide // Pas d'amorce de RIB ici : un bloc vide n'apparait que si LCR est choisi
// (non persiste tant qu'incomplet — RG-1.13). // (cf. onPaymentTypeChange).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}) })
</script> </script>
@@ -30,6 +30,10 @@ export interface AddressFormDraft {
isProspect: boolean isProspect: boolean
isDelivery: boolean isDelivery: boolean
isBilling: boolean isBilling: boolean
/** Adresse Courtier — type autonome exclusif. */
isBroker: boolean
/** Adresse Distributeur — type autonome exclusif. */
isDistributor: boolean
country: string country: string
postalCode: string | null postalCode: string | null
city: string | null city: string | null
@@ -43,6 +47,10 @@ export interface AddressFormDraft {
contactIris: string[] contactIris: string[]
/** Email de facturation (obligatoire si isBilling — RG-1.11). */ /** Email de facturation (obligatoire si isBilling — RG-1.11). */
billingEmail: string | null billingEmail: string | null
/** 2e email de facturation, optionnel (max 2 — pendant du telephone secondaire). */
billingEmailSecondary: string | null
/** Drapeau UI : 2e champ email revele (comme hasSecondaryPhone). */
hasSecondaryBillingEmail: boolean
} }
/** Un RIB du client (onglet Comptabilite). */ /** Un RIB du client (onglet Comptabilite). */
@@ -75,6 +83,8 @@ export function emptyAddress(): AddressFormDraft {
isProspect: false, isProspect: false,
isDelivery: false, isDelivery: false,
isBilling: false, isBilling: false,
isBroker: false,
isDistributor: false,
country: 'France', country: 'France',
postalCode: null, postalCode: null,
city: null, city: null,
@@ -84,6 +94,8 @@ export function emptyAddress(): AddressFormDraft {
siteIris: [], siteIris: [],
contactIris: [], contactIris: [],
billingEmail: null, billingEmail: null,
billingEmailSecondary: null,
hasSecondaryBillingEmail: false,
} }
} }
@@ -61,7 +61,9 @@ function accountingDraft(overrides: Partial<AccountingFormDraft> = {}): Accounti
// Le contact inline (nom/prenom/telephones/email) ne fait plus partie du groupe // Le contact inline (nom/prenom/telephones/email) ne fait plus partie du groupe
// main : les coordonnees vivent desormais sur la sous-ressource ClientContact. // main : les coordonnees vivent desormais sur la sous-ressource ClientContact.
const MAIN_KEYS = [ const MAIN_KEYS = [
'companyName', 'categories', 'distributor', 'broker', 'triageService', // relationType : champ transitoire envoye au back pour la validation croisee
// « relation choisie => FK obligatoire » (RG-1.03 bis, ERP-119).
'companyName', 'categories', 'relationType', 'distributor', 'broker', 'triageService',
] ]
const INFORMATION_KEYS = [ const INFORMATION_KEYS = [
'description', 'competitors', 'foundedAt', 'employeesCount', 'description', 'competitors', 'foundedAt', 'employeesCount',
@@ -99,6 +101,27 @@ describe('buildMainPayload — scoping strict groupe client:write:main', () => {
expect(payload.distributor).toBeNull() expect(payload.distributor).toBeNull()
expect(payload.broker).toBeNull() expect(payload.broker).toBeNull()
}) })
it('transmet relationType au back pour la validation croisee (RG-1.03 bis)', () => {
expect(buildMainPayload(mainDraft({ relationType: 'distributeur' })).relationType).toBe('distributeur')
expect(buildMainPayload(mainDraft({ relationType: 'courtier' })).relationType).toBe('courtier')
expect(buildMainPayload(mainDraft({ relationType: null })).relationType).toBeNull()
})
// ERP-119 : companyName est requis ET adosse a une colonne NON-nullable. Si le
// champ est vide, on OMET la cle (au lieu d'envoyer null) pour que le back
// renvoie une 422 NotBlank (propertyPath companyName) et non un 400 de type.
it('omet companyName quand il est vide (null) -> 422 NotBlank cote back', () => {
expect('companyName' in buildMainPayload(mainDraft({ companyName: null }))).toBe(false)
})
it('omet companyName quand il est une chaine vide', () => {
expect('companyName' in buildMainPayload(mainDraft({ companyName: '' }))).toBe(false)
})
it('conserve companyName quand il est renseigne', () => {
expect(buildMainPayload(mainDraft({ companyName: 'ACME' })).companyName).toBe('ACME')
})
}) })
describe('buildInformationPayload — scoping strict groupe client:write:information', () => { describe('buildInformationPayload — scoping strict groupe client:write:information', () => {
@@ -142,19 +165,50 @@ describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => {
it('adresse : email facturation conserve uniquement si requis (RG-1.11)', () => { it('adresse : email facturation conserve uniquement si requis (RG-1.11)', () => {
const address: AddressFormDraft = { const address: AddressFormDraft = {
id: 3, isProspect: false, isDelivery: false, isBilling: true, country: 'France', id: 3, isProspect: false, isDelivery: false, isBilling: true, isBroker: false, isDistributor: false, country: 'France',
postalCode: '86100', city: 'Châtellerault', street: '1 rue X', streetComplement: null, postalCode: '86100', city: 'Châtellerault', street: '1 rue X', streetComplement: null,
categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [], categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [],
billingEmail: 'facturation@acme.fr', billingEmail: 'facturation@acme.fr', billingEmailSecondary: 'compta@acme.fr', hasSecondaryBillingEmail: true,
} }
expect(buildAddressPayload(address, true).billingEmail).toBe('facturation@acme.fr') expect(buildAddressPayload(address, true).billingEmail).toBe('facturation@acme.fr')
expect(buildAddressPayload(address, false).billingEmail).toBeNull() expect(buildAddressPayload(address, false).billingEmail).toBeNull()
// 2e email : transmis si facturation + revele, sinon null (ERP-119).
expect(buildAddressPayload(address, true).billingEmailSecondary).toBe('compta@acme.fr')
expect(buildAddressPayload(address, false).billingEmailSecondary).toBeNull()
expect(buildAddressPayload({ ...address, hasSecondaryBillingEmail: false }, true).billingEmailSecondary).toBeNull()
}) })
it('rib : label / bic / iban transmis tels quels', () => { it('rib : label / bic / iban transmis tels quels', () => {
const rib: RibFormDraft = { id: 1, label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' } const rib: RibFormDraft = { id: 1, label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' }
expect(buildRibPayload(rib)).toEqual({ label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' }) expect(buildRibPayload(rib)).toEqual({ label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' })
}) })
// ERP-119 : un RIB partiel (IBAN seul) doit omettre label/bic vides pour
// declencher la 422 NotBlank par champ, pas un 400 de type a la deserialisation.
it('rib partiel : omet label / bic vides, conserve iban', () => {
const rib: RibFormDraft = { id: null, label: null, bic: null, iban: 'FR7612345' }
const payload = buildRibPayload(rib)
expect('label' in payload).toBe(false)
expect('bic' in payload).toBe(false)
expect(payload.iban).toBe('FR7612345')
})
// ERP-119 : une adresse partielle omet postalCode/city/street vides (NotBlank).
it('adresse partielle : omet postalCode / city / street vides', () => {
const address: AddressFormDraft = {
id: null, isProspect: false, isDelivery: true, isBilling: false, isBroker: false, isDistributor: false, country: 'France',
postalCode: null, city: '', street: null, streetComplement: null,
categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [],
billingEmail: null, billingEmailSecondary: null, hasSecondaryBillingEmail: false,
}
const payload = buildAddressPayload(address, false)
expect('postalCode' in payload).toBe(false)
expect('city' in payload).toBe(false)
expect('street' in payload).toBe(false)
// Les champs non requis / booleens restent presents.
expect(payload.isDelivery).toBe(true)
expect(payload.sites).toEqual(['/api/sites/1'])
})
}) })
describe('mapMainDraft — pre-remplissage bloc principal', () => { describe('mapMainDraft — pre-remplissage bloc principal', () => {
@@ -7,14 +7,22 @@ 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,
lastFillableTabKey,
omitEmptyRequired,
showsRelationAndTriageFields,
type AddressFlagsDraft,
type AddressValidityDraft,
type ContactDraft, type ContactDraft,
type ContactFillableDraft, type ContactFillableDraft,
} from '../clientFormRules' } from '../clientFormRules'
@@ -63,6 +71,24 @@ describe('buildClientFormTabKeys (gating onglet Comptabilite + onglets edit-only
}) })
}) })
describe('lastFillableTabKey (redirection fin d\'ajout, role-aware)', () => {
it('Adresse pour un role sans Comptabilite (Bureau / Commerciale)', () => {
expect(lastFillableTabKey(buildClientFormTabKeys(false))).toBe('address')
})
it('Comptabilite pour un role avec accounting.view (Admin)', () => {
expect(lastFillableTabKey(buildClientFormTabKeys(true))).toBe('accounting')
})
it('ignore les onglets placeholder (Transport en dernier ne compte pas)', () => {
expect(lastFillableTabKey(['information', 'contact', 'address', 'transport'])).toBe('address')
})
it('undefined si aucun onglet remplissable (que des placeholders)', () => {
expect(lastFillableTabKey(['transport', 'statistics'])).toBeUndefined()
})
})
describe('isContactNamed (RG-1.05)', () => { describe('isContactNamed (RG-1.05)', () => {
it('vrai si le prenom est renseigne', () => { it('vrai si le prenom est renseigne', () => {
expect(isContactNamed({ firstName: 'Alice', lastName: null })).toBe(true) expect(isContactNamed({ firstName: 'Alice', lastName: null })).toBe(true)
@@ -143,83 +169,79 @@ describe('hasAtLeastOneValidContact (RG-1.14)', () => {
}) })
}) })
/** Drapeaux d'adresse complets (5 types) avec surcharge partielle. */
function flags(overrides: Partial<AddressFlagsDraft> = {}): AddressFlagsDraft {
return {
isProspect: false, isDelivery: false, isBilling: false, isBroker: false, isDistributor: false,
...overrides,
}
}
describe('exclusivite Prospect / Livraison / Facturation (RG-1.06/07/08)', () => { describe('exclusivite Prospect / Livraison / Facturation (RG-1.06/07/08)', () => {
it('Prospect est selectionnable tant que ni Livraison ni Facturation', () => { it('Prospect est selectionnable tant que ni Livraison ni Facturation', () => {
expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true) expect(canSelectProspect(flags())).toBe(true)
expect(canSelectProspect({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false) expect(canSelectProspect(flags({ isDelivery: true }))).toBe(false)
expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: true })).toBe(false) expect(canSelectProspect(flags({ isBilling: true }))).toBe(false)
}) })
it('Livraison / Facturation selectionnables tant que pas Prospect', () => { it('Livraison / Facturation selectionnables tant que pas Prospect', () => {
expect(canSelectDeliveryOrBilling({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true) expect(canSelectDeliveryOrBilling(flags())).toBe(true)
expect(canSelectDeliveryOrBilling({ isProspect: true, isDelivery: false, isBilling: false })).toBe(false) expect(canSelectDeliveryOrBilling(flags({ isProspect: true }))).toBe(false)
}) })
it('cocher Prospect efface Livraison et Facturation', () => { it('cocher Prospect efface Livraison et Facturation', () => {
const next = applyProspectExclusivity( const next = applyProspectExclusivity(flags({ isDelivery: true, isBilling: true }), 'isProspect', true)
{ isProspect: false, isDelivery: true, isBilling: true }, expect(next).toEqual(flags({ isProspect: true }))
'isProspect',
true,
)
expect(next).toEqual({ isProspect: true, isDelivery: false, isBilling: false })
}) })
it('cocher Livraison efface Prospect', () => { it('cocher Livraison efface Prospect', () => {
const next = applyProspectExclusivity( const next = applyProspectExclusivity(flags({ isProspect: true }), 'isDelivery', true)
{ isProspect: true, isDelivery: false, isBilling: false }, expect(next).toEqual(flags({ isDelivery: true }))
'isDelivery',
true,
)
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
}) })
it('cocher Facturation efface Prospect mais conserve Livraison', () => { it('cocher Facturation efface Prospect mais conserve Livraison', () => {
const next = applyProspectExclusivity( const next = applyProspectExclusivity(flags({ isProspect: true, isDelivery: true }), 'isBilling', true)
{ isProspect: true, isDelivery: true, isBilling: false }, expect(next).toEqual(flags({ isDelivery: true, isBilling: true }))
'isBilling',
true,
)
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: true })
}) })
it('decocher un drapeau ne reactive rien d autre', () => { it('decocher un drapeau ne reactive rien d autre', () => {
const next = applyProspectExclusivity( const next = applyProspectExclusivity(flags({ isDelivery: true, isBilling: true }), 'isBilling', false)
{ isProspect: false, isDelivery: true, isBilling: true }, expect(next).toEqual(flags({ isDelivery: true }))
'isBilling',
false,
)
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
}) })
}) })
describe('isBillingEmailRequired (RG-1.11)', () => { describe('isBillingEmailRequired (RG-1.11)', () => {
it('obligatoire uniquement si Facturation est coche', () => { it('obligatoire uniquement si Facturation est coche', () => {
expect(isBillingEmailRequired({ isProspect: false, isDelivery: false, isBilling: true })).toBe(true) expect(isBillingEmailRequired(flags({ isBilling: true }))).toBe(true)
expect(isBillingEmailRequired({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false) expect(isBillingEmailRequired(flags({ isDelivery: true }))).toBe(false)
}) })
}) })
describe('type d\'adresse (Select front) <-> drapeaux back', () => { describe('type d\'adresse (Select front) <-> drapeaux back', () => {
it('addressFlagsFromType mappe chaque type vers les bons drapeaux', () => { it('addressFlagsFromType mappe chaque type vers les bons drapeaux', () => {
expect(addressFlagsFromType('prospect')).toEqual({ isProspect: true, isDelivery: false, isBilling: false }) expect(addressFlagsFromType('prospect')).toEqual(flags({ isProspect: true }))
expect(addressFlagsFromType('delivery')).toEqual({ isProspect: false, isDelivery: true, isBilling: false }) expect(addressFlagsFromType('delivery')).toEqual(flags({ isDelivery: true }))
expect(addressFlagsFromType('billing')).toEqual({ isProspect: false, isDelivery: false, isBilling: true }) expect(addressFlagsFromType('billing')).toEqual(flags({ isBilling: true }))
expect(addressFlagsFromType('delivery_billing')).toEqual({ isProspect: false, isDelivery: true, isBilling: true }) expect(addressFlagsFromType('delivery_billing')).toEqual(flags({ isDelivery: true, isBilling: true }))
expect(addressFlagsFromType('broker')).toEqual(flags({ isBroker: true }))
expect(addressFlagsFromType('distributor')).toEqual(flags({ isDistributor: true }))
}) })
it('addressTypeFromFlags reconstruit le type (Prospect prioritaire, livraison+facturation groupes)', () => { it('addressTypeFromFlags reconstruit le type (Prospect/Courtier/Distributeur autonomes, livraison+facturation groupes)', () => {
expect(addressTypeFromFlags({ isProspect: true, isDelivery: false, isBilling: false })).toBe('prospect') expect(addressTypeFromFlags(flags({ isProspect: true }))).toBe('prospect')
expect(addressTypeFromFlags({ isProspect: false, isDelivery: true, isBilling: false })).toBe('delivery') expect(addressTypeFromFlags(flags({ isDelivery: true }))).toBe('delivery')
expect(addressTypeFromFlags({ isProspect: false, isDelivery: false, isBilling: true })).toBe('billing') expect(addressTypeFromFlags(flags({ isBilling: true }))).toBe('billing')
expect(addressTypeFromFlags({ isProspect: false, isDelivery: true, isBilling: true })).toBe('delivery_billing') expect(addressTypeFromFlags(flags({ isDelivery: true, isBilling: true }))).toBe('delivery_billing')
expect(addressTypeFromFlags(flags({ isBroker: true }))).toBe('broker')
expect(addressTypeFromFlags(flags({ isDistributor: true }))).toBe('distributor')
}) })
it('addressTypeFromFlags retourne null quand aucun drapeau (amorce vierge -> bouton bloque)', () => { it('addressTypeFromFlags retourne null quand aucun drapeau (amorce vierge -> bouton bloque)', () => {
expect(addressTypeFromFlags({ isProspect: false, isDelivery: false, isBilling: false })).toBeNull() expect(addressTypeFromFlags(flags())).toBeNull()
}) })
it('aller-retour type -> drapeaux -> type stable pour les 4 types', () => { it('aller-retour type -> drapeaux -> type stable pour les 6 types', () => {
for (const type of ['prospect', 'delivery', 'billing', 'delivery_billing'] as const) { for (const type of ['prospect', 'delivery', 'billing', 'delivery_billing', 'broker', 'distributor'] as const) {
expect(addressTypeFromFlags(addressFlagsFromType(type))).toBe(type) expect(addressTypeFromFlags(addressFlagsFromType(type))).toBe(type)
} }
}) })
@@ -271,3 +293,128 @@ 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,
isBroker: false,
isDistributor: 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)
})
})
describe('omitEmptyRequired (ERP-119 : 422 NotBlank au lieu de 400 de type)', () => {
it('retire les cles requises vides (null / vide / undefined)', () => {
const payload = omitEmptyRequired(
{ companyName: null, label: '', iban: undefined, categories: ['/api/categories/1'] },
['companyName', 'label', 'iban'],
)
expect('companyName' in payload).toBe(false)
expect('label' in payload).toBe(false)
expect('iban' in payload).toBe(false)
// Les cles hors liste ne sont jamais touchees.
expect(payload.categories).toEqual(['/api/categories/1'])
})
it('conserve les cles requises renseignees', () => {
const payload = omitEmptyRequired({ companyName: 'ACME', bic: 'BNPAFRPP' }, ['companyName', 'bic'])
expect(payload).toEqual({ companyName: 'ACME', bic: 'BNPAFRPP' })
})
it('ne retire jamais une cle hors de la liste requise, meme vide', () => {
const payload = omitEmptyRequired({ streetComplement: null }, ['street'])
expect('streetComplement' in payload).toBe(true)
expect(payload.streetComplement).toBeNull()
})
it('false / 0 ne sont pas consideres vides (booleens / nombres preserves)', () => {
const payload = omitEmptyRequired({ isDelivery: false, position: 0 }, ['isDelivery', 'position'])
expect(payload).toEqual({ isDelivery: false, position: 0 })
})
})
@@ -63,9 +63,12 @@ export interface AddressRead extends HydraRef {
street?: string | null street?: string | null
streetComplement?: string | null streetComplement?: string | null
billingEmail?: string | null billingEmail?: string | null
billingEmailSecondary?: string | null
isProspect?: boolean isProspect?: boolean
isDelivery?: boolean isDelivery?: boolean
isBilling?: boolean isBilling?: boolean
isBroker?: boolean
isDistributor?: boolean
sites?: SiteRead[] sites?: SiteRead[]
categories?: CategoryRead[] categories?: CategoryRead[]
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu. // L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
@@ -209,6 +212,8 @@ export function mapAddressToDraft(address: AddressRead): AddressFormDraft {
isProspect: address.isProspect ?? false, isProspect: address.isProspect ?? false,
isDelivery: address.isDelivery ?? false, isDelivery: address.isDelivery ?? false,
isBilling: address.isBilling ?? false, isBilling: address.isBilling ?? false,
isBroker: address.isBroker ?? false,
isDistributor: address.isDistributor ?? false,
country: address.country ?? 'France', country: address.country ?? 'France',
postalCode: address.postalCode ?? null, postalCode: address.postalCode ?? null,
city: address.city ?? null, city: address.city ?? null,
@@ -218,6 +223,8 @@ export function mapAddressToDraft(address: AddressRead): AddressFormDraft {
siteIris: (address.sites ?? []).map(s => s['@id']), siteIris: (address.sites ?? []).map(s => s['@id']),
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])), contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
billingEmail: address.billingEmail ?? null, billingEmail: address.billingEmail ?? null,
billingEmailSecondary: address.billingEmailSecondary ?? null,
hasSecondaryBillingEmail: (address.billingEmailSecondary ?? null) !== null && address.billingEmailSecondary !== '',
} }
} }
+28 -10
View File
@@ -12,10 +12,8 @@
* *
* 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 RG-1.04 (Information obligatoire pour la Commerciale) : volontairement NON * NOTE : l'onglet Information est facultatif pour tous les roles (RG-1.04
* miroitee cote front (cf. clientFormRules.ts) — /api/me n'expose pas le code de * « Information obligatoire pour la Commerciale » retiree cote back).
* 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 {
@@ -23,6 +21,12 @@ import {
relationOf, relationOf,
type ClientDetail, type ClientDetail,
} from '~/modules/commercial/utils/clientConsultation' } from '~/modules/commercial/utils/clientConsultation'
import {
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
MAIN_REQUIRED_NON_NULLABLE_KEYS,
omitEmptyRequired,
RIB_REQUIRED_NON_NULLABLE_KEYS,
} from '~/modules/commercial/utils/clientFormRules'
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm' import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
/** /**
@@ -141,13 +145,21 @@ export function mapAccountingFormDraft(client: ClientDetail): AccountingFormDraf
* que la FK correspondant au type choisi, l'autre est forcee a null. * que la FK correspondant au type choisi, l'autre est forcee a null.
*/ */
export function buildMainPayload(main: MainFormDraft): Record<string, unknown> { export function buildMainPayload(main: MainFormDraft): Record<string, unknown> {
return { // companyName omis si vide -> 422 NotBlank au lieu d'un 400 de type (ERP-119).
// relationType : champ transitoire (non persiste cote back) qui porte
// l'intention UI « ce client depend d'un distributeur / courtier ». Il sert
// a la validation croisee serveur (RG-1.03 bis) : si une relation est choisie,
// la FK correspondante devient obligatoire -> 422 sur distributor / broker.
// Sans equivalent derivable cote back (FK nullable), c'est la seule facon de
// rester sur « on soumet, le back tranche » plutot qu'une garde front-only.
return omitEmptyRequired({
companyName: main.companyName, companyName: main.companyName,
categories: main.categoryIris, categories: main.categoryIris,
relationType: main.relationType,
distributor: main.relationType === 'distributeur' ? main.distributorIri : null, distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
broker: main.relationType === 'courtier' ? main.brokerIri : null, broker: main.relationType === 'courtier' ? main.brokerIri : null,
triageService: main.triageService, triageService: main.triageService,
} }, MAIN_REQUIRED_NON_NULLABLE_KEYS)
} }
/** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */ /** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */
@@ -200,10 +212,13 @@ export function buildAddressPayload(
address: AddressFormDraft, address: AddressFormDraft,
isBillingEmailRequired: boolean, isBillingEmailRequired: boolean,
): Record<string, unknown> { ): Record<string, unknown> {
return { // postalCode / city / street omis si vides -> 422 NotBlank (ERP-119).
return omitEmptyRequired({
isProspect: address.isProspect, isProspect: address.isProspect,
isDelivery: address.isDelivery, isDelivery: address.isDelivery,
isBilling: address.isBilling, isBilling: address.isBilling,
isBroker: address.isBroker,
isDistributor: address.isDistributor,
country: address.country, country: address.country,
postalCode: address.postalCode || null, postalCode: address.postalCode || null,
city: address.city || null, city: address.city || null,
@@ -213,16 +228,19 @@ export function buildAddressPayload(
sites: address.siteIris, sites: address.siteIris,
contacts: address.contactIris, contacts: address.contactIris,
billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null, billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null,
} billingEmailSecondary: isBillingEmailRequired && address.hasSecondaryBillingEmail ? (address.billingEmailSecondary || null) : null,
}, ADDRESS_REQUIRED_NON_NULLABLE_KEYS)
} }
/** Payload d'un RIB (sous-ressource client_rib). */ /** Payload d'un RIB (sous-ressource client_rib). */
export function buildRibPayload(rib: RibFormDraft): Record<string, unknown> { export function buildRibPayload(rib: RibFormDraft): Record<string, unknown> {
return { // label / bic / iban omis si vides -> 422 NotBlank au lieu d'un 400 de type
// sur un RIB partiel (ex. IBAN seul). ERP-119.
return omitEmptyRequired({
label: rib.label, label: rib.label,
bic: rib.bic, bic: rib.bic,
iban: rib.iban, iban: rib.iban,
} }, RIB_REQUIRED_NON_NULLABLE_KEYS)
} }
// ── Gating par permission ──────────────────────────────────────────────────── // ── Gating par permission ────────────────────────────────────────────────────
@@ -9,12 +9,9 @@
* 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 RG-1.04 (Information obligatoire pour la Commerciale) : volontairement * NOTE : l'onglet Information est facultatif pour tous les roles. L'ancienne
* NON miroite cote front pour l'instant. Le payload /api/me ne porte pas le code * RG-1.04 (« Information obligatoire pour la Commerciale ») a ete retiree cote
* de role (roles = IRIs opaques) et Bureau partage les memes permissions que * back — rien a miroiter ici.
* 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.
*/ */
/** /**
@@ -53,6 +50,38 @@ export function buildClientFormTabKeys(
return keys return keys
} }
/**
* Dernier onglet REMPLISSABLE d'un jeu d'onglets : le dernier qui n'est pas un
* placeholder (coquille). Role-aware sans regle ad hoc — il suffit de lui passer
* les `tabKeys` deja filtres par permission (l'onglet Comptabilite n'y figure que
* si accounting.view). Sa validation marque la fin de l'ajout (redirection liste).
*/
export function lastFillableTabKey(tabKeys: string[]): string | undefined {
return [...tabKeys].reverse().find(
key => !(CLIENT_FORM_PLACEHOLDER_TABS as readonly string[]).includes(key),
)
}
/**
* 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
@@ -64,6 +93,10 @@ export interface AddressFlagsDraft {
isProspect: boolean isProspect: boolean
isDelivery: boolean isDelivery: boolean
isBilling: boolean isBilling: boolean
/** Adresse Courtier — type autonome exclusif (comme isProspect). */
isBroker: boolean
/** Adresse Distributeur — type autonome exclusif (comme isProspect). */
isDistributor: boolean
} }
/** Vrai si une chaine porte au moins un caractere non-espace. */ /** Vrai si une chaine porte au moins un caractere non-espace. */
@@ -138,6 +171,16 @@ 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
@@ -193,22 +236,30 @@ export function isBillingEmailRequired(flags: AddressFlagsDraft): boolean {
* drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). Les seules * drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). Les seules
* combinaisons proposees respectent l'exclusivite Prospect (RG-1.06/07/08). * combinaisons proposees respectent l'exclusivite Prospect (RG-1.06/07/08).
*/ */
export type AddressType = 'prospect' | 'delivery' | 'billing' | 'delivery_billing' export type AddressType = 'prospect' | 'delivery' | 'billing' | 'delivery_billing' | 'broker' | 'distributor'
/** /**
* Mappe le type d'adresse choisi vers les trois drapeaux back. * Mappe le type d'adresse choisi vers les cinq drapeaux back.
* « Adresse + Facturation » = livraison ET facturation sur la meme adresse. * « Adresse + Facturation » = livraison ET facturation sur la meme adresse.
* Courtier / Distributeur sont autonomes (un seul drapeau, exclusif du reste).
*/ */
export function addressFlagsFromType(type: AddressType): AddressFlagsDraft { export function addressFlagsFromType(type: AddressType): AddressFlagsDraft {
const none: AddressFlagsDraft = {
isProspect: false, isDelivery: false, isBilling: false, isBroker: false, isDistributor: false,
}
switch (type) { switch (type) {
case 'prospect': case 'prospect':
return { isProspect: true, isDelivery: false, isBilling: false } return { ...none, isProspect: true }
case 'delivery': case 'delivery':
return { isProspect: false, isDelivery: true, isBilling: false } return { ...none, isDelivery: true }
case 'billing': case 'billing':
return { isProspect: false, isDelivery: false, isBilling: true } return { ...none, isBilling: true }
case 'delivery_billing': case 'delivery_billing':
return { isProspect: false, isDelivery: true, isBilling: true } return { ...none, isDelivery: true, isBilling: true }
case 'broker':
return { ...none, isBroker: true }
case 'distributor':
return { ...none, isDistributor: true }
} }
} }
@@ -219,6 +270,8 @@ export function addressFlagsFromType(type: AddressType): AddressFlagsDraft {
*/ */
export function addressTypeFromFlags(flags: AddressFlagsDraft): AddressType | null { export function addressTypeFromFlags(flags: AddressFlagsDraft): AddressType | null {
if (flags.isProspect) return 'prospect' if (flags.isProspect) return 'prospect'
if (flags.isBroker) return 'broker'
if (flags.isDistributor) return 'distributor'
if (flags.isDelivery && flags.isBilling) return 'delivery_billing' if (flags.isDelivery && flags.isBilling) return 'delivery_billing'
if (flags.isDelivery) return 'delivery' if (flags.isDelivery) return 'delivery'
if (flags.isBilling) return 'billing' if (flags.isBilling) return 'billing'
@@ -226,6 +279,31 @@ 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'
@@ -248,6 +326,36 @@ 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
@@ -276,3 +384,38 @@ export function hasAllRequiredAccountingFields(accounting: AccountingRequiredDra
&& filled(accounting.paymentDelayIri) && filled(accounting.paymentDelayIri)
&& filled(accounting.paymentTypeIri) && filled(accounting.paymentTypeIri)
} }
// ── Champs requis adosses a une colonne NON-nullable (ERP-119) ───────────────
// Ces champs requis (NotBlank back) sont portes par une colonne Doctrine NON
// nullable. Si le front envoie `null` (champ vide, desormais possible : le bouton
// « Valider » n'est plus desactive), API Platform rejette la valeur en 400 de TYPE
// a la deserialisation (« The type of the X attribute must be string, NULL given »)
// AVANT le Validator -> pas de violation, donc pas d'erreur rouge cote champ.
// La parade : OMETTRE la cle du payload quand elle est vide. Sans la cle, la
// propriete garde son defaut null cote entite et #[Assert\NotBlank] se declenche
// normalement -> 422 avec propertyPath, mappee en rouge sous le champ.
// (Les champs requis a colonne NULLABLE — contacts, scalaires compta — acceptent
// deja `null` et renvoient une 422 : inutile de les omettre.)
export const MAIN_REQUIRED_NON_NULLABLE_KEYS = ['companyName'] as const
export const ADDRESS_REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const
export const RIB_REQUIRED_NON_NULLABLE_KEYS = ['label', 'bic', 'iban'] as const
/**
* Retire d'un payload d'ecriture les cles requises laissees vides (null / ''
* / undefined), pour laisser le back produire une 422 NotBlank par champ plutot
* qu'un 400 de type sur une colonne non-nullable. Mute et retourne le payload.
* A n'appliquer QU'aux cles ci-dessus (champs requis a colonne non-nullable).
*/
export function omitEmptyRequired<T extends Record<string, unknown>>(
payload: T,
requiredKeys: readonly string[],
): T {
for (const key of requiredKeys) {
const value = payload[key]
if (value === null || value === undefined || value === '') {
delete payload[key]
}
}
return payload
}
@@ -9,7 +9,6 @@
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>
@@ -30,7 +29,7 @@
> >
<template #cell-action="{ item }"> <template #cell-action="{ item }">
<span <span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium" class="inline-flex items-center rounded-full px-2 py-0.5 font-medium"
:class="actionBadgeClass(item.action as string)" :class="actionBadgeClass(item.action as string)"
> >
{{ t(`audit.action.${item.action}`) }} {{ t(`audit.action.${item.action}`) }}
@@ -38,15 +37,14 @@
</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 class="font-mono text-xs">{{ item.entityId }}</span> <span>{{ item.entityId }}</span>
</template> </template>
<template #cell-summary="{ item }"> <template #cell-summary="{ item }">
<span class="text-xs text-gray-600">{{ item.summary }}</span> <span class="text-gray-600">{{ item.summary }}</span>
</template> </template>
</MalioDataTable> </MalioDataTable>
+3 -2
View File
@@ -5,6 +5,7 @@
<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"
@@ -28,7 +29,7 @@
@update:per-page="setItemsPerPage" @update:per-page="setItemsPerPage"
> >
<template #cell-code="{ item }"> <template #cell-code="{ item }">
<span class="font-mono text-xs">{{ item.code }}</span> <span>{{ item.code }}</span>
</template> </template>
<template #cell-permissions="{ item }"> <template #cell-permissions="{ item }">
{{ item.permissions }} {{ item.permissions }}
@@ -36,7 +37,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 text-xs font-medium text-blue-800" class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 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 text-xs font-medium text-purple-800" class="inline-flex items-center rounded-full bg-purple-100 px-2.5 py-0.5 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 font-mono" input-class="w-full"
required required
/> />
<!-- pb-4 sur le wrapper : simule le slot message du <!-- pb-4 sur le wrapper : simule le slot message du
+3 -2
View File
@@ -5,6 +5,7 @@
<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"
@@ -33,11 +34,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 class="font-mono text-xs">{{ item.color }}</span> <span>{{ item.color }}</span>
</span> </span>
</template> </template>
<template #cell-fullAddress="{ item }"> <template #cell-fullAddress="{ item }">
<span class="line-clamp-2 text-xs text-neutral-600"> <span class="line-clamp-2 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.4", "@malio/layer-ui": "^1.7.8",
"@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.10.0", "version": "1.11.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/wasi-threads": "1.2.1", "@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.10.0", "version": "1.11.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
"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.1", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"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.4", "version": "1.7.8",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.4/layer-ui-1.7.4.tgz", "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.8/layer-ui-1.7.8.tgz",
"integrity": "sha512-JNXwBelj5UQ35Qv5VmnassXKt8niX9jDXjM1vUSukJQiyeUXRxAiZr16QumVgBN9P9YGDyjXVKrwCHltTXvPtQ==", "integrity": "sha512-gUMAZzBsPCfQUF3OQSjN/OFzjONvQZYfwqH0u5VUbxaqwBdX1hUGtjD4ym6RvZkyNsKulrxkncFZYTWCS+IdGA==",
"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.4", "@malio/layer-ui": "^1.7.8",
"@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 font-mono">{{ field }}</td> <td class="px-2 py-1">{{ 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 font-mono">{{ field }}</td> <td class="px-2 py-1">{{ 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="font-mono text-xs text-gray-600">{{ key }}:</span> <span class="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>
+8 -3
View File
@@ -1,8 +1,13 @@
<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.
<div class="mb-[44px] flex items-center justify-between gap-4"> Sticky en haut du <main> scrollable : reste visible au scroll. Fond blanc
<h1 class="text-[32px] font-semibold text-primary-500"> + pt-[38px]/pb-[30px] (au lieu de marges) pour que le contenu defilant soit
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">
@@ -0,0 +1,33 @@
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
@@ -0,0 +1,22 @@
/**
* 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
}
+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. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.'); $this->comment('client', 'description', 'Onglet Information : description libre. Facultatif.');
$this->comment('client', 'competitors', 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).'); $this->comment('client', 'competitors', 'Onglet Information : concurrents identifies (texte libre ≤ 255). Facultatif.');
$this->comment('client', 'founded_at', 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).'); $this->comment('client', 'founded_at', 'Onglet Information : date de creation de l entreprise. Facultatif.');
$this->comment('client', 'employees_count', 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).'); $this->comment('client', 'employees_count', 'Onglet Information : effectif (entier >= 0). Facultatif.');
$this->comment('client', 'revenue_amount', 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).'); $this->comment('client', 'revenue_amount', 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Facultatif.');
$this->comment('client', 'director_name', 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).'); $this->comment('client', 'director_name', 'Onglet Information : nom du dirigeant. Facultatif.');
$this->comment('client', 'profit_amount', 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).'); $this->comment('client', 'profit_amount', 'Onglet Information : resultat / benefice (NUMERIC 15,2). Facultatif.');
$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.');
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Commercial — deux nouveaux types d'adresse client : Courtier et Distributeur.
*
* Ajoute les drapeaux `is_broker` / `is_distributor` sur `client_address`, au
* meme titre que `is_prospect` / `is_delivery` / `is_billing`. Ce sont des types
* AUTONOMES (comme la Prospection) : exclusifs de tout autre usage. Deux CHECK
* Postgres miroitent l'exclusivite applicative (validateExclusiveAddressTypes),
* en filet de securite (comme chk_client_address_prospect_exclusive).
*
* NB Postgres : `ADD COLUMN` ajoute en derniere position physique (pas de clause
* AFTER) — l'ordre physique est cosmetique, on adresse par nom. Les colonnes sont
* declarees juste apres isBilling dans l'entite (ERP-119).
*
* Migration au namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) : le
* tri par version garantit son passage apres l'init des tables.
*/
final class Version20260609120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Commercial : types d\'adresse Courtier / Distributeur (is_broker / is_distributor) sur client_address, exclusifs (CHECK).';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE client_address ADD COLUMN is_broker BOOLEAN DEFAULT FALSE NOT NULL');
$this->addSql('ALTER TABLE client_address ADD COLUMN is_distributor BOOLEAN DEFAULT FALSE NOT NULL');
// Exclusivite miroir (filet de securite DBAL) : un type autonome interdit
// tout autre drapeau. Livraison + Facturation restent cumulables entre eux.
$this->addSql(<<<'SQL'
ALTER TABLE client_address
ADD CONSTRAINT chk_client_address_broker_exclusive
CHECK (NOT (is_broker = TRUE AND (is_prospect = TRUE OR is_delivery = TRUE OR is_billing = TRUE OR is_distributor = TRUE)))
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE client_address
ADD CONSTRAINT chk_client_address_distributor_exclusive
CHECK (NOT (is_distributor = TRUE AND (is_prospect = TRUE OR is_delivery = TRUE OR is_billing = TRUE OR is_broker = TRUE)))
SQL);
$this->comment('client_address', 'is_broker', 'Adresse Courtier — type autonome exclusif de tout autre usage (chk_client_address_broker_exclusive). Faux par defaut.');
$this->comment('client_address', 'is_distributor', 'Adresse Distributeur — type autonome exclusif de tout autre usage (chk_client_address_distributor_exclusive). Faux par defaut.');
// Le commentaire de table mentionnait seulement prospect/livraison/facturation :
// on y ajoute les types autonomes Courtier / Distributeur (cf. ColumnCommentsCatalog).
$this->addSql('COMMENT ON TABLE client_address IS $_$Adresses d un client (1:n) — types prospect / livraison / facturation (exclusivites RG-1.06/07/08) + Courtier / Distributeur autonomes (exclusifs de tout autre usage), >= 1 site rattache (RG-1.10).$_$');
}
public function down(Schema $schema): void
{
$this->addSql('COMMENT ON TABLE client_address IS $_$Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).$_$');
$this->addSql('ALTER TABLE client_address DROP CONSTRAINT IF EXISTS chk_client_address_broker_exclusive');
$this->addSql('ALTER TABLE client_address DROP CONSTRAINT IF EXISTS chk_client_address_distributor_exclusive');
$this->addSql('ALTER TABLE client_address DROP COLUMN is_distributor');
$this->addSql('ALTER TABLE client_address DROP COLUMN is_broker');
}
/**
* Emet un `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour
* eviter tout echappement.
*/
private function comment(string $table, string $column, string $description): void
{
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
'"'.str_replace('"', '""', $table).'"',
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Commercial — second email de facturation (optionnel) sur une adresse client.
*
* Ajoute `billing_email_secondary` sur `client_address`, pendant du telephone
* secondaire du contact (max 2 emails). Optionnel ; comme l'email principal, il
* n'a de sens que sur une adresse de facturation (validateBillingEmailPresence).
*
* Migration au namespace racine `DoctrineMigrations` (regle ABSOLUE n°11).
*/
final class Version20260609140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Commercial : 2e email de facturation optionnel (billing_email_secondary) sur client_address.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE client_address ADD COLUMN billing_email_secondary VARCHAR(180) DEFAULT NULL');
$this->comment('client_address', 'billing_email_secondary', '2e email de facturation, optionnel (max 2). Interdit hors facturation (validateBillingEmailPresence), normalise en minuscules (RG-1.21).');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE client_address DROP COLUMN billing_email_secondary');
}
/**
* Emet un `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour
* eviter tout echappement.
*/
private function comment(string $table, string $column, string $description): void
{
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
'"'.str_replace('"', '""', $table).'"',
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
}
@@ -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.
* *
* Calque sur ClientInformationCompletenessValidator (RG-1.04) : colonnes nullable * Parti pris : colonnes nullable en base + validateur contextuel, plutot qu'un
* en base + validateur contextuel, plutot qu'un Assert\NotBlank sur l'entite (qui * Assert\NotBlank sur l'entite (qui casserait le POST de l'onglet principal,
* casserait le POST de l'onglet principal, lequel n'envoie aucun champ comptable). * 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.
@@ -1,77 +0,0 @@
<?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(
// Pas de nom de champ technique dans le message : la violation est
// deja rattachee au bon champ via son propertyPath (mappe inline
// cote front par useFormErrors).
'Ce champ est obligatoire pour le rôle Commerciale.',
null,
[],
$client,
$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 (jumeau du ClientInformationCompletenessValidator M1) : * Validator metier RG-2.03 (completude Information cote fournisseur) :
* 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.
@@ -25,6 +25,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName; use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/** /**
* Client (M1 Commercial) — entite racine du repertoire clients. Porte le * Client (M1 Commercial) — entite racine du repertoire clients. Porte le
@@ -171,6 +172,17 @@ class Client implements TimestampableInterface, BlamableInterface
#[Groups(['client:read', 'client:write:main'])] #[Groups(['client:read', 'client:write:main'])]
private bool $triageService = false; private bool $triageService = false;
// Champ transitoire (NON persiste : aucune colonne ORM) portant l'intention UI
// « ce client depend d'un distributeur / courtier ». Write-only (groupe
// d'ecriture main uniquement, pas de groupe de lecture -> jamais serialise en
// sortie). Sert exclusivement a la validation croisee validateRelationName :
// si une relation est choisie, la FK correspondante (distributor / broker)
// devient obligatoire. Non mappe ORM -> non audite, et toujours null une fois
// l'entite rechargee depuis la base (ne sert qu'au cycle d'une ecriture).
#[Assert\Choice(choices: ['distributeur', 'courtier'], message: 'Le type de relation est invalide.')]
#[Groups(['client:write:main'])]
private ?string $relationType = null;
// RG : au moins une categorie (Count min 1). M2M vers Category via le contrat // RG : au moins une categorie (Count min 1). M2M vers Category via le contrat
// CategoryInterface (resolve_target_entities -> Category). // CategoryInterface (resolve_target_entities -> Category).
/** @var Collection<int, CategoryInterface> */ /** @var Collection<int, CategoryInterface> */
@@ -333,6 +345,45 @@ class Client implements TimestampableInterface, BlamableInterface
return $this; return $this;
} }
public function getRelationType(): ?string
{
return $this->relationType;
}
public function setRelationType(?string $relationType): static
{
$this->relationType = $relationType;
return $this;
}
/**
* RG-1.03 bis : si l'utilisateur declare une relation (« depend d'un
* distributeur / courtier » via le champ transitoire relationType), la FK
* correspondante est obligatoire. Le back ne peut pas deviner cette intention
* a partir des seules FK nullable (distributor=null ne distingue pas « pas de
* relation » de « relation choisie sans nom »), d'ou relationType qui la porte.
* Violation portee sur distributor / broker (champ fautif cote formulaire), de
* sorte que useFormErrors la mappe inline sous le bon select (ERP-101).
*/
#[Assert\Callback]
public function validateRelationName(ExecutionContextInterface $context): void
{
if ('distributeur' === $this->relationType && null === $this->distributor) {
$context->buildViolation('Le nom du distributeur est obligatoire.')
->atPath('distributor')
->addViolation()
;
}
if ('courtier' === $this->relationType && null === $this->broker) {
$context->buildViolation('Le nom du courtier est obligatoire.')
->atPath('broker')
->addViolation()
;
}
}
public function isTriageService(): bool public function isTriageService(): bool
{ {
return $this->triageService; return $this->triageService;
@@ -129,6 +129,18 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
#[Groups(['client_address:write'])] #[Groups(['client_address:write'])]
private bool $isBilling = false; private bool $isBilling = false;
// Adresse Courtier / Distributeur : types autonomes (comme Prospection),
// exclusifs de tout autre usage (validateExclusiveAddressTypes + CHECK BDD
// chk_client_address_broker_exclusive / chk_client_address_distributor_exclusive).
// Lecture portee par le getter + SerializedName (meme pattern que isProspect).
#[ORM\Column(name: 'is_broker', options: ['default' => false])]
#[Groups(['client_address:write'])]
private bool $isBroker = false;
#[ORM\Column(name: 'is_distributor', options: ['default' => false])]
#[Groups(['client_address:write'])]
private bool $isDistributor = false;
#[ORM\Column(length: 80, options: ['default' => 'France'])] #[ORM\Column(length: 80, options: ['default' => 'France'])]
#[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
@@ -166,6 +178,15 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private ?string $billingEmail = null; private ?string $billingEmail = null;
// 2e email de facturation, optionnel (max 2 — pendant du telephone secondaire).
// Comme le principal : interdit hors facturation (validateBillingEmailPresence),
// mais jamais obligatoire. Normalise en lowercase par le ClientAddressProcessor.
#[ORM\Column(length: 180, nullable: true)]
#[Assert\Email(message: 'L\'email de facturation secondaire n\'est pas valide.')]
#[Assert\Length(max: 180, maxMessage: 'L\'email de facturation secondaire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $billingEmailSecondary = null;
#[ORM\Column(options: ['default' => 0])] #[ORM\Column(options: ['default' => 0])]
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private int $position = 0; private int $position = 0;
@@ -223,6 +244,48 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
} }
} }
/**
* Au moins un type d'adresse est obligatoire (Prospection, Livraison ou
* Facturation) : une adresse sans aucun drapeau pose n'a pas de sens metier.
* La violation est portee sur `isProspect` (meme champ que l'exclusivite) pour
* un mapping inline sous le select « Type d'adresse » cote front (ERP-119).
*/
#[Assert\Callback]
public function validateAddressTypeRequired(ExecutionContextInterface $context): void
{
if (!$this->isProspect && !$this->isDelivery && !$this->isBilling && !$this->isBroker && !$this->isDistributor) {
$context->buildViolation('Le type d\'adresse est obligatoire.')
->atPath('isProspect')
->addViolation()
;
}
}
/**
* Courtier et Distributeur sont des types d'adresse AUTONOMES (comme la
* Prospection) : exclusifs de tout autre usage (Livraison / Facturation /
* Prospection / l'autre type autonome). Mirror applicatif (422) des CHECK
* chk_client_address_broker_exclusive / chk_client_address_distributor_exclusive.
* Violation portee sur `isProspect` (mappee sous le select « Type d'adresse »).
*/
#[Assert\Callback]
public function validateExclusiveAddressTypes(ExecutionContextInterface $context): void
{
if ($this->isBroker && ($this->isProspect || $this->isDelivery || $this->isBilling || $this->isDistributor)) {
$context->buildViolation('Une adresse Courtier ne peut pas avoir d\'autre type.')
->atPath('isProspect')
->addViolation()
;
}
if ($this->isDistributor && ($this->isProspect || $this->isDelivery || $this->isBilling || $this->isBroker)) {
$context->buildViolation('Une adresse Distributeur ne peut pas avoir d\'autre type.')
->atPath('isProspect')
->addViolation()
;
}
}
/** /**
* RG-1.11 : l'email de facturation est obligatoire si l'adresse est de * RG-1.11 : l'email de facturation est obligatoire si l'adresse est de
* facturation, et interdit sinon. Mirror applicatif (422) du CHECK * facturation, et interdit sinon. Mirror applicatif (422) du CHECK
@@ -254,6 +317,16 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
->addViolation() ->addViolation()
; ;
} }
// Le 2e email est OPTIONNEL (jamais requis), mais comme le principal il
// n'a de sens que sur une adresse de facturation.
$hasSecondaryEmail = null !== $this->billingEmailSecondary && '' !== trim($this->billingEmailSecondary);
if (!$this->isBilling && $hasSecondaryEmail) {
$context->buildViolation('L\'email de facturation n\'est autorisé que sur une adresse de facturation.')
->atPath('billingEmailSecondary')
->addViolation()
;
}
} }
/** /**
@@ -343,6 +416,34 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
return $this; return $this;
} }
#[Groups(['client_address:read'])]
#[SerializedName('isBroker')]
public function isBroker(): bool
{
return $this->isBroker;
}
public function setIsBroker(bool $isBroker): static
{
$this->isBroker = $isBroker;
return $this;
}
#[Groups(['client_address:read'])]
#[SerializedName('isDistributor')]
public function isDistributor(): bool
{
return $this->isDistributor;
}
public function setIsDistributor(bool $isDistributor): static
{
$this->isDistributor = $isDistributor;
return $this;
}
public function getCountry(): string public function getCountry(): string
{ {
return $this->country; return $this->country;
@@ -415,6 +516,18 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
return $this; return $this;
} }
public function getBillingEmailSecondary(): ?string
{
return $this->billingEmailSecondary;
}
public function setBillingEmailSecondary(?string $billingEmailSecondary): static
{
$this->billingEmailSecondary = $billingEmailSecondary;
return $this;
}
public function getPosition(): int public function getPosition(): int
{ {
return $this->position; return $this->position;
@@ -31,8 +31,8 @@ use Symfony\Component\Validator\Constraints as Assert;
* comptable et la conformite, cf. spec § 2.5 / § 6.1). * comptable et la conformite, cf. spec § 2.5 / § 6.1).
* *
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony au M1 * Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony au M1
* (HP-M2-14 : pas de controle externe banque reelle). Timestampable/Blamable * (HP-M2-14 : pas de controle externe banque reelle), avec controle croise pays
* standard. * BIC/IBAN (ibanPropertyPath). Timestampable/Blamable standard.
* *
* Sous-ressource API (ERP-57, spec § 4.5) — gating comptable renforce : * Sous-ressource API (ERP-57, spec § 4.5) — gating comptable renforce :
* - POST /api/clients/{clientId}/ribs : creation rattachee au client parent * - POST /api/clients/{clientId}/ribs : creation rattachee au client parent
@@ -109,9 +109,15 @@ class ClientRib implements TimestampableInterface, BlamableInterface
// Bic/Iban bornent deja le format (et donc la longueur) : pas de Length // Bic/Iban bornent deja le format (et donc la longueur) : pas de Length
// redondant calee sur la colonne (whitelist du garde-fou ERP-107). // redondant calee sur la colonne (whitelist du garde-fou ERP-107).
// ibanPropertyPath : controle croise — le pays du BIC (positions 5-6) doit
// correspondre au pays de l'IBAN (positions 1-2). Violation portee sur `bic`.
#[ORM\Column(length: 20)] #[ORM\Column(length: 20)]
#[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')]
#[Assert\Bic(message: 'Le BIC n\'est pas valide.')] #[Assert\Bic(
message: 'Le BIC n\'est pas valide.',
ibanPropertyPath: 'iban',
ibanMessage: 'Le BIC ne correspond pas au pays de l\'IBAN.',
)]
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])] #[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])]
private ?string $bic = null; private ?string $bic = null;
@@ -44,7 +44,8 @@ use Symfony\Component\Validator\Constraints as Assert;
* Tout passe par le SupplierRibProcessor (RG-2.08 sur DELETE). * Tout passe par le SupplierRibProcessor (RG-2.08 sur DELETE).
* *
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony (pas de controle * Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony (pas de controle
* banque reelle). Audite (#[Auditable]) + Timestampable / Blamable. * banque reelle), avec controle croise pays BIC/IBAN (ibanPropertyPath). Audite
* (#[Auditable]) + Timestampable / Blamable.
*/ */
#[ApiResource( #[ApiResource(
operations: [ operations: [
@@ -105,9 +106,15 @@ class SupplierRib implements TimestampableInterface, BlamableInterface
// Bic/Iban bornent deja le format (et donc la longueur) : pas de Length // Bic/Iban bornent deja le format (et donc la longueur) : pas de Length
// redondant calee sur la colonne (auto-exempte du miroir ERP-107). // redondant calee sur la colonne (auto-exempte du miroir ERP-107).
// ibanPropertyPath : controle croise — le pays du BIC (positions 5-6) doit
// correspondre au pays de l'IBAN (positions 1-2). Violation portee sur `bic`.
#[ORM\Column(length: 20)] #[ORM\Column(length: 20)]
#[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')]
#[Assert\Bic(message: 'Le BIC n\'est pas valide.')] #[Assert\Bic(
message: 'Le BIC n\'est pas valide.',
ibanPropertyPath: 'iban',
ibanMessage: 'Le BIC ne correspond pas au pays de l\'IBAN.',
)]
#[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
private ?string $bic = null; private ?string $bic = null;
@@ -94,5 +94,6 @@ final class ClientAddressProcessor implements ProcessorInterface
private function normalize(ClientAddress $address): void private function normalize(ClientAddress $address): void
{ {
$address->setBillingEmail($this->normalizer->normalizeEmail($address->getBillingEmail())); $address->setBillingEmail($this->normalizer->normalizeEmail($address->getBillingEmail()));
$address->setBillingEmailSecondary($this->normalizer->normalizeEmail($address->getBillingEmailSecondary()));
} }
} }
@@ -9,11 +9,8 @@ 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;
@@ -44,8 +41,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), RG-1.04 (completude Information exigee sur POST * RG-1.13 (LCR -> >= 1 RIB). L'onglet Information est entierement facultatif
* et tout PATCH pour le role Commerciale). * (RG-1.04 retiree : plus d'obligation, y compris 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
@@ -108,7 +105,6 @@ 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,
@@ -142,7 +138,6 @@ 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);
@@ -511,9 +506,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 (meme parti que RG-1.04) : * Colonnes nullable en base + validateur contextuel : un Assert\NotBlank sur
* un Assert\NotBlank sur l'entite casserait le POST de l'onglet principal, qui * l'entite casserait le POST de l'onglet principal, qui n'envoie aucun champ
* n'envoie aucun champ comptable. * comptable.
*/ */
private function validateAccountingCompleteness(Client $data): void private function validateAccountingCompleteness(Client $data): void
{ {
@@ -526,21 +521,6 @@ 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).
@@ -556,21 +536,12 @@ 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) et du * groupe d'ecriture connu. C'est la base du 422 d'archivage (RG-1.22)
* declenchement conditionnel de RG-1.04 sans elles, un PATCH * sans elles, un PATCH « representation complete » porteur de @id ferait
* « representation complete » porteur de @id ferait croire a une * croire a une modification multi-onglets.
* modification multi-onglets.
* *
* @return list<string> * @return list<string>
*/ */
@@ -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 (RG-1.04) === // === Onglet Information complet (exemple de fiche renseignee) ===
[$holding, $isNew] = $this->ensureClient( [$holding, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Holding Premium Invest', companyName: 'Holding Premium Invest',
@@ -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 deja consommee par le ClientProcessor * reference la constante Shared pour eviter tout drift : un seul litteral
* (RG-1.04) pour eviter tout drift : un seul litteral pour ce code. * 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` pour RG-1.04 du M1 Clients) sans importer * role metier (ex: `commerciale`) sans importer cette classe. Comparaison
* cette classe. Comparaison stricte sur Role::code. * stricte sur Role::code.
*/ */
public function hasBusinessRole(string $roleCode): bool public function hasBusinessRole(string $roleCode): bool
{ {
@@ -10,7 +10,6 @@ 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).
* *
+8 -6
View File
@@ -10,9 +10,11 @@ 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 ex : RG-1.04 du M1 Clients rend l'onglet Information obligatoire * RBAC pures (Commerciale et Bureau partagent par ex. les memes permissions
* pour le seul role Commerciale, alors que Commerciale et Bureau partagent les * commercial.clients.view + manage mais peuvent porter des regles de gestion
* memes permissions (commercial.clients.view + manage, cf. spec-back M1 § 5.2). * propres au poste). NB : l'ancienne RG-1.04 du M1 (« Information obligatoire
* 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
@@ -24,14 +26,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 (ici) ne fait que le REFERENCER : tant qu'aucun user ne porte le * - ERP-55 le referencait pour la completude Information (M1 RG-1.04), regle
* role `commerciale`, la validation de completude Information reste dormante. * depuis retiree ; le code reste utilise par le seed RBAC et les personas.
*/ */
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). Conditionne RG-1.04. * snake_case impose par la regex Role).
*/ */
public const string COMMERCIALE = 'commerciale'; public const string COMMERCIALE = 'commerciale';
@@ -180,13 +180,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. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.', 'description' => 'Onglet Information : description libre. Facultatif.',
'competitors' => 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).', 'competitors' => 'Onglet Information : concurrents identifies (texte libre ≤ 255). Facultatif.',
'founded_at' => 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).', 'founded_at' => 'Onglet Information : date de creation de l entreprise. Facultatif.',
'employees_count' => 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).', 'employees_count' => 'Onglet Information : effectif (entier >= 0). Facultatif.',
'revenue_amount' => 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).', 'revenue_amount' => 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Facultatif.',
'director_name' => 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).', 'director_name' => 'Onglet Information : nom du dirigeant. Facultatif.',
'profit_amount' => 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).', 'profit_amount' => 'Onglet Information : resultat / benefice (NUMERIC 15,2). Facultatif.',
'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.',
@@ -219,19 +219,22 @@ final class ColumnCommentsCatalog
] + self::timestampableBlamableComments(), ] + self::timestampableBlamableComments(),
'client_address' => [ 'client_address' => [
'_table' => 'Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).', '_table' => 'Adresses d un client (1:n) — types prospect / livraison / facturation (exclusivites RG-1.06/07/08) + Courtier / Distributeur autonomes (exclusifs de tout autre usage), >= 1 site rattache (RG-1.10).',
'id' => 'Identifiant interne auto-incremente.', 'id' => 'Identifiant interne auto-incremente.',
'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.', 'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.',
'is_prospect' => 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.', 'is_prospect' => 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.',
'is_delivery' => 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.', 'is_delivery' => 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.',
'is_billing' => 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.', 'is_billing' => 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.',
'country' => 'Pays de l adresse — defaut France.', 'is_broker' => 'Adresse Courtier — type autonome exclusif de tout autre usage (chk_client_address_broker_exclusive). Faux par defaut.',
'postal_code' => 'Code postal (4-5 chiffres attendus, RG-1.09).', 'is_distributor' => 'Adresse Distributeur — type autonome exclusif de tout autre usage (chk_client_address_distributor_exclusive). Faux par defaut.',
'city' => 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).', 'country' => 'Pays de l adresse — defaut France.',
'street' => 'Numero et voie de l adresse.', 'postal_code' => 'Code postal (4-5 chiffres attendus, RG-1.09).',
'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.', 'city' => 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).',
'billing_email' => 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).', 'street' => 'Numero et voie de l adresse.',
'position' => 'Ordre d affichage de l adresse dans la liste du client (croissant).', 'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.',
'billing_email' => 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).',
'billing_email_secondary' => '2e email de facturation, optionnel (max 2). Interdit hors facturation, normalise en minuscules (RG-1.21).',
'position' => 'Ordre d affichage de l adresse dans la liste du client (croissant).',
] + self::timestampableBlamableComments(), ] + self::timestampableBlamableComments(),
'client_address_site' => [ 'client_address_site' => [
@@ -137,6 +137,27 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
return $client; return $client;
} }
/**
* 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. Mutualise ici (et non dans la
* sous-classe Supplier) pour etre accessible a tous les tests Commercial.
*
* @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;
}
private function cleanupCommercialTestData(): void private function cleanupCommercialTestData(): void
{ {
$em = $this->getEm(); $em = $this->getEm();
@@ -51,6 +51,9 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
/** IBAN/BIC valides (Assert\Iban / Assert\Bic) reutilises par les seeds. */ /** IBAN/BIC valides (Assert\Iban / Assert\Bic) reutilises par les seeds. */
protected const string VALID_IBAN = 'FR1420041010050500013M02606'; protected const string VALID_IBAN = 'FR1420041010050500013M02606';
protected const string VALID_BIC = 'BNPAFRPPXXX'; protected const string VALID_BIC = 'BNPAFRPPXXX';
// BIC allemand valide isolement (pays DE en positions 5-6) : sert au controle
// croise pays BIC/IBAN (DE vs IBAN FR -> mismatch, cf. Assert\Bic ibanPropertyPath).
protected const string FOREIGN_BIC = 'DEUTDEFFXXX';
protected function tearDown(): void protected function tearDown(): void
{ {
@@ -316,24 +319,4 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
return $entity; 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;
}
} }
@@ -146,6 +146,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'isDelivery' => true,
'isBilling' => false, 'isBilling' => false,
'billingEmail' => 'parasite@test.fr', 'billingEmail' => 'parasite@test.fr',
'postalCode' => '86100', 'postalCode' => '86100',
@@ -174,6 +175,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'isDelivery' => true,
'isBilling' => false, 'isBilling' => false,
'billingEmail' => '', 'billingEmail' => '',
'postalCode' => '86100', 'postalCode' => '86100',
@@ -187,6 +189,62 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(201);
} }
/**
* ERP-119 : une adresse de facturation accepte un 2e email (optionnel, max 2).
*/
public function testBillingAddressAcceptsTwoEmails(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Billing Two Emails');
$category = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isBilling' => true,
'billingEmail' => 'facturation@test.fr',
'billingEmailSecondary' => 'compta@test.fr',
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
}
/**
* ERP-119 : le 2e email de facturation, comme le principal, n'est autorise que
* sur une adresse de facturation -> 422 avec violation sur billingEmailSecondary.
*/
public function testSecondaryBillingEmailRejectedOnNonBillingAddress(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Secondary Email Non Billing');
$category = $this->createCategory('SECTEUR');
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'isDelivery' => true,
'billingEmailSecondary' => 'compta@test.fr',
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($body);
self::assertArrayHasKey('billingEmailSecondary', $byPath);
}
/** /**
* RG-1.29 : poster une categorie de type DISTRIBUTEUR sur une adresse -> 422 * RG-1.29 : poster une categorie de type DISTRIBUTEUR sur une adresse -> 422
* avec violation sur le champ `categories`. * avec violation sur le champ `categories`.
@@ -201,6 +259,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'isDelivery' => true,
'postalCode' => '86100', 'postalCode' => '86100',
'city' => 'Châtellerault', 'city' => 'Châtellerault',
'street' => '1 rue du Test', 'street' => '1 rue du Test',
@@ -229,6 +288,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'isDelivery' => true,
'postalCode' => '86100', 'postalCode' => '86100',
'city' => 'Châtellerault', 'city' => 'Châtellerault',
'street' => '1 rue du Test', 'street' => '1 rue du Test',
@@ -253,6 +313,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'isDelivery' => true,
'postalCode' => '86100', 'postalCode' => '86100',
'city' => 'Châtellerault', 'city' => 'Châtellerault',
'street' => '1 rue du Test', 'street' => '1 rue du Test',
@@ -277,6 +338,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'isDelivery' => true,
'postalCode' => '86100', 'postalCode' => '86100',
'city' => 'Châtellerault', 'city' => 'Châtellerault',
'street' => '1 rue du Test', 'street' => '1 rue du Test',
@@ -301,6 +363,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'isDelivery' => true,
'postalCode' => '86100', 'postalCode' => '86100',
'city' => 'Châtellerault', 'city' => 'Châtellerault',
'street' => '1 rue du Test', 'street' => '1 rue du Test',
@@ -311,6 +374,115 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(422); self::assertResponseStatusCodeSame(422);
} }
/**
* RG (ERP-119) : au moins un type d'adresse (Prospection / Livraison /
* Facturation) est obligatoire. POST sans aucun drapeau de type -> 422, avec
* une violation portee sur `isProspect` (mappee sous le select « Type
* d'adresse » cote front via ClientAddressBlock).
*/
public function testAddressRequiresAtLeastOneType(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address No Type');
$category = $this->createCategory('SECTEUR');
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($body);
self::assertArrayHasKey('isProspect', $byPath);
self::assertSame('Le type d\'adresse est obligatoire.', $byPath['isProspect']);
}
/**
* Nouveaux types d'adresse (ERP-119) : Courtier et Distributeur sont acceptes
* comme types autonomes (avec site + categorie). is_broker / is_distributor.
*/
public function testBrokerAddressAccepted(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address Broker Type');
$category = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isBroker' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
}
public function testDistributorAddressAccepted(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address Distributor Type');
$category = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDistributor' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
}
/**
* Courtier / Distributeur sont des types AUTONOMES exclusifs : les combiner avec
* un autre usage (ici Livraison) -> 422, violation sur isProspect (mappee sous le
* select Type d'adresse). Miroir applicatif du CHECK chk_client_address_broker_exclusive.
*/
public function testExclusiveAddressTypeRejected(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address Broker Mix');
$category = $this->createCategory('SECTEUR');
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'isBroker' => true,
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($body);
self::assertArrayHasKey('isProspect', $byPath);
self::assertSame('Une adresse Courtier ne peut pas avoir d\'autre type.', $byPath['isProspect']);
}
/** /**
* Retourne l'IRI du premier site seede (fixtures Sites). * Retourne l'IRI du premier site seede (fixtures Sites).
*/ */
@@ -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, RG-1.04 * gating par permission (accounting.manage / archive / RG-1.28 strict) est
* Commerciale) est couvert par les tests unitaires du ClientProcessor : il * 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).
* *
@@ -85,4 +85,77 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase
self::assertNotNull($persisted); self::assertNotNull($persisted);
self::assertSame('LEGACY FIELDS SARL', $persisted->getCompanyName()); self::assertSame('LEGACY FIELDS SARL', $persisted->getCompanyName());
} }
/**
* RG-1.03 bis : declarer une relation « depend d'un distributeur »
* (relationType, champ transitoire) sans renseigner la FK distributor doit
* produire une 422 portee sur `distributor`. Le back ne peut pas deviner
* l'intention depuis la seule FK nullable (distributor=null = client
* independant), d'ou relationType qui la transporte.
*/
public function testRelationDistributeurSansDistributeurEst422(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$body = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Relation Sans Distrib SARL',
'categories' => ['/api/categories/'.$cat->getId()],
'relationType' => 'distributeur',
],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($body);
self::assertArrayHasKey('distributor', $byPath);
self::assertSame('Le nom du distributeur est obligatoire.', $byPath['distributor']);
}
/** Idem courtier : relationType=courtier sans broker -> 422 portee sur `broker`. */
public function testRelationCourtierSansCourtierEst422(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$body = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Relation Sans Courtier SARL',
'categories' => ['/api/categories/'.$cat->getId()],
'relationType' => 'courtier',
],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($body);
self::assertArrayHasKey('broker', $byPath);
self::assertSame('Le nom du courtier est obligatoire.', $byPath['broker']);
}
/**
* Le champ transitoire relationType ne casse pas la creation nominale : avec
* la FK correspondante renseignee, le client se cree (201) et relationType
* n'est jamais serialise en sortie (write-only, aucun groupe de lecture).
*/
public function testRelationDistributeurAvecDistributeurEst201(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$distributor = $this->seedClient('Distrib Cible', false, 'DISTRIBUTEUR');
$data = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Relation Ok SARL',
'categories' => ['/api/categories/'.$cat->getId()],
'relationType' => 'distributeur',
'distributor' => '/api/clients/'.$distributor->getId(),
],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertArrayNotHasKey('relationType', $data);
}
} }
@@ -14,8 +14,7 @@ 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, plus le durcissement RG-1.04 * bureau / compta / commerciale / usine.
* (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.
@@ -174,14 +173,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 (pas un 403 comme // manage : la creation passe la security d'operation et aboutit -> 201
// Compta) mais bute sur RG-1.04 (onglet Information incomplet) -> 422. // (l'onglet Information est facultatif pour tous depuis le retrait de
// C'est la preuve que Commerciale porte `manage` (sinon 403). // RG-1.04). 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(422); self::assertResponseStatusCodeSame(201);
// 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(), [
@@ -198,27 +197,6 @@ 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) et le test fonctionnel RG-1.04 sont DELEGUES a ERP-74 (#493) : ils * / usine) est DELEGUEE a ERP-74 (#493) : elle exige les roles seedes apres le
* exigent les roles seedes apres le merge de la stack. NE PAS les ajouter ici. * merge de la stack. NE PAS l'ajouter ici.
* *
* @internal * @internal
*/ */
@@ -59,12 +59,18 @@ final class ClientSerializationContractTest extends AbstractCommercialApiTestCas
self::assertArrayHasKey('isProspect', $address); self::assertArrayHasKey('isProspect', $address);
self::assertArrayHasKey('isDelivery', $address); self::assertArrayHasKey('isDelivery', $address);
self::assertArrayHasKey('isBilling', $address); self::assertArrayHasKey('isBilling', $address);
// Memes garanties pour les types Courtier / Distributeur (ERP-119, meme
// pattern getter + SerializedName).
self::assertArrayHasKey('isBroker', $address);
self::assertArrayHasKey('isDistributor', $address);
// L'adresse seedee est livraison + facturation (prospect exclusif, RG-1.06). // L'adresse seedee est livraison + facturation (prospect exclusif, RG-1.06).
// Prouve qu'un booleen `true` est bien serialise (le bug masquait meme les true). // Prouve qu'un booleen `true` est bien serialise (le bug masquait meme les true).
self::assertFalse($address['isProspect']); self::assertFalse($address['isProspect']);
self::assertTrue($address['isDelivery']); self::assertTrue($address['isDelivery']);
self::assertTrue($address['isBilling']); self::assertTrue($address['isBilling']);
self::assertFalse($address['isBroker']);
self::assertFalse($address['isDistributor']);
} }
// === #80 — Gating des RIB par accounting.view === // === #80 — Gating des RIB par accounting.view ===
@@ -27,6 +27,9 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
private const string MERGE = 'application/merge-patch+json'; private const string MERGE = 'application/merge-patch+json';
private const string VALID_IBAN = 'FR1420041010050500013M02606'; private const string VALID_IBAN = 'FR1420041010050500013M02606';
private const string VALID_BIC = 'BNPAFRPPXXX'; private const string VALID_BIC = 'BNPAFRPPXXX';
// BIC allemand valide isolement (pays DE en positions 5-6) : sert au controle
// croise pays BIC/IBAN (DE vs IBAN FR -> mismatch, cf. Assert\Bic ibanPropertyPath).
private const string FOREIGN_BIC = 'DEUTDEFFXXX';
// === Contacts === // === Contacts ===
@@ -86,10 +89,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
]); ]);
self::assertResponseStatusCodeSame(422); self::assertResponseStatusCodeSame(422);
$byPath = []; $byPath = $this->violationsByPath($response->toArray(false));
foreach ($response->toArray(false)['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
self::assertArrayHasKey('email', $byPath, 'La violation email doit porter propertyPath=email (mapping front).'); self::assertArrayHasKey('email', $byPath, 'La violation email doit porter propertyPath=email (mapping front).');
self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']); self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']);
@@ -132,10 +132,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
]); ]);
self::assertResponseStatusCodeSame(422); self::assertResponseStatusCodeSame(422);
$byPath = []; $byPath = $this->violationsByPath($response->toArray(false));
foreach ($response->toArray(false)['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
self::assertArrayHasKey('email', $byPath); self::assertArrayHasKey('email', $byPath);
self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']); self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']);
} }
@@ -234,6 +231,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'isDelivery' => true,
'postalCode' => '86100', 'postalCode' => '86100',
'city' => 'Châtellerault', 'city' => 'Châtellerault',
'street' => '1 rue du Test', 'street' => '1 rue du Test',
@@ -255,6 +253,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'isDelivery' => true,
'postalCode' => '123', 'postalCode' => '123',
'city' => 'Châtellerault', 'city' => 'Châtellerault',
'street' => '1 rue du Test', 'street' => '1 rue du Test',
@@ -284,6 +283,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [ 'json' => [
'isDelivery' => true,
'postalCode' => '75001', 'postalCode' => '75001',
'city' => 'Paris', 'city' => 'Paris',
'street' => '2 rue Neuve', 'street' => '2 rue Neuve',
@@ -310,6 +310,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/999999/addresses', [ $client->request('POST', '/api/clients/999999/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [ 'json' => [
'isDelivery' => true,
'postalCode' => '75001', 'postalCode' => '75001',
'city' => 'Paris', 'city' => 'Paris',
'street' => '2 rue Neuve', 'street' => '2 rue Neuve',
@@ -359,6 +360,32 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(422); self::assertResponseStatusCodeSame(422);
} }
/**
* Controle croise pays BIC/IBAN (Assert\Bic ibanPropertyPath) : un BIC (DE) et
* un IBAN (FR) valides isolement mais de pays differents -> 422. La violation
* porte propertyPath=bic et le message FR `ibanMessage` (mapping inline front).
*/
public function testPostRibWithBicIbanCountryMismatchReturns422WithFrenchMessageOnBic(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Rib Pays Mismatch');
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'label' => 'Compte incoherent',
'bic' => self::FOREIGN_BIC,
'iban' => self::VALID_IBAN,
],
]);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($response->toArray(false));
self::assertArrayHasKey('bic', $byPath, 'Le mismatch pays BIC/IBAN doit porter propertyPath=bic (mapping front).');
self::assertSame('Le BIC ne correspond pas au pays de l\'IBAN.', $byPath['bic']);
}
/** /**
* Regression ERP-110 : POST d'un RIB sur un client qui en a DEJA >= 2 ne doit * Regression ERP-110 : POST d'un RIB sur un client qui en a DEJA >= 2 ne doit
* pas exploser en 500 (NonUniqueResult sur la resolution du parent). L'admin * pas exploser en 500 (NonUniqueResult sur la resolution du parent). L'admin
@@ -294,6 +294,27 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
self::assertResponseStatusCodeSame(422); self::assertResponseStatusCodeSame(422);
} }
/**
* Controle croise pays BIC/IBAN (Assert\Bic ibanPropertyPath) : un BIC (DE) et
* un IBAN (FR) valides isolement mais de pays differents -> 422. La violation
* porte propertyPath=bic et le message FR `ibanMessage` (mapping inline front).
*/
public function testPostRibWithBicIbanCountryMismatchReturns422WithFrenchMessageOnBic(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Rib Pays Mismatch');
$response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['label' => 'Compte incoherent', 'bic' => self::FOREIGN_BIC, 'iban' => self::VALID_IBAN],
]);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($response->toArray(false));
self::assertArrayHasKey('bic', $byPath, 'Le mismatch pays BIC/IBAN doit porter propertyPath=bic (mapping front).');
self::assertSame('Le BIC ne correspond pas au pays de l\'IBAN.', $byPath['bic']);
}
public function testDeleteRibNonLcrReturns204(): void public function testDeleteRibNonLcrReturns204(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();
@@ -9,7 +9,6 @@ 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;
@@ -17,8 +16,6 @@ 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;
@@ -27,13 +24,11 @@ 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.04 Commerciale, RG-1.12 Virement, RG-1.13 LCR), grace a un Security et * (RG-1.12 Virement, RG-1.13 LCR), grace a un Security et un RequestStack stubbes.
* un RequestStack stubbes.
* *
* @internal * @internal
*/ */
@@ -342,62 +337,6 @@ 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
@@ -407,7 +346,6 @@ 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 {
@@ -422,7 +360,6 @@ 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)));
@@ -440,7 +377,6 @@ 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,
@@ -493,26 +429,4 @@ 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';
}
};
}
} }