From 0df18da00c85c18ce04134c49b128d3eb1297a8c Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 8 Jun 2026 09:58:34 +0200 Subject: [PATCH 01/12] fix(commercial) : corrections formulaire client (ajout de bloc, onglet Information) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Boutons « ajouter » Adresse/RIB desactives tant que le dernier bloc n'est pas valide (predicats isAddressValid/isRibComplete, reutilises par la validation d'onglet). - Onglet Information : bouton Valider desactive si aucun champ rempli (pas de validation a vide) — creation uniquement, l'edition garde la possibilite de tout vider. - Onglet Contact accessible des la creation du client (onglet Information facultatif). --- .../commercial/pages/clients/[id]/edit.vue | 37 +++++---- .../modules/commercial/pages/clients/new.vue | 57 +++++++------ .../utils/__tests__/clientFormRules.spec.ts | 80 +++++++++++++++++++ .../commercial/utils/clientFormRules.ts | 65 +++++++++++++++ 4 files changed, 199 insertions(+), 40 deletions(-) diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue index b5387f2..2600aa4 100644 --- a/frontend/modules/commercial/pages/clients/[id]/edit.vue +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -205,6 +205,7 @@ icon-name="mdi:add-bold" icon-position="left" :label="t('commercial.clients.form.address.add')" + :disabled="!canAddAddress" @click="addAddress" /> { // ── Onglet Adresse ─────────────────────────────────────────────────────────── const canValidateAddresses = computed(() => - addresses.value.length > 0 - && addresses.value.every((a) => { - const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== '' - return addressTypeFromFlags(a) !== null - && a.siteIris.length >= 1 - && a.categoryIris.length >= 1 - && (!isBillingEmailRequired(a) || filledBillingEmail) - }), + addresses.value.length > 0 && addresses.value.every(isAddressValid), ) +// « + 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 { - addresses.value.push(emptyAddress()) + if (canAddAddress.value) addresses.value.push(emptyAddress()) } function askRemoveAddress(index: number): void { @@ -875,20 +877,21 @@ function onPaymentTypeChange(value: string | number | null): void { if (!isBankRequired.value) accounting.bankIri = null } -function ribIsComplete(rib: { label: string | null, bic: string | null, iban: string | null }): boolean { - const filled = (v: string | null) => v !== null && v.trim() !== '' - return filled(rib.label) && filled(rib.bic) && filled(rib.iban) -} - const canValidateAccounting = computed(() => { if (!hasAllRequiredAccountingFields(accounting)) return false if (isBankRequired.value && accounting.bankIri === null) return false - if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false + if (isRibRequired.value && !ribs.value.some(isRibComplete)) return false 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 { - ribs.value.push(emptyRib()) + if (canAddRib.value) ribs.value.push(emptyRib()) } function askRemoveRib(index: number): void { diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue index 720f40e..794384e 100644 --- a/frontend/modules/commercial/pages/clients/new.vue +++ b/frontend/modules/commercial/pages/clients/new.vue @@ -134,13 +134,15 @@ />
- +
@@ -204,6 +206,7 @@ icon-name="mdi:add-bold" icon-position="left" :label="t('commercial.clients.form.address.add')" + :disabled="!canAddAddress" @click="addAddress" /> { main.companyName = created.companyName ?? main.companyName 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' toast.success({ title: t('commercial.clients.toast.createSuccess') }) } @@ -625,9 +633,12 @@ const information = reactive({ 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. */ async function submitInformation(): Promise { - if (clientId.value === null || tabSubmitting.value) return + if (clientId.value === null || tabSubmitting.value || !canValidateInformation.value) return tabSubmitting.value = true informationErrors.clearErrors() try { @@ -753,18 +764,17 @@ const countryOptions: RefOption[] = [ // Type d'adresse (Select) obligatoire + RG-1.10 (>= 1 site) + RG-1.11 (email // facturation si Facturation) sur chaque adresse. const canValidateAddresses = computed(() => - addresses.value.length > 0 - && addresses.value.every((a) => { - const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== '' - return addressTypeFromFlags(a) !== null - && a.siteIris.length >= 1 - && a.categoryIris.length >= 1 - && (!isBillingEmailRequired(a) || filledBillingEmail) - }), + addresses.value.length > 0 && addresses.value.every(isAddressValid), ) +// « + 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 { - addresses.value.push(emptyAddress()) + if (canAddAddress.value) addresses.value.push(emptyAddress()) } function askRemoveAddress(index: number): void { @@ -859,23 +869,24 @@ function onPaymentTypeChange(value: string | number | null): void { if (!isBankRequired.value) accounting.bankIri = null } -function ribIsComplete(rib: RibFormDraft): boolean { - const filled = (v: string | null) => v !== null && v.trim() !== '' - return filled(rib.label) && filled(rib.bic) && filled(rib.iban) -} - // RG-1.30 : les 6 champs scalaires obligatoires (comme les onglets Contact / // Adresse, le bouton reste desactive tant que l'onglet n'est pas complet). // RG-1.12 : banque requise si VIREMENT. RG-1.13 : >= 1 RIB complet si LCR. const canValidateAccounting = computed(() => { if (!hasAllRequiredAccountingFields(accounting)) return false if (isBankRequired.value && (accounting.bankIri === null)) return false - if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false + if (isRibRequired.value && !ribs.value.some(isRibComplete)) return false 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 { - ribs.value.push(emptyRib()) + if (canAddRib.value) ribs.value.push(emptyRib()) } function askRemoveRib(index: number): void { diff --git a/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts b/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts index c8929c9..2945a1b 100644 --- a/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts +++ b/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts @@ -7,14 +7,18 @@ import { canSelectDeliveryOrBilling, canSelectProspect, hasAllRequiredAccountingFields, + hasAtLeastOneInformationField, hasAtLeastOneValidContact, + isAddressValid, isBankRequiredForPaymentType, isBillingEmailRequired, isBlankRow, isContactBlank, isContactNamed, isRibBlank, + isRibComplete, isRibRequiredForPaymentType, + type AddressValidityDraft, type ContactDraft, type ContactFillableDraft, } from '../clientFormRules' @@ -271,3 +275,79 @@ describe('hasAllRequiredAccountingFields (RG-1.30)', () => { })).toBe(false) }) }) + +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) + }) +}) diff --git a/frontend/modules/commercial/utils/clientFormRules.ts b/frontend/modules/commercial/utils/clientFormRules.ts index ea838b4..755bd79 100644 --- a/frontend/modules/commercial/utils/clientFormRules.ts +++ b/frontend/modules/commercial/utils/clientFormRules.ts @@ -138,6 +138,16 @@ export function isRibBlank(rib: RibFillableDraft): boolean { 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 * livraison/facturation. Prospect n'est selectionnable que si ni Livraison ni @@ -226,6 +236,31 @@ export function addressTypeFromFlags(flags: AddressFlagsDraft): AddressType | nu 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). */ const PAYMENT_TYPE_TRANSFER = 'VIREMENT' @@ -248,6 +283,36 @@ export function isRibRequiredForPaymentType(code: string | null | undefined): bo 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. */ export interface AccountingRequiredDraft { siren: string | null -- 2.39.5 From 8d80781e8cf79d96f83dc7a85b85da7322586676 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 8 Jun 2026 10:23:42 +0200 Subject: [PATCH 02/12] fix(commercial) : masque Relation / Prestation de triage selon la categorie (formulaire client) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les champs « Relation » (depend du distributeur/courtier) et « Prestation de triage » du bloc principal sont masques par defaut et reveles uniquement quand une categorie ordinaire (autre que Distributeur/Courtier) est selectionnee. Masquer ces champs reinitialise leurs valeurs (pas de relation/triage fantome soumis). Applique a la creation et a l'edition. --- .../commercial/pages/clients/[id]/edit.vue | 31 +++++++++++++++++-- .../modules/commercial/pages/clients/new.vue | 29 +++++++++++++++-- .../utils/__tests__/clientFormRules.spec.ts | 18 +++++++++++ .../commercial/utils/clientFormRules.ts | 20 ++++++++++++ 4 files changed, 93 insertions(+), 5 deletions(-) diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue index 2600aa4..784f654 100644 --- a/frontend/modules/commercial/pages/clients/[id]/edit.vue +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -41,6 +41,7 @@ @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" /> -- 2.39.5 From 27f2dcd4c0ede1e7fdfef11e33d978c5dc2ad9e0 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 8 Jun 2026 12:17:16 +0200 Subject: [PATCH 04/12] fix(commercial) : retrait RG-1.04 (onglet Information facultatif pour tous) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'onglet Information n'est plus obligatoire pour le role metier Commerciale : il devient facultatif pour tous les roles, cote back comme cote front (le front l'etait deja). - Suppression du validateur ClientInformationCompletenessValidator et de son gating (validateInformationCompleteness / currentUserIsCommerciale) dans ClientProcessor. Security conserve (gating accounting/archive/manage). - Tests : retrait des 3 tests RG-1.04 (ClientProcessorTest) ; POST Commerciale attendu en 201 et suppression du test dedie (ClientRBACMatrixTest). - Coherence : commentaires de colonnes BDD (catalogue + migration d'init) passes a « Facultatif », nettoyage des references RG-1.04 (BusinessRoles, RbacSeeder, User, fixtures, front, specs M1, README). Le role metier Commerciale et ses permissions RBAC restent inchanges. - Pas de migration de schema (colonnes Information deja nullable). --- README.md | 2 +- docs/specs/M1-clients/cahier-test-back-M1.md | 9 +- docs/specs/M1-clients/spec-back.md | 9 +- docs/specs/M1-clients/spec-front.md | 16 ++-- .../modules/commercial/utils/clientEdit.ts | 6 +- .../commercial/utils/clientFormRules.ts | 9 +- migrations/Version20260601000000.php | 14 +-- .../ClientAccountingCompletenessValidator.php | 6 +- ...ClientInformationCompletenessValidator.php | 74 ---------------- .../State/Processor/ClientProcessor.php | 45 ++-------- .../DataFixtures/ClientFixtures.php | 2 +- .../Core/Application/Rbac/RbacSeeder.php | 4 +- src/Module/Core/Domain/Entity/User.php | 4 +- .../Contract/BusinessRoleAwareInterface.php | 1 - src/Shared/Domain/Security/BusinessRoles.php | 14 +-- .../Database/ColumnCommentsCatalog.php | 14 +-- tests/Module/Commercial/Api/ClientApiTest.php | 4 +- .../Commercial/Api/ClientRBACMatrixTest.php | 32 ++----- .../Commercial/Api/ClientSecurityTest.php | 4 +- .../Commercial/Unit/ClientProcessorTest.php | 88 +------------------ 20 files changed, 70 insertions(+), 287 deletions(-) delete mode 100644 src/Module/Commercial/Application/Validator/ClientInformationCompletenessValidator.php diff --git a/README.md b/README.md index 5388528..b01b52c 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ Disponibles uniquement après `make db-reset` / `make fixtures` (état « avec s | `bob` | `bob` | ROLE_USER | — | | `bureau` | `demo` | ROLE_USER | clients : 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 | --- diff --git a/docs/specs/M1-clients/cahier-test-back-M1.md b/docs/specs/M1-clients/cahier-test-back-M1.md index 33fe309..f0b58e4 100644 --- a/docs/specs/M1-clients/cahier-test-back-M1.md +++ b/docs/specs/M1-clients/cahier-test-back-M1.md @@ -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 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 -fonctionnel) sont **délégués à ERP-74 (#493)** : ces rôles n'existent qu'après le +des **rôles métier** (matrice RBAC bureau/compta/commerciale/usine) sont +**délégués à ERP-74 (#493)** : ces rôles n'existent qu'après le merge de la stack. ## 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.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.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.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 | @@ -60,8 +60,7 @@ merge de la stack. - **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. -- **RG-1.04 fonctionnel** : PATCH onglet Information par une Commerciale avec - champs incomplets → 422 ; même PATCH par Admin → 200 (+ durcissement code/spec). +- ~~**RG-1.04 fonctionnel**~~ : _(SUPPRIMÉE)_ l'onglet Information est désormais facultatif pour tous les rôles. - Raison : ces rôles métier ne sont seedés qu'après le merge de la stack M1. ## Gaps & suivi diff --git a/docs/specs/M1-clients/spec-back.md b/docs/specs/M1-clients/spec-back.md index fb30551..c5706d2 100644 --- a/docs/specs/M1-clients/spec-back.md +++ b/docs/specs/M1-clients/spec-back.md @@ -310,7 +310,7 @@ CREATE TABLE client ( distributor_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, - -- Onglet Information (Commerciale obligatoire — RG-1.04 — null sinon) + -- Onglet Information (facultatif pour tous — RG-1.04 supprimée) description TEXT, competitors VARCHAR(255), founded_at DATE, @@ -864,8 +864,7 @@ Cf. § 2.6. Pattern Shared standard. ### 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. - - **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). +- ~~**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. ### Onglet Contact @@ -883,7 +882,7 @@ Cf. § 2.6. Pattern Shared standard. ### 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.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 ». @@ -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.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.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.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) diff --git a/docs/specs/M1-clients/spec-front.md b/docs/specs/M1-clients/spec-front.md index 88a19ab..466d020 100644 --- a/docs/specs/M1-clients/spec-front.md +++ b/docs/specs/M1-clients/spec-front.md @@ -13,7 +13,7 @@ date_redaction: 2026-05-28 # === LIENS === 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] lien_spec_back: ./spec-back.md @@ -105,13 +105,13 @@ Saisir les informations de l'entreprise. | Champ | Type | Obligatoire | Règle | |---|---|---|---| -| **Description** | `` | Conditionnel | RG-1.04 (obligatoire pour rôle Commerciale) | -| **Concurrents** | `` | Conditionnel | RG-1.04 | -| **Date de création** (de l'entreprise) | `` (exception Malio — pas de composant date couvert) | Conditionnel | RG-1.04 | -| **Nombre de salariés** | `` | Conditionnel | RG-1.04 | -| **CA €** | `` | Conditionnel | RG-1.04 | -| **Dirigeant** | `` | Conditionnel | RG-1.04 | -| **Résultat €** | `` | Conditionnel | RG-1.04 | +| **Description** | `` | Non | — _(onglet facultatif, RG-1.04 supprimée)_ | +| **Concurrents** | `` | Non | — | +| **Date de création** (de l'entreprise) | `` (exception Malio — pas de composant date couvert) | Non | — | +| **Nombre de salariés** | `` | Non | — | +| **CA €** | `` | Non | — | +| **Dirigeant** | `` | Non | — | +| **Résultat €** | `` | Non | — | **Action** : « Valider » → PATCH partiel `/api/clients/{id}` (groupe `client:write:information`). diff --git a/frontend/modules/commercial/utils/clientEdit.ts b/frontend/modules/commercial/utils/clientEdit.ts index 6b949d7..c7551f6 100644 --- a/frontend/modules/commercial/utils/clientEdit.ts +++ b/frontend/modules/commercial/utils/clientEdit.ts @@ -12,10 +12,8 @@ * * Ces helpers ne touchent ni a l'API ni a l'etat reactif. * - * NOTE RG-1.04 (Information obligatoire pour la Commerciale) : volontairement NON - * miroitee cote front (cf. clientFormRules.ts) — /api/me n'expose pas le code de - * role et Bureau partage les permissions de Commerciale. Le back l'applique de - * maniere fiable (422) ; on laisse remonter ce 422 en toast. + * NOTE : l'onglet Information est facultatif pour tous les roles (RG-1.04 + * « Information obligatoire pour la Commerciale » retiree cote back). */ import { diff --git a/frontend/modules/commercial/utils/clientFormRules.ts b/frontend/modules/commercial/utils/clientFormRules.ts index 80ce4bf..a9ea3b5 100644 --- a/frontend/modules/commercial/utils/clientFormRules.ts +++ b/frontend/modules/commercial/utils/clientFormRules.ts @@ -9,12 +9,9 @@ * 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). * - * NOTE RG-1.04 (Information obligatoire pour la Commerciale) : volontairement - * NON miroite cote front pour l'instant. Le payload /api/me ne porte pas le code - * de role (roles = IRIs opaques) et Bureau partage les memes permissions que - * Commerciale : aucun signal fiable pour distinguer le role cote front. Le back - * (ClientProcessor, via BusinessRoleAware) applique la regle de maniere fiable ; - * a rebrancher ici des qu'un code de role sera expose dans /api/me. + * NOTE : l'onglet Information est facultatif pour tous les roles. L'ancienne + * RG-1.04 (« Information obligatoire pour la Commerciale ») a ete retiree cote + * back — rien a miroiter ici. */ /** diff --git a/migrations/Version20260601000000.php b/migrations/Version20260601000000.php index 75a0934..0490f08 100644 --- a/migrations/Version20260601000000.php +++ b/migrations/Version20260601000000.php @@ -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', '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', 'description', 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.'); - $this->comment('client', 'competitors', 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).'); - $this->comment('client', 'founded_at', 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).'); - $this->comment('client', 'employees_count', 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).'); - $this->comment('client', 'revenue_amount', 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).'); - $this->comment('client', 'director_name', 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).'); - $this->comment('client', 'profit_amount', 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).'); + $this->comment('client', 'description', 'Onglet Information : description libre. Facultatif.'); + $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. Facultatif.'); + $this->comment('client', 'employees_count', 'Onglet Information : effectif (entier >= 0). Facultatif.'); + $this->comment('client', 'revenue_amount', 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Facultatif.'); + $this->comment('client', 'director_name', 'Onglet Information : nom du dirigeant. Facultatif.'); + $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', '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.'); diff --git a/src/Module/Commercial/Application/Validator/ClientAccountingCompletenessValidator.php b/src/Module/Commercial/Application/Validator/ClientAccountingCompletenessValidator.php index 32a2a23..7c4dc11 100644 --- a/src/Module/Commercial/Application/Validator/ClientAccountingCompletenessValidator.php +++ b/src/Module/Commercial/Application/Validator/ClientAccountingCompletenessValidator.php @@ -16,9 +16,9 @@ use Symfony\Component\Validator\ConstraintViolationList; * Type de reglement). La banque reste conditionnelle (RG-1.12) et les RIB aussi * (RG-1.13) : ils ne sont pas couverts ici. * - * Calque sur ClientInformationCompletenessValidator (RG-1.04) : colonnes nullable - * en base + validateur contextuel, plutot qu'un Assert\NotBlank sur l'entite (qui - * casserait le POST de l'onglet principal, lequel n'envoie aucun champ comptable). + * Parti pris : colonnes nullable en base + validateur contextuel, plutot qu'un + * Assert\NotBlank sur l'entite (qui casserait le POST de l'onglet principal, + * lequel n'envoie aucun champ comptable). * * Invoque par le ClientProcessor uniquement quand le payload porte les six champs * (= une validation d'onglet), jamais sur un PATCH ciblant un seul champ comptable. diff --git a/src/Module/Commercial/Application/Validator/ClientInformationCompletenessValidator.php b/src/Module/Commercial/Application/Validator/ClientInformationCompletenessValidator.php deleted file mode 100644 index fe374fe..0000000 --- a/src/Module/Commercial/Application/Validator/ClientInformationCompletenessValidator.php +++ /dev/null @@ -1,74 +0,0 @@ - valeur courante de l'onglet Information. - $fields = [ - 'description' => $client->getDescription(), - 'competitors' => $client->getCompetitors(), - 'foundedAt' => $client->getFoundedAt(), - 'employeesCount' => $client->getEmployeesCount(), - 'revenueAmount' => $client->getRevenueAmount(), - 'directorName' => $client->getDirectorName(), - 'profitAmount' => $client->getProfitAmount(), - ]; - - $violations = new ConstraintViolationList(); - - foreach ($fields as $property => $value) { - if ($this->isMissing($value)) { - $violations->add(new ConstraintViolation( - sprintf('Ce champ est obligatoire pour le role Commerciale (champ "%s").', $property), - null, - [], - $client, - $property, - $value, - )); - } - } - - if (count($violations) > 0) { - throw new ValidationException($violations); - } - } - - /** - * Une valeur est manquante si null ou, pour une chaine, vide apres trim. - * Les zeros numeriques (employeesCount = 0, profitAmount = "0.00") sont des - * valeurs valides : on ne les considere pas manquants. - */ - private function isMissing(mixed $value): bool - { - if (null === $value) { - return true; - } - - return is_string($value) && '' === trim($value); - } -} diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php index e451ee7..8967c5b 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php @@ -9,11 +9,8 @@ use ApiPlatform\State\ProcessorInterface; use ApiPlatform\Validator\Exception\ValidationException; use App\Module\Commercial\Application\Service\ClientFieldNormalizer; use App\Module\Commercial\Application\Validator\ClientAccountingCompletenessValidator; -use App\Module\Commercial\Application\Validator\ClientInformationCompletenessValidator; use App\Module\Commercial\Domain\Entity\Client; -use App\Shared\Domain\Contract\BusinessRoleAwareInterface; use App\Shared\Domain\Contract\CategoryInterface; -use App\Shared\Domain\Security\BusinessRoles; use DateTimeImmutable; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; @@ -44,8 +41,8 @@ use Symfony\Component\Validator\ConstraintViolationList; * 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer. * 3. Regles metier : RG-1.03 (distributor/broker * exclusifs + type de categorie), RG-1.12 (Virement -> banque), - * RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information exigee sur POST - * et tout PATCH pour le role Commerciale). + * RG-1.13 (LCR -> >= 1 RIB). L'onglet Information est entierement facultatif + * (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). * 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 @@ -108,7 +105,6 @@ final class ClientProcessor implements ProcessorInterface #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] private readonly ProcessorInterface $persistProcessor, private readonly ClientFieldNormalizer $normalizer, - private readonly ClientInformationCompletenessValidator $informationValidator, private readonly ClientAccountingCompletenessValidator $accountingValidator, private readonly Security $security, private readonly RequestStack $requestStack, @@ -136,7 +132,6 @@ final class ClientProcessor implements ProcessorInterface $this->validateDistributorBroker($data); $this->validateAccountingConsistency($data); $this->validateAccountingCompleteness($data); - $this->validateInformationCompleteness($data); try { return $this->persistProcessor->process($data, $operation, $uriVariables, $context); @@ -505,9 +500,9 @@ final class ClientProcessor implements ProcessorInterface * ce n'est pas une validation d'onglet (edition ponctuelle preservee). bank / * RIB restent geres par validateAccountingConsistency (RG-1.12 / RG-1.13). * - * Colonnes nullable en base + validateur contextuel (meme parti que RG-1.04) : - * un Assert\NotBlank sur l'entite casserait le POST de l'onglet principal, qui - * n'envoie aucun champ comptable. + * Colonnes nullable en base + validateur contextuel : un Assert\NotBlank sur + * l'entite casserait le POST de l'onglet principal, qui n'envoie aucun champ + * comptable. */ private function validateAccountingCompleteness(Client $data): void { @@ -520,21 +515,6 @@ final class ClientProcessor implements ProcessorInterface $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 * CategoryInterface::getCode() (pas d'import de Category — regle ABSOLUE n°1). @@ -550,21 +530,12 @@ final class ClientProcessor implements ProcessorInterface 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 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 - * declenchement conditionnel de RG-1.04 — sans elles, un PATCH - * « representation complete » porteur de @id ferait croire a une - * modification multi-onglets. + * groupe d'ecriture connu. C'est la base du 422 d'archivage (RG-1.22) — + * sans elles, un PATCH « representation complete » porteur de @id ferait + * croire a une modification multi-onglets. * * @return list */ diff --git a/src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php b/src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php index 40a899f..2b741e2 100644 --- a/src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php +++ b/src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php @@ -234,7 +234,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface $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( $manager, companyName: 'Holding Premium Invest', diff --git a/src/Module/Core/Application/Rbac/RbacSeeder.php b/src/Module/Core/Application/Rbac/RbacSeeder.php index 6c6c327..ebbc0ca 100644 --- a/src/Module/Core/Application/Rbac/RbacSeeder.php +++ b/src/Module/Core/Application/Rbac/RbacSeeder.php @@ -36,8 +36,8 @@ final class RbacSeeder { /** * Codes des roles metier (snake_case, regex Role respectee). `commerciale` - * reference la constante Shared deja consommee par le ClientProcessor - * (RG-1.04) pour eviter tout drift : un seul litteral pour ce code. + * reference la constante Shared pour eviter tout drift : un seul litteral + * pour ce code. */ public const string ROLE_BUREAU = 'bureau'; public const string ROLE_COMPTA = 'compta'; diff --git a/src/Module/Core/Domain/Entity/User.php b/src/Module/Core/Domain/Entity/User.php index 09d0a85..74bd580 100644 --- a/src/Module/Core/Domain/Entity/User.php +++ b/src/Module/Core/Domain/Entity/User.php @@ -344,8 +344,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Busines /** * Implemente BusinessRoleAwareInterface : vrai si l'un des roles RBAC * 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 - * cette classe. Comparaison stricte sur Role::code. + * role metier (ex: `commerciale`) sans importer cette classe. Comparaison + * stricte sur Role::code. */ public function hasBusinessRole(string $roleCode): bool { diff --git a/src/Shared/Domain/Contract/BusinessRoleAwareInterface.php b/src/Shared/Domain/Contract/BusinessRoleAwareInterface.php index 92646a4..9f5fbb4 100644 --- a/src/Shared/Domain/Contract/BusinessRoleAwareInterface.php +++ b/src/Shared/Domain/Contract/BusinessRoleAwareInterface.php @@ -10,7 +10,6 @@ namespace App\Shared\Domain\Contract; * App\Shared\Domain\Security\BusinessRoles). * * 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 * (regle ABSOLUE n°1 : pas d'import inter-modules). * diff --git a/src/Shared/Domain/Security/BusinessRoles.php b/src/Shared/Domain/Security/BusinessRoles.php index 83aac90..4270878 100644 --- a/src/Shared/Domain/Security/BusinessRoles.php +++ b/src/Shared/Domain/Security/BusinessRoles.php @@ -10,9 +10,11 @@ namespace App\Shared\Domain\Security; * Distincts des roles SYSTEME (cf. App\Module\Core\Domain\Security\SystemRoles : * `admin` / `user`). Un role metier porte une intention fonctionnelle (poste de * 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 - * pour le seul role Commerciale, alors que Commerciale et Bureau partagent les - * memes permissions (commercial.clients.view + manage, cf. spec-back M1 § 5.2). + * RBAC pures (Commerciale et Bureau partagent par ex. les memes permissions + * commercial.clients.view + manage mais peuvent porter des regles de gestion + * 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 : * - 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 : * - ERP-74 seede le role `commerciale` avec ce code exact. * - 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 - * role `commerciale`, la validation de completude Information reste dormante. + * - ERP-55 le referencait pour la completude Information (M1 RG-1.04), regle + * depuis retiree ; le code reste utilise par le seed RBAC et les personas. */ final class BusinessRoles { /** * 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'; diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index faa1188..70a32e2 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -175,13 +175,13 @@ final class ColumnCommentsCatalog 'distributor_id' => 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.', '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.', - 'description' => 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.', - 'competitors' => 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).', - 'founded_at' => 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).', - 'employees_count' => 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).', - 'revenue_amount' => 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).', - 'director_name' => 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).', - 'profit_amount' => 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).', + 'description' => 'Onglet Information : description libre. Facultatif.', + 'competitors' => 'Onglet Information : concurrents identifies (texte libre ≤ 255). Facultatif.', + 'founded_at' => 'Onglet Information : date de creation de l entreprise. Facultatif.', + 'employees_count' => 'Onglet Information : effectif (entier >= 0). Facultatif.', + 'revenue_amount' => 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Facultatif.', + 'director_name' => 'Onglet Information : nom du dirigeant. Facultatif.', + '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).', '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.', diff --git a/tests/Module/Commercial/Api/ClientApiTest.php b/tests/Module/Commercial/Api/ClientApiTest.php index 445447b..a590877 100644 --- a/tests/Module/Commercial/Api/ClientApiTest.php +++ b/tests/Module/Commercial/Api/ClientApiTest.php @@ -14,8 +14,8 @@ use App\Module\Sites\Domain\Entity\Site; * * Authentifies en ADMIN (bypass RBAC via isAdmin) : on valide ici les regles * METIER (normalisation, unicite, distributor/broker, archivage, liste). Le - * gating par permission (accounting.manage / archive / RG-1.28 strict, RG-1.04 - * Commerciale) est couvert par les tests unitaires du ClientProcessor : il + * gating par permission (accounting.manage / archive / RG-1.28 strict) est + * couvert par les tests unitaires du ClientProcessor : il * exige des users non-admin portant des permissions `commercial.clients.*` qui * ne sont declarees qu'en ERP-59 (tests RBAC complets en ERP-60). * diff --git a/tests/Module/Commercial/Api/ClientRBACMatrixTest.php b/tests/Module/Commercial/Api/ClientRBACMatrixTest.php index 014ab66..bd51856 100644 --- a/tests/Module/Commercial/Api/ClientRBACMatrixTest.php +++ b/tests/Module/Commercial/Api/ClientRBACMatrixTest.php @@ -14,8 +14,7 @@ use Symfony\Component\Console\Output\NullOutput; /** * 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 - * bureau / compta / commerciale / usine, plus le durcissement RG-1.04 - * (Commerciale) au POST. + * bureau / compta / commerciale / usine. * * 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. @@ -174,14 +173,14 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase $client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]); self::assertResponseStatusCodeSame(200); - // manage : la creation passe la security d'operation (pas un 403 comme - // Compta) mais bute sur RG-1.04 (onglet Information incomplet) -> 422. - // C'est la preuve que Commerciale porte `manage` (sinon 403). + // manage : la creation passe la security d'operation et aboutit -> 201 + // (l'onglet Information est facultatif pour tous depuis le retrait de + // RG-1.04). C'est la preuve que Commerciale porte `manage` (sinon 403). $client->request('POST', '/api/clients', [ 'headers' => ['Content-Type' => self::LD], 'json' => $this->validMainPayload('Commerciale Post'), ]); - self::assertResponseStatusCodeSame(422); + self::assertResponseStatusCodeSame(201); // PAS accounting : edition onglet Comptabilite refusee $client->request('PATCH', '/api/clients/'.$seed->getId(), [ @@ -198,27 +197,6 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase 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 { // FIX review MR #40 : un Compta (accounting.manage, PAS manage) faisant un diff --git a/tests/Module/Commercial/Api/ClientSecurityTest.php b/tests/Module/Commercial/Api/ClientSecurityTest.php index 4d9abf1..f967ed9 100644 --- a/tests/Module/Commercial/Api/ClientSecurityTest.php +++ b/tests/Module/Commercial/Api/ClientSecurityTest.php @@ -12,8 +12,8 @@ namespace App\Tests\Module\Commercial\Api; * - 403 si l'utilisateur authentifie ne porte pas `commercial.clients.view`. * * ⚠ 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 - * exigent les roles seedes apres le merge de la stack. NE PAS les ajouter ici. + * / usine) est DELEGUEE a ERP-74 (#493) : elle exige les roles seedes apres le + * merge de la stack. NE PAS l'ajouter ici. * * @internal */ diff --git a/tests/Module/Commercial/Unit/ClientProcessorTest.php b/tests/Module/Commercial/Unit/ClientProcessorTest.php index 1ac8905..4fce0cf 100644 --- a/tests/Module/Commercial/Unit/ClientProcessorTest.php +++ b/tests/Module/Commercial/Unit/ClientProcessorTest.php @@ -9,7 +9,6 @@ use ApiPlatform\State\ProcessorInterface; use ApiPlatform\Validator\Exception\ValidationException; use App\Module\Commercial\Application\Service\ClientFieldNormalizer; 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\Client; 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\TvaMode; 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\UnitOfWork; use PHPUnit\Framework\TestCase; @@ -27,13 +24,11 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; -use Symfony\Component\Security\Core\User\UserInterface; /** * Tests unitaires du ClientProcessor : gating par permission (accounting.manage * / 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 - * un RequestStack stubbes. + * (RG-1.12 Virement, RG-1.13 LCR), grace a un Security et un RequestStack stubbes. * * @internal */ @@ -342,62 +337,6 @@ final class ClientProcessorTest extends TestCase 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 $granted Permissions accordees a l'utilisateur courant * @param array $payload Corps JSON simule de la requete @@ -407,7 +346,6 @@ final class ClientProcessorTest extends TestCase private function makeProcessor( array $granted, array $payload, - ?UserInterface $user = null, bool $managed = false, array $originalData = [], ): ClientProcessor { @@ -422,7 +360,6 @@ final class ClientProcessorTest extends TestCase $security->method('isGranted')->willReturnCallback( static fn (mixed $attribute): bool => is_string($attribute) && in_array($attribute, $granted, true), ); - $security->method('getUser')->willReturn($user); $requestStack = new RequestStack(); $requestStack->push(new Request([], [], [], [], [], [], json_encode($payload, JSON_THROW_ON_ERROR))); @@ -440,7 +377,6 @@ final class ClientProcessorTest extends TestCase return new ClientProcessor( $persist, new ClientFieldNormalizer(), - new ClientInformationCompletenessValidator(), new ClientAccountingCompletenessValidator(), $security, $requestStack, @@ -493,26 +429,4 @@ final class ClientProcessorTest extends TestCase { 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'; - } - }; - } } -- 2.39.5 From e06bc79127c1e9f00ac6c7bcad9b493f9633a4e3 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 8 Jun 2026 12:29:25 +0200 Subject: [PATCH 05/12] =?UTF-8?q?fix(address)=20:=20recherche=20adresse=20?= =?UTF-8?q?BAN=20=E2=80=94=20retry=20apres=20erreur=20+=20garde=203=20cara?= =?UTF-8?q?cteres?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une erreur de l'API d'autocompletion (BAN) ne bascule plus le champ Adresse en saisie libre de maniere definitive : l'autocompletion reste montee et chaque frappe relance la recherche (le flag degrade etait verrouille a true sans jamais etre reinitialise). - Garde min. 3 caracteres avant l'appel BAN (evite le 400 de l'API). - Ville : repli saisie libre conserve mais recuperable (re-saisir le code postal repasse en select au succes). - Avertissement « service indisponible » emis une seule fois. - Tests Vitest : pas d'appel < 3 car., relance apres erreur, emission unique de l'evenement. --- .../components/ClientAddressBlock.vue | 49 ++++++++----- .../__tests__/ClientAddressBlock.spec.ts | 73 +++++++++++++++++-- 2 files changed, 98 insertions(+), 24 deletions(-) diff --git a/frontend/modules/commercial/components/ClientAddressBlock.vue b/frontend/modules/commercial/components/ClientAddressBlock.vue index 1745a4b..a188159 100644 --- a/frontend/modules/commercial/components/ClientAddressBlock.vue +++ b/frontend/modules/commercial/components/ClientAddressBlock.vue @@ -87,8 +87,9 @@ @update:model-value="onPostalCodeChange" /> - +
- + ([]) // Adresses proposees par la BAN (alimentees a la saisie d'adresse). @@ -258,10 +266,10 @@ function update(field: K, value: AddressFormDr emit('update:modelValue', { ...props.modelValue, [field]: value }) } -/** Bascule définitivement en mode degrade et previent le parent (toast unique). */ -function enterDegraded(): void { - if (!degraded.value) { - degraded.value = true +/** Previent le parent (toast unique) que l'autocompletion est indisponible. */ +function notifyUnavailable(): void { + if (!unavailableNotified) { + unavailableNotified = true emit('degraded') } } @@ -270,9 +278,6 @@ function enterDegraded(): void { async function onPostalCodeChange(value: string): Promise { update('postalCode', value) - if (degraded.value) { - return - } const digits = (value ?? '').replace(/\D/g, '') if (digits.length < 5) { return @@ -280,15 +285,22 @@ async function onPostalCodeChange(value: string): Promise { try { const suggestions = await autocomplete.searchCity(digits) 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 { - enterDegraded() + // BAN indispo : Ville en saisie libre (recuperable au prochain essai). + degraded.value = true + notifyUnavailable() } } /** Recherche d'adresse assistee (event de MalioInputAutocomplete). */ async function onAddressSearch(query: string): Promise { - 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 } addressLoading.value = true @@ -299,7 +311,10 @@ async function onAddressSearch(query: string): Promise { banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label })) } 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 { addressLoading.value = false diff --git a/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts b/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts index 6f0ba9e..20cb438 100644 --- a/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts +++ b/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts @@ -1,16 +1,21 @@ -import { describe, it, expect, vi } from 'vitest' -import { mount } from '@vue/test-utils' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' import { defineComponent, h, ref, computed } from 'vue' import { emptyAddress } from '~/modules/commercial/types/clientForm' import ClientAddressBlock from '../ClientAddressBlock.vue' -// Le composable BAN est mocke : aucun appel reseau, aucune suggestion chargee. -// On reproduit ainsi l'etat « adresse persistee, mais liste de suggestions -// vide » (remontage apres validation / edition d'une adresse existante). +// Mocks controlables du composable BAN (hoisted) : chaque test configure le +// comportement de searchCity / searchAddress (succes, rejet, rejet-puis-succes). +// 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', () => ({ useAddressAutocomplete: () => ({ - searchCity: vi.fn(), - searchAddress: vi.fn(), + searchCity: searchCityMock, + searchAddress: searchAddressMock, }), })) @@ -130,3 +135,57 @@ describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => { 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) + }) +}) -- 2.39.5 From 5441bb3a7528fe02ed78a54ee72b8afd970dd50b Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 8 Jun 2026 12:38:05 +0200 Subject: [PATCH 06/12] fix(commercial) : aligne le bas du textarea description sur les autres champs Le textarea (h-full) remplissait toute la cellule alors que les inputs ont un champ de 40px centre dans un conteneur h-12 (~4px de coussin haut ET bas). Le composant n'appliquait qu'un pt-1 (haut) : ajout d'un pb-1 symetrique pour aligner le bord bas. Applique aux 3 ecrans client (creation / edition / consultation). --- frontend/modules/commercial/pages/clients/[id]/edit.vue | 5 ++++- frontend/modules/commercial/pages/clients/[id]/index.vue | 5 ++++- frontend/modules/commercial/pages/clients/new.vue | 8 +++++--- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue index 2a53dfa..bc824b0 100644 --- a/frontend/modules/commercial/pages/clients/[id]/edit.vue +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -92,11 +92,14 @@