Compare commits

...

2 Commits

Author SHA1 Message Date
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
42 changed files with 653 additions and 432 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.97'
+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>
@@ -87,8 +87,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 +116,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). Une erreur BAN
ne bascule PAS en saisie libre : l'autocompletion reste montee et
chaque frappe relance la recherche (l'utilisateur peut aussi taper
une rue librement). -->
<MalioInputAutocomplete <MalioInputAutocomplete
v-if="!degraded && !readonly" v-if="!readonly"
:model-value="model.street" :model-value="model.street"
:options="addressOptions" :options="addressOptions"
:loading="addressLoading" :loading="addressLoading"
@@ -217,8 +221,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 +266,10 @@ function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDr
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
} }
/** Bascule définitivement en mode degrade et previent le parent (toast unique). */ /** Previent le parent (toast unique) que l'autocompletion est indisponible. */
function enterDegraded(): void { function notifyUnavailable(): void {
if (!degraded.value) { if (!unavailableNotified) {
degraded.value = true unavailableNotified = true
emit('degraded') emit('degraded')
} }
} }
@@ -270,9 +278,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 +285,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 +311,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,
}), }),
})) }))
@@ -130,3 +135,57 @@ describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => {
expect(field?.attributes('data-error')).toBe('Code postal invalide.') expect(field?.attributes('data-error')).toBe('Code postal invalide.')
}) })
}) })
describe('ClientAddressBlock — recherche adresse robuste (erreur BAN)', () => {
beforeEach(() => {
searchAddressMock.mockReset()
})
it('n\'appelle pas la BAN en deca de 3 caracteres', async () => {
const wrapper = mountBlock(null)
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
auto.vm.$emit('search', 'ab')
await flushPromises()
expect(searchAddressMock).not.toHaveBeenCalled()
})
it('relance la recherche apres une erreur (pas de bascule definitive)', async () => {
searchAddressMock
.mockRejectedValueOnce(new Error('BAN indisponible'))
.mockResolvedValueOnce([
{ label: '8 Boulevard du Port, Paris', street: '8 Boulevard du Port', postalCode: '75001', city: 'Paris' },
])
const wrapper = mountBlock(null)
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
// 1er essai -> erreur BAN.
auto.vm.$emit('search', 'boulevard du port')
await flushPromises()
expect(searchAddressMock).toHaveBeenCalledTimes(1)
// 2e essai -> DOIT relancer l'appel (c'etait le bug : plus aucune recherche).
auto.vm.$emit('search', 'boulevard du porte')
await flushPromises()
expect(searchAddressMock).toHaveBeenCalledTimes(2)
// L'autocompletion reste montee (aucune bascule en saisie libre).
expect(wrapper.find('[data-testid="addr-autocomplete"]').exists()).toBe(true)
})
it('emet « degraded » une seule fois malgre plusieurs erreurs', async () => {
searchAddressMock.mockRejectedValue(new Error('BAN indisponible'))
const wrapper = mountBlock(null)
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
auto.vm.$emit('search', 'rue de la paix')
await flushPromises()
auto.vm.$emit('search', 'rue de la paixx')
await flushPromises()
expect(wrapper.emitted('degraded')).toHaveLength(1)
})
})
@@ -99,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"
@@ -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"
@@ -205,6 +210,7 @@
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
@@ -289,9 +295,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,10 +336,12 @@
<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
@@ -379,7 +387,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 +418,18 @@ import {
type MainFormDraft, type MainFormDraft,
} from '~/modules/commercial/utils/clientEdit' } from '~/modules/commercial/utils/clientEdit'
import { import {
addressTypeFromFlags,
buildClientFormTabKeys, buildClientFormTabKeys,
hasAllRequiredAccountingFields, hasAllRequiredAccountingFields,
hasAtLeastOneValidContact, hasAtLeastOneValidContact,
isAddressValid,
isBankRequiredForPaymentType, isBankRequiredForPaymentType,
isBillingEmailRequired, isBillingEmailRequired,
isContactBlank, isContactBlank,
isContactNamed, isContactNamed,
isRibBlank, isRibBlank,
isRibComplete,
isRibRequiredForPaymentType, isRibRequiredForPaymentType,
showsRelationAndTriageFields,
} from '~/modules/commercial/utils/clientFormRules' } from '~/modules/commercial/utils/clientFormRules'
import { import {
emptyAddress, emptyAddress,
@@ -430,6 +440,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 +511,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 +564,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 +627,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 } })
} }
/** /**
@@ -787,18 +824,17 @@ async function submitContacts(): Promise<void> {
// ── Onglet Adresse ─────────────────────────────────────────────────────────── // ── Onglet Adresse ───────────────────────────────────────────────────────────
const canValidateAddresses = computed(() => const canValidateAddresses = computed(() =>
addresses.value.length > 0 addresses.value.length > 0 && addresses.value.every(isAddressValid),
&& addresses.value.every((a) => {
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
return addressTypeFromFlags(a) !== null
&& a.siteIris.length >= 1
&& a.categoryIris.length >= 1
&& (!isBillingEmailRequired(a) || filledBillingEmail)
}),
) )
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => {
const last = addresses.value[addresses.value.length - 1]
return last !== undefined && isAddressValid(last)
})
function addAddress(): void { function addAddress(): void {
addresses.value.push(emptyAddress()) if (canAddAddress.value) addresses.value.push(emptyAddress())
} }
function askRemoveAddress(index: number): void { function askRemoveAddress(index: number): void {
@@ -870,25 +906,42 @@ 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
function ribIsComplete(rib: { label: string | null, bic: string | null, iban: string | null }): boolean { // marques pour suppression serveur au prochain enregistrement.
const filled = (v: string | null) => v !== null && v.trim() !== '' if (isRibRequired.value) {
return filled(rib.label) && filled(rib.bic) && filled(rib.iban) 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 = []
}
} }
const canValidateAccounting = computed(() => { const canValidateAccounting = computed(() => {
if (!hasAllRequiredAccountingFields(accounting)) return false if (!hasAllRequiredAccountingFields(accounting)) return false
if (isBankRequired.value && accounting.bankIri === null) return false if (isBankRequired.value && accounting.bankIri === null) return false
if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false if (isRibRequired.value && !ribs.value.some(isRibComplete)) return false
return true return true
}) })
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
const canAddRib = computed(() => {
const last = ribs.value[ribs.value.length - 1]
return last !== undefined && isRibComplete(last)
})
function addRib(): void { function addRib(): void {
ribs.value.push(emptyRib()) if (canAddRib.value) ribs.value.push(emptyRib())
} }
function askRemoveRib(index: number): void { function askRemoveRib(index: number): void {
@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<!-- En-tete : retour repertoire + nom du client + actions (Modifier / Archiver|Restaurer). --> <!-- En-tete : retour repertoire + nom du client + actions (Modifier / Archiver|Restaurer). -->
<div class="flex items-center gap-3"> <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 ────────────────────────────────────────────────
@@ -4,7 +4,7 @@
{{ t('commercial.clients.title') }} {{ t('commercial.clients.title') }}
<template #actions> <template #actions>
<!-- gap-12 = 48px d'espacement entre Ajouter et Filtres. --> <!-- gap-12 = 48px d'espacement entre Ajouter et Filtres. -->
<div class="flex items-center gap-12"> <div class="flex items-center gap-8">
<MalioButton <MalioButton
v-if="canManage" v-if="canManage"
variant="secondary" variant="secondary"
@@ -21,8 +21,8 @@
:label="filterButtonLabel" :label="filterButtonLabel"
icon-name="mdi:tune" icon-name="mdi:tune"
icon-position="left" icon-position="left"
icon-size="24" icon-size="20"
button-class="w-[184px] justify-start gap-4 text-black" button-class="w-[180px] justify-start gap-4 text-black"
@click="openFilters" @click="openFilters"
/> />
</div> </div>
@@ -39,7 +39,7 @@
:per-page="itemsPerPage" :per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions" :per-page-options="itemsPerPageOptions"
row-clickable row-clickable
table-class="table-fixed" 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 +56,7 @@
<span <span
v-for="site in (item.sites as ClientSite[])" v-for="site in (item.sites as ClientSite[])"
:key="site.id" :key="site.id"
class="inline-flex items-center rounded-full px-2 py-0.5 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 +70,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 +350,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 +421,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>
@@ -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"
@@ -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"
@@ -134,13 +138,15 @@
/> />
</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) OU si aucun champ n'est rempli : onglet
facultatif, mais pas de validation a vide (on passe alors
directement a Contact). -->
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('commercial.clients.form.submit')" :label="t('commercial.clients.form.submit')"
:disabled="tabSubmitting || clientId === null" :disabled="tabSubmitting || clientId === null || !canValidateInformation"
@click="submitInformation" @click="submitInformation"
/> />
</div> </div>
@@ -204,6 +210,7 @@
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
@@ -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,10 +336,12 @@
<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
@@ -380,17 +389,20 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials' import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors' import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
import { import {
addressTypeFromFlags,
buildClientFormTabKeys, buildClientFormTabKeys,
CLIENT_FORM_PLACEHOLDER_TABS, CLIENT_FORM_PLACEHOLDER_TABS,
hasAllRequiredAccountingFields, hasAllRequiredAccountingFields,
hasAtLeastOneInformationField,
hasAtLeastOneValidContact, hasAtLeastOneValidContact,
isAddressValid,
isBankRequiredForPaymentType, isBankRequiredForPaymentType,
isBillingEmailRequired, isBillingEmailRequired,
isContactBlank, isContactBlank,
isContactNamed, isContactNamed,
isRibBlank, isRibBlank,
isRibComplete,
isRibRequiredForPaymentType, isRibRequiredForPaymentType,
showsRelationAndTriageFields,
} from '~/modules/commercial/utils/clientFormRules' } from '~/modules/commercial/utils/clientFormRules'
import { import {
emptyAddress, emptyAddress,
@@ -483,6 +495,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 les IRI du brouillon).
const selectedCategoryCodes = computed(() =>
main.categoryIris
.map(iri => referentials.categories.value.find(c => c.value === iri)?.code)
.filter((code): code is string => code !== undefined),
)
// « Relation » + « Prestation de triage » masques par defaut, reveles des qu'une
// categorie ordinaire (autre que Distributeur/Courtier) est selectionnee.
const showRelationAndTriage = computed(() => showsRelationAndTriageFields(selectedCategoryCodes.value))
// Masquer ces champs reinitialise leurs valeurs : pas de relation/triage fantome
// soumis pour un client Distributeur/Courtier.
watch(showRelationAndTriage, (visible) => {
if (!visible) {
main.relationType = null
main.distributorIri = null
main.brokerIri = null
main.triageService = false
}
})
// Validation du formulaire principal (gate le bouton « Valider ») : // Validation du formulaire principal (gate le bouton « Valider ») :
// - companyName / >= 1 categorie obligatoires ; // - companyName / >= 1 categorie obligatoires ;
// - relation Distributeur/Courtier optionnelle, mais le nom correspondant // - relation Distributeur/Courtier optionnelle, mais le nom correspondant
@@ -538,7 +572,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') })
} }
@@ -625,9 +661,12 @@ const information = reactive({
directorName: null as string | null, directorName: null as string | null,
}) })
// Onglet facultatif, mais pas de validation « a vide » : au moins un champ rempli.
const canValidateInformation = computed(() => hasAtLeastOneInformationField(information))
/** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */ /** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */
async function submitInformation(): Promise<void> { async function submitInformation(): Promise<void> {
if (clientId.value === null || tabSubmitting.value) return if (clientId.value === null || tabSubmitting.value || !canValidateInformation.value) return
tabSubmitting.value = true tabSubmitting.value = true
informationErrors.clearErrors() informationErrors.clearErrors()
try { try {
@@ -753,18 +792,17 @@ const countryOptions: RefOption[] = [
// Type d'adresse (Select) obligatoire + RG-1.10 (>= 1 site) + RG-1.11 (email // Type d'adresse (Select) obligatoire + RG-1.10 (>= 1 site) + RG-1.11 (email
// facturation si Facturation) sur chaque adresse. // facturation si Facturation) sur chaque adresse.
const canValidateAddresses = computed(() => const canValidateAddresses = computed(() =>
addresses.value.length > 0 addresses.value.length > 0 && addresses.value.every(isAddressValid),
&& addresses.value.every((a) => {
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
return addressTypeFromFlags(a) !== null
&& a.siteIris.length >= 1
&& a.categoryIris.length >= 1
&& (!isBillingEmailRequired(a) || filledBillingEmail)
}),
) )
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => {
const last = addresses.value[addresses.value.length - 1]
return last !== undefined && isAddressValid(last)
})
function addAddress(): void { function addAddress(): void {
addresses.value.push(emptyAddress()) if (canAddAddress.value) addresses.value.push(emptyAddress())
} }
function askRemoveAddress(index: number): void { function askRemoveAddress(index: number): void {
@@ -853,15 +891,22 @@ 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).
function ribIsComplete(rib: RibFormDraft): boolean { if (isRibRequired.value) {
const filled = (v: string | null) => v !== null && v.trim() !== '' if (ribs.value.length === 0) ribs.value.push(emptyRib())
return filled(rib.label) && filled(rib.bic) && filled(rib.iban) }
else {
ribs.value = []
ribErrors.value = []
}
} }
// RG-1.30 : les 6 champs scalaires obligatoires (comme les onglets Contact / // RG-1.30 : les 6 champs scalaires obligatoires (comme les onglets Contact /
@@ -870,12 +915,18 @@ function ribIsComplete(rib: RibFormDraft): boolean {
const canValidateAccounting = computed(() => { const canValidateAccounting = computed(() => {
if (!hasAllRequiredAccountingFields(accounting)) return false if (!hasAllRequiredAccountingFields(accounting)) return false
if (isBankRequired.value && (accounting.bankIri === null)) return false if (isBankRequired.value && (accounting.bankIri === null)) return false
if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false if (isRibRequired.value && !ribs.value.some(isRibComplete)) return false
return true return true
}) })
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
const canAddRib = computed(() => {
const last = ribs.value[ribs.value.length - 1]
return last !== undefined && isRibComplete(last)
})
function addRib(): void { function addRib(): void {
ribs.value.push(emptyRib()) if (canAddRib.value) ribs.value.push(emptyRib())
} }
function askRemoveRib(index: number): void { function askRemoveRib(index: number): void {
@@ -987,8 +1038,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>
@@ -7,14 +7,19 @@ import {
canSelectDeliveryOrBilling, canSelectDeliveryOrBilling,
canSelectProspect, canSelectProspect,
hasAllRequiredAccountingFields, hasAllRequiredAccountingFields,
hasAtLeastOneInformationField,
hasAtLeastOneValidContact, hasAtLeastOneValidContact,
isAddressValid,
isBankRequiredForPaymentType, isBankRequiredForPaymentType,
isBillingEmailRequired, isBillingEmailRequired,
isBlankRow, isBlankRow,
isContactBlank, isContactBlank,
isContactNamed, isContactNamed,
isRibBlank, isRibBlank,
isRibComplete,
isRibRequiredForPaymentType, isRibRequiredForPaymentType,
showsRelationAndTriageFields,
type AddressValidityDraft,
type ContactDraft, type ContactDraft,
type ContactFillableDraft, type ContactFillableDraft,
} from '../clientFormRules' } from '../clientFormRules'
@@ -271,3 +276,96 @@ describe('hasAllRequiredAccountingFields (RG-1.30)', () => {
})).toBe(false) })).toBe(false)
}) })
}) })
describe('showsRelationAndTriageFields (affichage Relation + Triage selon categorie)', () => {
it('faux par defaut (aucune categorie selectionnee)', () => {
expect(showsRelationAndTriageFields([])).toBe(false)
})
it('faux si seules des categories Distributeur / Courtier sont selectionnees', () => {
expect(showsRelationAndTriageFields(['DISTRIBUTEUR'])).toBe(false)
expect(showsRelationAndTriageFields(['COURTIER'])).toBe(false)
expect(showsRelationAndTriageFields(['DISTRIBUTEUR', 'COURTIER'])).toBe(false)
})
it('vrai des qu\'une categorie ordinaire est selectionnee', () => {
expect(showsRelationAndTriageFields(['CLIENT'])).toBe(true)
expect(showsRelationAndTriageFields(['DISTRIBUTEUR', 'CLIENT'])).toBe(true)
})
})
describe('hasAtLeastOneInformationField (pas de validation a vide a la creation)', () => {
const blank = {
description: null,
competitors: null,
foundedAt: null,
employeesCount: null,
revenueAmount: null,
profitAmount: null,
directorName: null,
}
it('faux quand aucun champ n\'est rempli (onglet vierge)', () => {
expect(hasAtLeastOneInformationField(blank)).toBe(false)
expect(hasAtLeastOneInformationField({ ...blank, description: ' ' })).toBe(false)
})
it('vrai des qu\'un champ porte une valeur', () => {
expect(hasAtLeastOneInformationField({ ...blank, description: 'Acteur majeur' })).toBe(true)
expect(hasAtLeastOneInformationField({ ...blank, employeesCount: '42' })).toBe(true)
expect(hasAtLeastOneInformationField({ ...blank, foundedAt: '2020-01-01' })).toBe(true)
})
})
describe('isAddressValid (gating « + Adresse » + validation onglet)', () => {
/** Adresse de livraison valide (type + site + categorie ; pas de facturation). */
function validDelivery(): AddressValidityDraft {
return {
isProspect: false,
isDelivery: true,
isBilling: false,
categoryIris: ['/api/client_categories/1'],
siteIris: ['/api/sites/1'],
billingEmail: null,
}
}
it('vrai quand type + >= 1 site + >= 1 categorie (sans facturation)', () => {
expect(isAddressValid(validDelivery())).toBe(true)
})
it('faux si aucun drapeau (type d\'adresse non renseigne, amorce vierge)', () => {
expect(isAddressValid({ ...validDelivery(), isDelivery: false })).toBe(false)
})
it('faux si aucun site (RG-1.10)', () => {
expect(isAddressValid({ ...validDelivery(), siteIris: [] })).toBe(false)
})
it('faux si aucune categorie', () => {
expect(isAddressValid({ ...validDelivery(), categoryIris: [] })).toBe(false)
})
it('RG-1.11 : email de facturation obligatoire quand l\'adresse est de facturation', () => {
const billing: AddressValidityDraft = { ...validDelivery(), isBilling: true }
expect(isAddressValid({ ...billing, billingEmail: null })).toBe(false)
expect(isAddressValid({ ...billing, billingEmail: ' ' })).toBe(false)
expect(isAddressValid({ ...billing, billingEmail: 'facture@acme.fr' })).toBe(true)
})
})
describe('isRibComplete (gating « + RIB » + RG-1.13)', () => {
it('vrai quand label + BIC + IBAN sont remplis', () => {
expect(isRibComplete({ label: 'Compte courant', bic: 'BNPAFRPP', iban: 'FR1420041010050500013M02606' })).toBe(true)
})
it('faux si un champ manque (null ou vide apres trim)', () => {
expect(isRibComplete({ label: null, bic: 'BNPAFRPP', iban: 'FR14...' })).toBe(false)
expect(isRibComplete({ label: 'Compte', bic: ' ', iban: 'FR14...' })).toBe(false)
expect(isRibComplete({ label: 'Compte', bic: 'BNPAFRPP', iban: null })).toBe(false)
})
it('faux pour un bloc totalement vide (amorce)', () => {
expect(isRibComplete({ label: null, bic: null, iban: null })).toBe(false)
})
})
@@ -12,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 {
@@ -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,26 @@ export function buildClientFormTabKeys(
return keys return keys
} }
/**
* Codes de categorie « intermediaire » : un client dont la categorie est
* Distributeur ou Courtier n'a ni relation amont (il EST le distributeur /
* courtier) ni prestation de triage. Sert a conditionner l'affichage des champs
* « Relation » et « Prestation de triage » du formulaire principal.
*/
export const DISTRIBUTOR_BROKER_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER'] as const
/**
* Vrai des qu'au moins une categorie « ordinaire » (autre que Distributeur /
* Courtier) est selectionnee. Les champs « Relation » (depend du distributeur /
* courtier) et « Prestation de triage » du formulaire principal sont masques par
* defaut et reveles uniquement dans ce cas.
*/
export function showsRelationAndTriageFields(selectedCategoryCodes: string[]): boolean {
return selectedCategoryCodes.some(
code => !(DISTRIBUTOR_BROKER_CATEGORY_CODES as readonly string[]).includes(code),
)
}
/** Sous-ensemble d'un contact necessaire aux regles de nommage (RG-1.05/1.14). */ /** Sous-ensemble d'un contact necessaire aux regles de nommage (RG-1.05/1.14). */
export interface ContactDraft { export interface ContactDraft {
firstName: string | null firstName: string | null
@@ -138,6 +155,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
@@ -226,6 +253,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 +300,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
@@ -30,7 +30,7 @@
> >
<template #cell-action="{ item }"> <template #cell-action="{ item }">
<span <span
class="inline-flex items-center rounded-full px-2 py-0.5 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 +38,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>
+2 -2
View File
@@ -28,7 +28,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 +36,7 @@
<template #cell-system="{ item }"> <template #cell-system="{ item }">
<span <span
v-if="item.isSystem" v-if="item.isSystem"
class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 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
+2 -2
View File
@@ -33,11 +33,11 @@
:style="{ backgroundColor: item.color }" :style="{ backgroundColor: item.color }"
class="inline-block size-5 rounded-full border border-neutral-200" class="inline-block size-5 rounded-full border border-neutral-200"
/> />
<span 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.7",
"@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.7",
"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.7/layer-ui-1.7.7.tgz",
"integrity": "sha512-JNXwBelj5UQ35Qv5VmnassXKt8niX9jDXjM1vUSukJQiyeUXRxAiZr16QumVgBN9P9YGDyjXVKrwCHltTXvPtQ==", "integrity": "sha512-MLHDtOzUxcCwIBGWj4FcUMLQTExtGD29uLvpU+IA6qr7gCj9kZ9fGZDu76LXxuJJdfBwzZmenuZioE7Z1qQUUw==",
"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.7",
"@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-11/pb-[34px] (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-11 = 44px, la marge haute
d'origine. z-20 < drawers/modales. -->
<div class="sticky top-0 z-20 flex items-center justify-between gap-4 bg-white pt-11 pb-[34px]">
<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.');
@@ -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.
@@ -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.',
@@ -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).
* *
@@ -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
*/ */
@@ -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';
}
};
}
} }