From f407c3d46a8e03e7067489032b6dd5641dfd6e21 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 4 Jun 2026 12:01:14 +0200 Subject: [PATCH] fix(commercial) : ne sauter que les blocs contact/RIB totalement vides (bloc partiel sans nom -> 422 inline, ERP-110) --- .../commercial/pages/clients/[id]/edit.vue | 16 +++-- .../modules/commercial/pages/clients/new.vue | 16 +++-- .../utils/__tests__/clientFormRules.spec.ts | 59 +++++++++++++++++++ .../commercial/utils/clientFormRules.ts | 52 ++++++++++++++++ 4 files changed, 131 insertions(+), 12 deletions(-) diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue index 9c29de4..afb6275 100644 --- a/frontend/modules/commercial/pages/clients/[id]/edit.vue +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -414,7 +414,9 @@ import { hasAtLeastOneValidContact, isBankRequiredForPaymentType, isBillingEmailRequired, + isContactBlank, isContactNamed, + isRibBlank, isRibRequiredForPaymentType, } from '~/modules/commercial/utils/clientFormRules' import { @@ -742,8 +744,9 @@ async function submitContacts(): Promise { } removedContactIds.value = [] - // On tente TOUS les blocs (collecte des erreurs par index, ERP-110) ; les - // blocs vides (ni nom ni prenom) sont ignores. + // On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls + // les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli + // sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc. const hasError = await submitRows( contacts.value, contactErrors, @@ -763,7 +766,7 @@ async function submitContacts(): Promise { } }, error => showError(error), - contact => !isContactNamed(contact), + contact => isContactBlank(contact), ) // Tant qu'un bloc reste en erreur : pas de toast succes. if (hasError) return @@ -917,8 +920,9 @@ async function submitAccounting(): Promise { } removedRibIds.value = [] - // 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes — - // les blocs RIB incomplets sont ignores). + // 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes). + // Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex. + // IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline. const ribHasError = await submitRows( ribs.value, ribErrors, @@ -937,7 +941,7 @@ async function submitAccounting(): Promise { } }, error => showError(error), - rib => !ribIsComplete(rib), + rib => isRibBlank(rib), ) if (ribHasError) return toast.success({ title: t('commercial.clients.toast.updateSuccess') }) diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue index 366fb9e..7136d0e 100644 --- a/frontend/modules/commercial/pages/clients/new.vue +++ b/frontend/modules/commercial/pages/clients/new.vue @@ -385,7 +385,9 @@ import { hasAtLeastOneValidContact, isBankRequiredForPaymentType, isBillingEmailRequired, + isContactBlank, isContactNamed, + isRibBlank, isRibRequiredForPaymentType, } from '~/modules/commercial/utils/clientFormRules' import { @@ -677,8 +679,9 @@ async function submitContacts(): Promise { if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return tabSubmitting.value = true try { - // On tente TOUS les blocs (collecte des erreurs par index, ERP-110) ; les - // blocs vides (ni nom ni prenom) sont ignores. + // On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls + // les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli + // sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc. const hasError = await submitRows( contacts.value, contactErrors, @@ -705,7 +708,7 @@ async function submitContacts(): Promise { } }, error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }), - contact => !isContactNamed(contact), + contact => isContactBlank(contact), ) // Tant qu'un bloc reste en erreur : pas de validation d'onglet ni de toast succes. if (hasError) return @@ -901,8 +904,9 @@ async function submitAccounting(): Promise { return } - // 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes — - // les blocs RIB incomplets sont ignores). + // 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes). + // Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex. + // IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline. const ribHasError = await submitRows( ribs.value, ribErrors, @@ -921,7 +925,7 @@ async function submitAccounting(): Promise { } }, error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }), - rib => !ribIsComplete(rib), + rib => isRibBlank(rib), ) if (ribHasError) return diff --git a/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts b/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts index 5c76d00..eda7206 100644 --- a/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts +++ b/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts @@ -7,11 +7,27 @@ import { hasAtLeastOneValidContact, isBankRequiredForPaymentType, isBillingEmailRequired, + isBlankRow, + isContactBlank, isContactNamed, + isRibBlank, isRibRequiredForPaymentType, type ContactDraft, + type ContactFillableDraft, } from '../clientFormRules' +/** Bloc contact totalement vide (amorce par defaut). */ +function blankContact(): ContactFillableDraft { + return { + firstName: null, + lastName: null, + jobTitle: null, + phonePrimary: null, + phoneSecondary: null, + email: null, + } +} + describe('buildClientFormTabKeys (gating onglet Comptabilite + onglets edit-only)', () => { it('inclut l onglet accounting si l utilisateur a accounting.view', () => { expect(buildClientFormTabKeys(true)).toContain('accounting') @@ -59,6 +75,49 @@ describe('isContactNamed (RG-1.05)', () => { }) }) +describe('isBlankRow (primitive : toutes les valeurs vides)', () => { + it('vrai si toutes les valeurs sont nulles / vides / espaces', () => { + expect(isBlankRow([null, undefined, '', ' '])).toBe(true) + expect(isBlankRow([])).toBe(true) + }) + + it('faux des qu une valeur porte un caractere non-espace', () => { + expect(isBlankRow([null, 'x', ''])).toBe(false) + }) +}) + +describe('isRibBlank (bloc RIB totalement vide vs partiellement rempli)', () => { + it('vrai si label / bic / iban sont tous vides', () => { + expect(isRibBlank({ label: null, bic: null, iban: null })).toBe(true) + expect(isRibBlank({ label: ' ', bic: '', iban: null })).toBe(true) + }) + + it('faux si un IBAN seul est saisi (bloc a soumettre -> 422 NotBlank inline)', () => { + expect(isRibBlank({ label: null, bic: null, iban: 'FR1420041010050500013M02606' })).toBe(false) + }) + + it('faux si seul le libelle est saisi', () => { + expect(isRibBlank({ label: 'Compte courant', bic: null, iban: null })).toBe(false) + }) +}) + +describe('isContactBlank (bloc totalement vide vs partiellement rempli)', () => { + it('vrai si aucun champ saisissable n est rempli', () => { + expect(isContactBlank(blankContact())).toBe(true) + expect(isContactBlank({ ...blankContact(), firstName: ' ', email: '' })).toBe(true) + }) + + it('faux si un email seul est saisi (bloc a soumettre -> 422 RG-1.05 inline)', () => { + expect(isContactBlank({ ...blankContact(), email: 'jean@acme.fr' })).toBe(false) + }) + + it('faux si seul un telephone, une fonction ou un nom est saisi', () => { + expect(isContactBlank({ ...blankContact(), phonePrimary: '0612345678' })).toBe(false) + expect(isContactBlank({ ...blankContact(), jobTitle: 'Directeur' })).toBe(false) + expect(isContactBlank({ ...blankContact(), firstName: 'Alice' })).toBe(false) + }) +}) + describe('hasAtLeastOneValidContact (RG-1.14)', () => { it('faux sur une liste vide', () => { expect(hasAtLeastOneValidContact([])).toBe(false) diff --git a/frontend/modules/commercial/utils/clientFormRules.ts b/frontend/modules/commercial/utils/clientFormRules.ts index 280248e..f1f6830 100644 --- a/frontend/modules/commercial/utils/clientFormRules.ts +++ b/frontend/modules/commercial/utils/clientFormRules.ts @@ -86,6 +86,58 @@ export function hasAtLeastOneValidContact(contacts: ContactDraft[]): boolean { return contacts.some(isContactNamed) } +/** + * Primitive reutilisable : vrai si TOUTES les valeurs fournies sont vides (null / + * undefined / espaces uniquement). Sert a detecter un bloc de collection + * totalement vide (amorce non remplie). Un bloc qui porte la moindre donnee + * n'est PAS « blank » : il doit etre soumis pour declencher sa 422 inline plutot + * que d'etre saute silencieusement. + */ +export function isBlankRow(values: (string | null | undefined)[]): boolean { + return values.every(value => !isFilled(value)) +} + +/** Champs saisissables d'un bloc contact (pour detecter un bloc totalement vide). */ +export interface ContactFillableDraft extends ContactDraft { + jobTitle: string | null + phonePrimary: string | null + phoneSecondary: string | null + email: string | null +} + +/** + * Vrai si AUCUN champ saisissable du bloc contact n'est rempli. Distingue un bloc + * d'amorce vide (a ignorer au submit) d'un bloc partiellement rempli sans nom + * (email / telephone / fonction seul) : ce dernier doit etre soumis pour + * declencher la 422 RG-1.05 (« prenom ou nom obligatoire ») affichee inline. + */ +export function isContactBlank(contact: ContactFillableDraft): boolean { + return isBlankRow([ + contact.firstName, + contact.lastName, + contact.jobTitle, + contact.phonePrimary, + contact.phoneSecondary, + contact.email, + ]) +} + +/** Champs saisissables d'un bloc RIB (pour detecter un bloc totalement vide). */ +export interface RibFillableDraft { + label: string | null + bic: string | null + iban: string | null +} + +/** + * Vrai si AUCUN champ du bloc RIB n'est rempli. Un RIB partiellement rempli (ex. + * IBAN seul) n'est PAS « blank » : il doit etre soumis pour declencher les 422 + * NotBlank (label / bic / iban) inline plutot que d'etre saute silencieusement. + */ +export function isRibBlank(rib: RibFillableDraft): boolean { + return isBlankRow([rib.label, rib.bic, 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