From 982f501b94806a9da388400b779d43a11064c190 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 9 Jun 2026 13:13:50 +0200 Subject: [PATCH] feat(commercial) : revue de la validation front clients + RG type d'adresse (ERP-119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Boutons « Valider » toujours actifs (retrait du gating de validite) : le back renvoie les 422 mappees en rouge par champ. - Champs requis a colonne non-nullable : la cle est omise du payload si vide (companyName, RIB, adresse) -> 422 NotBlank au lieu d'un 400 de type a la deserialisation. - Onglet Contact : au moins un contact requis -> l'amorce vide est soumise pour declencher la 422 RG-1.05 quand aucun contact n'est nomme. - Adresse : affichage inline des erreurs type / sites / categories, et nouvelle RG back « au moins un type d'adresse obligatoire » (Callback sur isProspect). --- .../components/ClientAddressBlock.vue | 5 + .../__tests__/ClientAddressBlock.spec.ts | 26 +++++ .../commercial/pages/clients/[id]/edit.vue | 56 +++------- .../modules/commercial/pages/clients/new.vue | 100 ++++++------------ .../utils/__tests__/clientEdit.spec.ts | 42 ++++++++ .../utils/__tests__/clientFormRules.spec.ts | 31 ++++++ .../modules/commercial/utils/clientEdit.ts | 22 ++-- .../commercial/utils/clientFormRules.ts | 35 ++++++ .../Domain/Entity/ClientAddress.php | 17 +++ .../Commercial/Api/ClientAddressTest.php | 40 +++++++ .../Api/ClientSubResourceApiTest.php | 4 + 11 files changed, 267 insertions(+), 111 deletions(-) diff --git a/frontend/modules/commercial/components/ClientAddressBlock.vue b/frontend/modules/commercial/components/ClientAddressBlock.vue index a188159..bce7c9b 100644 --- a/frontend/modules/commercial/components/ClientAddressBlock.vue +++ b/frontend/modules/commercial/components/ClientAddressBlock.vue @@ -14,12 +14,15 @@ remplacant les 3 cases. Les options encodent les combinaisons valides (exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). --> + @@ -31,6 +34,7 @@ :display-tag="true" :readonly="readonly" :required="true" + :error="errors?.sites" @update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))" /> @@ -65,6 +69,7 @@ :display-tag="true" :readonly="readonly" :required="true" + :error="errors?.categories" @update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))" /> diff --git a/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts b/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts index 20cb438..8dce68c 100644 --- a/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts +++ b/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts @@ -134,6 +134,32 @@ describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => { ) expect(field?.attributes('data-error')).toBe('Code postal invalide.') }) + + // ERP-119 : type d'adresse (propertyPath back `isProspect`), sites et + // categories sont obligatoires ; leurs violations 422 doivent s'afficher sous + // le champ correspondant (bindings :error de ClientAddressBlock). + it('affiche l\'erreur serveur sur type d\'adresse (propertyPath isProspect)', () => { + const wrapper = mountWithErrors({ isProspect: 'Le type d\'adresse est obligatoire.' }) + + const field = wrapper.findAll('malio-select-stub').find( + el => el.attributes('label') === 'commercial.clients.form.address.addressType', + ) + expect(field?.attributes('error')).toBe('Le type d\'adresse est obligatoire.') + }) + + it('affiche les erreurs serveur sur sites et categories', () => { + const wrapper = mountWithErrors({ + sites: 'Au moins un site est obligatoire.', + categories: 'Au moins une catégorie est obligatoire.', + }) + + const checkboxes = wrapper.findAll('malio-select-checkbox-stub') + const sitesField = checkboxes.find(el => el.attributes('label') === 'commercial.clients.form.address.sites') + const categoriesField = checkboxes.find(el => el.attributes('label') === 'commercial.clients.form.address.categories') + + expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.') + expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.') + }) }) describe('ClientAddressBlock — recherche adresse robuste (erreur BAN)', () => { diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue index be7ef59..5d29cf4 100644 --- a/frontend/modules/commercial/pages/clients/[id]/edit.vue +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -82,7 +82,7 @@ @@ -178,7 +178,7 @@ @@ -216,7 +216,7 @@ @@ -347,7 +347,7 @@ @@ -419,8 +419,6 @@ import { } from '~/modules/commercial/utils/clientEdit' import { buildClientFormTabKeys, - hasAllRequiredAccountingFields, - hasAtLeastOneValidContact, isAddressValid, isBankRequiredForPaymentType, isBillingEmailRequired, @@ -673,17 +671,6 @@ const { } = useClientFormErrors() // ── Bloc principal ─────────────────────────────────────────────────────────── -const isMainValid = computed(() => { - const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== '' - const relationValid - = main.relationType === null - || (main.relationType === 'distributeur' && filled(main.distributorIri)) - || (main.relationType === 'courtier' && filled(main.brokerIri)) - return filled(main.companyName) - && main.categoryIris.length >= 1 - && relationValid -}) - async function onRelationChange(value: string | number | null): Promise { const relation = (value === null || value === '') ? null : (String(value) as 'distributeur' | 'courtier') main.relationType = relation @@ -697,7 +684,7 @@ async function onRelationChange(value: string | number | null): Promise { /** PATCH /clients/{id} — groupe client:write:main UNIQUEMENT (mode strict). */ async function submitMain(): Promise { - if (businessReadonly.value || !isMainValid.value || mainSubmitting.value) return + if (businessReadonly.value || mainSubmitting.value) return mainSubmitting.value = true mainErrors.clearErrors() try { @@ -750,9 +737,6 @@ const canAddContact = computed(() => { const last = contacts.value[contacts.value.length - 1] return last === undefined || isContactNamed(last) }) -// RG-1.14 : au moins un contact nomme pour finaliser l'onglet. -const canValidateContacts = computed(() => hasAtLeastOneValidContact(contacts.value)) - function addContact(): void { if (canAddContact.value) contacts.value.push(emptyContact()) } @@ -774,7 +758,7 @@ function askRemoveContact(index: number): void { * collection contacts (endpoints client_contact dedies). */ async function submitContacts(): Promise { - if (businessReadonly.value || !canValidateContacts.value || tabSubmitting.value) return + if (businessReadonly.value || tabSubmitting.value) return tabSubmitting.value = true contactErrors.value = [] try { @@ -783,6 +767,11 @@ async function submitContacts(): Promise { } removedContactIds.value = [] + // RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des + // amorces neuves vides (ex. tous les contacts existants supprimes), on ne + // les skippe pas -> le back renvoie la 422 RG-1.05 « prénom ou nom + // obligatoire » inline (la RG-1.14 n'a pas d'equivalent back au POST). + const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c)) // On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls // les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli // sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc. @@ -805,10 +794,10 @@ async function submitContacts(): Promise { } }, error => showError(error), - // On ne saute QUE les amorces neuves (id null) totalement vides. Un - // bloc existant vide est soumis -> 422 RG-1.05 inline (sinon la modif - // serait perdue en silence avec un faux toast de succes). - contact => contact.id === null && isContactBlank(contact), + // On ne saute une amorce neuve (id null) totalement vide QUE si un autre + // bloc sera soumis : sinon on la soumet pour declencher la 422 RG-1.05 + // (un onglet Contact vide ne doit pas passer en faux succes). + contact => hasSubmittableContact && contact.id === null && isContactBlank(contact), ) // Tant qu'un bloc reste en erreur : pas de toast succes. if (hasError) return @@ -823,10 +812,6 @@ async function submitContacts(): Promise { } // ── Onglet Adresse ─────────────────────────────────────────────────────────── -const canValidateAddresses = computed(() => - 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] @@ -859,7 +844,7 @@ function onAddressDegraded(): void { /** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */ async function submitAddresses(): Promise { - if (businessReadonly.value || !canValidateAddresses.value || tabSubmitting.value) return + if (businessReadonly.value || tabSubmitting.value) return tabSubmitting.value = true addressErrors.value = [] try { @@ -927,13 +912,6 @@ function onPaymentTypeChange(value: string | number | null): void { } } -const canValidateAccounting = computed(() => { - if (!hasAllRequiredAccountingFields(accounting)) return false - if (isBankRequired.value && accounting.bankIri === null) 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] @@ -965,7 +943,7 @@ function askRemoveRib(index: number): void { * 403 sur tout le payload). */ async function submitAccounting(): Promise { - if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return + if (accountingReadonly.value || tabSubmitting.value) return tabSubmitting.value = true accountingErrors.clearErrors() try { diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue index 73b5734..c50096a 100644 --- a/frontend/modules/commercial/pages/clients/new.vue +++ b/frontend/modules/commercial/pages/clients/new.vue @@ -76,7 +76,7 @@ @@ -140,13 +140,12 @@
+ actif par defaut). Onglet facultatif : un enregistrement a + vide reste possible, c'est le back qui valide. -->
@@ -178,7 +177,7 @@ @@ -216,7 +215,7 @@ @@ -347,7 +346,7 @@ @@ -389,11 +388,9 @@ import { computed, onMounted, reactive, ref, watch } from 'vue' import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials' import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors' import { + ADDRESS_REQUIRED_NON_NULLABLE_KEYS, buildClientFormTabKeys, CLIENT_FORM_PLACEHOLDER_TABS, - hasAllRequiredAccountingFields, - hasAtLeastOneInformationField, - hasAtLeastOneValidContact, isAddressValid, isBankRequiredForPaymentType, isBillingEmailRequired, @@ -402,6 +399,9 @@ import { isRibBlank, isRibComplete, isRibRequiredForPaymentType, + MAIN_REQUIRED_NON_NULLABLE_KEYS, + omitEmptyRequired, + RIB_REQUIRED_NON_NULLABLE_KEYS, showsRelationAndTriageFields, } from '~/modules/commercial/utils/clientFormRules' import { @@ -517,25 +517,6 @@ watch(showRelationAndTriage, (visible) => { } }) -// Validation du formulaire principal (gate le bouton « Valider ») : -// - companyName / >= 1 categorie obligatoires ; -// - relation Distributeur/Courtier optionnelle, mais le nom correspondant -// devient requis si l'un des deux est choisi (spec fonctionnelle). -// Les coordonnees de contact ne sont plus saisies ici : elles vivent dans -// l'onglet Contacts (RG-1.05/1.14 garantissent >= 1 contact valide). -const isMainValid = computed(() => { - const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== '' - // Relation Distributeur/Courtier OPTIONNELLE ; mais si « Depend du - // distributeur/courtier » est choisi, le nom correspondant devient requis. - const relationValid - = main.relationType === null - || (main.relationType === 'distributeur' && filled(main.distributorIri)) - || (main.relationType === 'courtier' && filled(main.brokerIri)) - return filled(main.companyName) - && main.categoryIris.length >= 1 - && relationValid -}) - async function onRelationChange(value: string | number | null): Promise { const relation = (value === null || value === '') ? null @@ -551,17 +532,18 @@ async function onRelationChange(value: string | number | null): Promise { /** POST /clients (groupe client:write:main). Au succes : verrouille + bascule Information. */ async function submitMain(): Promise { - if (!isMainValid.value || mainSubmitting.value) return + if (mainSubmitting.value) return mainSubmitting.value = true mainErrors.clearErrors() try { - const payload: Record = { + // companyName omis si vide -> 422 NotBlank au lieu d'un 400 de type (ERP-119). + const payload: Record = omitEmptyRequired({ companyName: main.companyName, categories: main.categoryIris, distributor: main.relationType === 'distributeur' ? main.distributorIri : null, broker: main.relationType === 'courtier' ? main.brokerIri : null, triageService: main.triageService, - } + }, MAIN_REQUIRED_NON_NULLABLE_KEYS) const created = await api.post('/clients', payload, { headers: { Accept: 'application/ld+json' }, toast: false, @@ -661,12 +643,9 @@ 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 || !canValidateInformation.value) return + if (clientId.value === null || tabSubmitting.value) return tabSubmitting.value = true informationErrors.clearErrors() try { @@ -701,9 +680,6 @@ const canAddContact = computed(() => { return last !== undefined && isContactNamed(last) }) -// RG-1.14 : au moins un contact nomme pour finaliser l'onglet. -const canValidateContacts = computed(() => hasAtLeastOneValidContact(contacts.value)) - function addContact(): void { if (canAddContact.value) contacts.value.push(emptyContact()) } @@ -717,9 +693,14 @@ function askRemoveContact(index: number): void { /** POST/PATCH des contacts sur la sous-ressource /clients/{id}/contacts. */ async function submitContacts(): Promise { - if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return + if (clientId.value === null || tabSubmitting.value) return tabSubmitting.value = true try { + // RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des + // amorces neuves vides, on ne les skippe pas -> le bloc vide est POSTe et + // le back renvoie la 422 RG-1.05 « prénom ou nom obligatoire » inline (la + // RG-1.14 n'a pas d'equivalent back au POST, on la materialise via RG-1.05). + const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c)) // On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls // les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli // sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc. @@ -749,10 +730,10 @@ async function submitContacts(): Promise { } }, error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }), - // On ne saute QUE les amorces neuves (id null) totalement vides. Un - // bloc existant vide est soumis -> 422 RG-1.05 inline (sinon la modif - // serait perdue en silence avec un faux toast de succes). - contact => contact.id === null && isContactBlank(contact), + // On ne saute une amorce neuve (id null) totalement vide QUE si un autre + // bloc sera soumis : sinon on la soumet pour declencher la 422 RG-1.05 + // (un onglet Contact vide ne doit pas passer en faux succes). + contact => hasSubmittableContact && contact.id === null && isContactBlank(contact), ) // Tant qu'un bloc reste en erreur : pas de validation d'onglet ni de toast succes. if (hasError) return @@ -789,12 +770,6 @@ const countryOptions: RefOption[] = [ { value: 'Espagne', label: 'Espagne' }, ] -// 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(isAddressValid), -) - // « + Adresse » desactive tant que la derniere adresse n'est pas valide. const canAddAddress = computed(() => { const last = addresses.value[addresses.value.length - 1] @@ -824,7 +799,7 @@ function onAddressDegraded(): void { /** POST des adresses sur la sous-ressource /clients/{id}/addresses. */ async function submitAddresses(): Promise { - if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return + if (clientId.value === null || tabSubmitting.value) return tabSubmitting.value = true try { // On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110). @@ -832,7 +807,8 @@ async function submitAddresses(): Promise { addresses.value, addressErrors, async (address) => { - const body = { + // postalCode / city / street omis si vides -> 422 NotBlank (ERP-119). + const body = omitEmptyRequired({ isProspect: address.isProspect, isDelivery: address.isDelivery, isBilling: address.isBilling, @@ -845,7 +821,7 @@ async function submitAddresses(): Promise { sites: address.siteIris, contacts: address.contactIris, billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null, - } + }, ADDRESS_REQUIRED_NON_NULLABLE_KEYS) if (address.id === null) { const created = await api.post<{ id: number }>( `/clients/${clientId.value}/addresses`, @@ -909,16 +885,6 @@ function onPaymentTypeChange(value: string | number | null): void { } } -// 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(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] @@ -947,7 +913,7 @@ function askRemoveRib(index: number): void { * il n'existe pas d'endpoint /accounting, cf. recon back). */ async function submitAccounting(): Promise { - if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return + if (clientId.value === null || tabSubmitting.value) return tabSubmitting.value = true accountingErrors.clearErrors() try { @@ -959,7 +925,9 @@ async function submitAccounting(): Promise { ribs.value, ribErrors, async (rib) => { - const body = { label: rib.label, bic: rib.bic, iban: rib.iban } + // label / bic / iban omis si vides -> 422 NotBlank au lieu d'un 400 + // de type sur un RIB partiel (ex. IBAN seul). ERP-119. + const body = omitEmptyRequired({ label: rib.label, bic: rib.bic, iban: rib.iban }, RIB_REQUIRED_NON_NULLABLE_KEYS) if (rib.id === null) { const created = await api.post<{ id: number }>( `/clients/${clientId.value}/ribs`, diff --git a/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts b/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts index b0e5ea4..bbe4d8f 100644 --- a/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts +++ b/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts @@ -99,6 +99,21 @@ describe('buildMainPayload — scoping strict groupe client:write:main', () => { expect(payload.distributor).toBeNull() expect(payload.broker).toBeNull() }) + + // ERP-119 : companyName est requis ET adosse a une colonne NON-nullable. Si le + // champ est vide, on OMET la cle (au lieu d'envoyer null) pour que le back + // renvoie une 422 NotBlank (propertyPath companyName) et non un 400 de type. + it('omet companyName quand il est vide (null) -> 422 NotBlank cote back', () => { + expect('companyName' in buildMainPayload(mainDraft({ companyName: null }))).toBe(false) + }) + + it('omet companyName quand il est une chaine vide', () => { + expect('companyName' in buildMainPayload(mainDraft({ companyName: '' }))).toBe(false) + }) + + it('conserve companyName quand il est renseigne', () => { + expect(buildMainPayload(mainDraft({ companyName: 'ACME' })).companyName).toBe('ACME') + }) }) describe('buildInformationPayload — scoping strict groupe client:write:information', () => { @@ -155,6 +170,33 @@ describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => { const rib: RibFormDraft = { id: 1, label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' } expect(buildRibPayload(rib)).toEqual({ label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' }) }) + + // ERP-119 : un RIB partiel (IBAN seul) doit omettre label/bic vides pour + // declencher la 422 NotBlank par champ, pas un 400 de type a la deserialisation. + it('rib partiel : omet label / bic vides, conserve iban', () => { + const rib: RibFormDraft = { id: null, label: null, bic: null, iban: 'FR7612345' } + const payload = buildRibPayload(rib) + expect('label' in payload).toBe(false) + expect('bic' in payload).toBe(false) + expect(payload.iban).toBe('FR7612345') + }) + + // ERP-119 : une adresse partielle omet postalCode/city/street vides (NotBlank). + it('adresse partielle : omet postalCode / city / street vides', () => { + const address: AddressFormDraft = { + id: null, isProspect: false, isDelivery: true, isBilling: false, country: 'France', + postalCode: null, city: '', street: null, streetComplement: null, + categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [], + billingEmail: null, + } + const payload = buildAddressPayload(address, false) + expect('postalCode' in payload).toBe(false) + expect('city' in payload).toBe(false) + expect('street' in payload).toBe(false) + // Les champs non requis / booleens restent presents. + expect(payload.isDelivery).toBe(true) + expect(payload.sites).toEqual(['/api/sites/1']) + }) }) describe('mapMainDraft — pre-remplissage bloc principal', () => { diff --git a/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts b/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts index 139f568..30c677a 100644 --- a/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts +++ b/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts @@ -18,6 +18,7 @@ import { isRibBlank, isRibComplete, isRibRequiredForPaymentType, + omitEmptyRequired, showsRelationAndTriageFields, type AddressValidityDraft, type ContactDraft, @@ -369,3 +370,33 @@ describe('isRibComplete (gating « + RIB » + RG-1.13)', () => { expect(isRibComplete({ label: null, bic: null, iban: null })).toBe(false) }) }) + +describe('omitEmptyRequired (ERP-119 : 422 NotBlank au lieu de 400 de type)', () => { + it('retire les cles requises vides (null / vide / undefined)', () => { + const payload = omitEmptyRequired( + { companyName: null, label: '', iban: undefined, categories: ['/api/categories/1'] }, + ['companyName', 'label', 'iban'], + ) + expect('companyName' in payload).toBe(false) + expect('label' in payload).toBe(false) + expect('iban' in payload).toBe(false) + // Les cles hors liste ne sont jamais touchees. + expect(payload.categories).toEqual(['/api/categories/1']) + }) + + it('conserve les cles requises renseignees', () => { + const payload = omitEmptyRequired({ companyName: 'ACME', bic: 'BNPAFRPP' }, ['companyName', 'bic']) + expect(payload).toEqual({ companyName: 'ACME', bic: 'BNPAFRPP' }) + }) + + it('ne retire jamais une cle hors de la liste requise, meme vide', () => { + const payload = omitEmptyRequired({ streetComplement: null }, ['street']) + expect('streetComplement' in payload).toBe(true) + expect(payload.streetComplement).toBeNull() + }) + + it('false / 0 ne sont pas consideres vides (booleens / nombres preserves)', () => { + const payload = omitEmptyRequired({ isDelivery: false, position: 0 }, ['isDelivery', 'position']) + expect(payload).toEqual({ isDelivery: false, position: 0 }) + }) +}) diff --git a/frontend/modules/commercial/utils/clientEdit.ts b/frontend/modules/commercial/utils/clientEdit.ts index c7551f6..53e3290 100644 --- a/frontend/modules/commercial/utils/clientEdit.ts +++ b/frontend/modules/commercial/utils/clientEdit.ts @@ -21,6 +21,12 @@ import { relationOf, type ClientDetail, } from '~/modules/commercial/utils/clientConsultation' +import { + ADDRESS_REQUIRED_NON_NULLABLE_KEYS, + MAIN_REQUIRED_NON_NULLABLE_KEYS, + omitEmptyRequired, + RIB_REQUIRED_NON_NULLABLE_KEYS, +} from '~/modules/commercial/utils/clientFormRules' import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm' /** @@ -139,13 +145,14 @@ export function mapAccountingFormDraft(client: ClientDetail): AccountingFormDraf * que la FK correspondant au type choisi, l'autre est forcee a null. */ export function buildMainPayload(main: MainFormDraft): Record { - return { + // companyName omis si vide -> 422 NotBlank au lieu d'un 400 de type (ERP-119). + return omitEmptyRequired({ companyName: main.companyName, categories: main.categoryIris, distributor: main.relationType === 'distributeur' ? main.distributorIri : null, broker: main.relationType === 'courtier' ? main.brokerIri : null, triageService: main.triageService, - } + }, MAIN_REQUIRED_NON_NULLABLE_KEYS) } /** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */ @@ -198,7 +205,8 @@ export function buildAddressPayload( address: AddressFormDraft, isBillingEmailRequired: boolean, ): Record { - return { + // postalCode / city / street omis si vides -> 422 NotBlank (ERP-119). + return omitEmptyRequired({ isProspect: address.isProspect, isDelivery: address.isDelivery, isBilling: address.isBilling, @@ -211,16 +219,18 @@ export function buildAddressPayload( sites: address.siteIris, contacts: address.contactIris, billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null, - } + }, ADDRESS_REQUIRED_NON_NULLABLE_KEYS) } /** Payload d'un RIB (sous-ressource client_rib). */ export function buildRibPayload(rib: RibFormDraft): Record { - return { + // label / bic / iban omis si vides -> 422 NotBlank au lieu d'un 400 de type + // sur un RIB partiel (ex. IBAN seul). ERP-119. + return omitEmptyRequired({ label: rib.label, bic: rib.bic, iban: rib.iban, - } + }, RIB_REQUIRED_NON_NULLABLE_KEYS) } // ── Gating par permission ──────────────────────────────────────────────────── diff --git a/frontend/modules/commercial/utils/clientFormRules.ts b/frontend/modules/commercial/utils/clientFormRules.ts index a9ea3b5..9ab1bb8 100644 --- a/frontend/modules/commercial/utils/clientFormRules.ts +++ b/frontend/modules/commercial/utils/clientFormRules.ts @@ -358,3 +358,38 @@ export function hasAllRequiredAccountingFields(accounting: AccountingRequiredDra && filled(accounting.paymentDelayIri) && filled(accounting.paymentTypeIri) } + +// ── Champs requis adosses a une colonne NON-nullable (ERP-119) ─────────────── +// Ces champs requis (NotBlank back) sont portes par une colonne Doctrine NON +// nullable. Si le front envoie `null` (champ vide, desormais possible : le bouton +// « Valider » n'est plus desactive), API Platform rejette la valeur en 400 de TYPE +// a la deserialisation (« The type of the X attribute must be string, NULL given ») +// AVANT le Validator -> pas de violation, donc pas d'erreur rouge cote champ. +// La parade : OMETTRE la cle du payload quand elle est vide. Sans la cle, la +// propriete garde son defaut null cote entite et #[Assert\NotBlank] se declenche +// normalement -> 422 avec propertyPath, mappee en rouge sous le champ. +// (Les champs requis a colonne NULLABLE — contacts, scalaires compta — acceptent +// deja `null` et renvoient une 422 : inutile de les omettre.) +export const MAIN_REQUIRED_NON_NULLABLE_KEYS = ['companyName'] as const +export const ADDRESS_REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const +export const RIB_REQUIRED_NON_NULLABLE_KEYS = ['label', 'bic', 'iban'] as const + +/** + * Retire d'un payload d'ecriture les cles requises laissees vides (null / '' + * / undefined), pour laisser le back produire une 422 NotBlank par champ plutot + * qu'un 400 de type sur une colonne non-nullable. Mute et retourne le payload. + * A n'appliquer QU'aux cles ci-dessus (champs requis a colonne non-nullable). + */ +export function omitEmptyRequired>( + payload: T, + requiredKeys: readonly string[], +): T { + for (const key of requiredKeys) { + const value = payload[key] + if (value === null || value === undefined || value === '') { + delete payload[key] + } + } + + return payload +} diff --git a/src/Module/Commercial/Domain/Entity/ClientAddress.php b/src/Module/Commercial/Domain/Entity/ClientAddress.php index 99a998f..ab77987 100644 --- a/src/Module/Commercial/Domain/Entity/ClientAddress.php +++ b/src/Module/Commercial/Domain/Entity/ClientAddress.php @@ -223,6 +223,23 @@ class ClientAddress implements TimestampableInterface, BlamableInterface } } + /** + * Au moins un type d'adresse est obligatoire (Prospection, Livraison ou + * Facturation) : une adresse sans aucun drapeau pose n'a pas de sens metier. + * La violation est portee sur `isProspect` (meme champ que l'exclusivite) pour + * un mapping inline sous le select « Type d'adresse » cote front (ERP-119). + */ + #[Assert\Callback] + public function validateAddressTypeRequired(ExecutionContextInterface $context): void + { + if (!$this->isProspect && !$this->isDelivery && !$this->isBilling) { + $context->buildViolation('Le type d\'adresse est obligatoire.') + ->atPath('isProspect') + ->addViolation() + ; + } + } + /** * RG-1.11 : l'email de facturation est obligatoire si l'adresse est de * facturation, et interdit sinon. Mirror applicatif (422) du CHECK diff --git a/tests/Module/Commercial/Api/ClientAddressTest.php b/tests/Module/Commercial/Api/ClientAddressTest.php index 93eb41c..41a1b21 100644 --- a/tests/Module/Commercial/Api/ClientAddressTest.php +++ b/tests/Module/Commercial/Api/ClientAddressTest.php @@ -146,6 +146,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ + 'isDelivery' => true, 'isBilling' => false, 'billingEmail' => 'parasite@test.fr', 'postalCode' => '86100', @@ -174,6 +175,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ + 'isDelivery' => true, 'isBilling' => false, 'billingEmail' => '', 'postalCode' => '86100', @@ -201,6 +203,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ + 'isDelivery' => true, 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', @@ -229,6 +232,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ + 'isDelivery' => true, 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', @@ -253,6 +257,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ + 'isDelivery' => true, 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', @@ -277,6 +282,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ + 'isDelivery' => true, 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', @@ -301,6 +307,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ + 'isDelivery' => true, 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', @@ -311,6 +318,39 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase self::assertResponseStatusCodeSame(422); } + /** + * RG (ERP-119) : au moins un type d'adresse (Prospection / Livraison / + * Facturation) est obligatoire. POST sans aucun drapeau de type -> 422, avec + * une violation portee sur `isProspect` (mappee sous le select « Type + * d'adresse » cote front via ClientAddressBlock). + */ + public function testAddressRequiresAtLeastOneType(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Address No Type'); + $category = $this->createCategory('SECTEUR'); + + $body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => [ + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$this->firstSiteIri()], + 'categories' => ['/api/categories/'.$category->getId()], + ], + ])->toArray(false); + + self::assertResponseStatusCodeSame(422); + $byPath = []; + foreach ($body['violations'] ?? [] as $v) { + $byPath[$v['propertyPath']] = $v['message']; + } + self::assertArrayHasKey('isProspect', $byPath); + self::assertSame('Le type d\'adresse est obligatoire.', $byPath['isProspect']); + } + /** * Retourne l'IRI du premier site seede (fixtures Sites). */ diff --git a/tests/Module/Commercial/Api/ClientSubResourceApiTest.php b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php index 5b4ecca..a7d4182 100644 --- a/tests/Module/Commercial/Api/ClientSubResourceApiTest.php +++ b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php @@ -237,6 +237,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ + 'isDelivery' => true, 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', @@ -258,6 +259,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ + 'isDelivery' => true, 'postalCode' => '123', 'city' => 'Châtellerault', 'street' => '1 rue du Test', @@ -287,6 +289,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'json' => [ + 'isDelivery' => true, 'postalCode' => '75001', 'city' => 'Paris', 'street' => '2 rue Neuve', @@ -313,6 +316,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase $client->request('POST', '/api/clients/999999/addresses', [ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'json' => [ + 'isDelivery' => true, 'postalCode' => '75001', 'city' => 'Paris', 'street' => '2 rue Neuve',