From 1f22fd142a6a7ffcb3a42fbb354bb1e50572d3aa Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 11 Jun 2026 08:36:54 +0200 Subject: [PATCH] fix(front) : meme correctif champ requis vide en edition client (M1) Port du fix fournisseur (blankEmptyRequired + flag forUpdate) a l'edition client : en PATCH d'une ligne existante, un champ requis vide (companyName / postalCode / city / street / label / bic / iban) est envoye en '' au lieu d'etre omis, sinon le merge-patch garde l'ancienne valeur (faux 200). Creation (POST) inchangee (omit). --- .../commercial/pages/clients/[id]/edit.vue | 11 +++-- .../utils/__tests__/clientEdit.spec.ts | 32 +++++++++++++ .../modules/commercial/utils/clientEdit.ts | 47 ++++++++++++++----- .../commercial/utils/clientFormRules.ts | 25 ++++++++++ 4 files changed, 101 insertions(+), 14 deletions(-) diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue index dc043ce..f91f12e 100644 --- a/frontend/modules/commercial/pages/clients/[id]/edit.vue +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -689,7 +689,7 @@ async function submitMain(): Promise { mainSubmitting.value = true mainErrors.clearErrors() try { - const updated = await api.patch(`/clients/${clientId}`, buildMainPayload(main), { + const updated = await api.patch(`/clients/${clientId}`, buildMainPayload(main, { forUpdate: true }), { headers: { Accept: 'application/ld+json' }, toast: false, }) @@ -859,7 +859,10 @@ async function submitAddresses(): Promise { addresses.value, addressErrors, async (address) => { - const body = buildAddressPayload(address, isBillingEmailRequired(address)) + // Edition d'une adresse existante : champ requis vide envoye en `''` + // (NotBlank 422) au lieu d'etre omis — sinon le PATCH garderait + // l'ancienne valeur (faux 200). Creation (id null) : omit classique. + const body = buildAddressPayload(address, isBillingEmailRequired(address), { forUpdate: address.id !== null }) if (address.id === null) { const created = await api.post<{ id: number }>( `/clients/${clientId}/addresses`, @@ -956,7 +959,9 @@ async function submitAccounting(): Promise { ribs.value, ribErrors, async (rib) => { - const body = buildRibPayload(rib) + // Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank + // 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur). + const body = buildRibPayload(rib, { forUpdate: rib.id !== null }) if (rib.id === null) { const created = await api.post<{ id: number }>( `/clients/${clientId}/ribs`, diff --git a/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts b/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts index 60bf4ac..9a6d244 100644 --- a/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts +++ b/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts @@ -211,6 +211,38 @@ describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => { }) }) +// Bug edition : en PATCH (merge), une cle de champ requis OMISE laisse la valeur +// serveur inchangee -> faux 200 quand l'utilisateur vide le champ. En `forUpdate`, +// on envoie `''` (chaine valide, pas de 400 de type) -> NotBlank 422 inline. +describe('forUpdate (EDITION/PATCH) : champ requis vide -> `\'\'` au lieu d\'etre omis', () => { + it('buildMainPayload : companyName vide envoye en `\'\'`', () => { + const payload = buildMainPayload(mainDraft({ companyName: '' }), { forUpdate: true }) + expect('companyName' in payload).toBe(true) + expect(payload.companyName).toBe('') + }) + + it('buildAddressPayload : postalCode / city / street vides envoyes en `\'\'`', () => { + const address: AddressFormDraft = { + id: 7, isProspect: false, isDelivery: true, isBilling: false, isBroker: false, isDistributor: false, country: 'France', + postalCode: '', city: null, street: '1 rue X', streetComplement: null, + categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [], + billingEmail: null, billingEmailSecondary: null, hasSecondaryBillingEmail: false, + } + const payload = buildAddressPayload(address, false, { forUpdate: true }) + expect(payload.postalCode).toBe('') + expect(payload.city).toBe('') + // Un champ requis renseigne reste tel quel. + expect(payload.street).toBe('1 rue X') + }) + + it('buildRibPayload : label / bic vides envoyes en `\'\'`, iban conserve', () => { + const payload = buildRibPayload({ id: 4, label: '', bic: null, iban: 'FR7612345' }, { forUpdate: true }) + expect(payload.label).toBe('') + expect(payload.bic).toBe('') + expect(payload.iban).toBe('FR7612345') + }) +}) + describe('mapMainDraft — pre-remplissage bloc principal', () => { it('resout la relation et extrait les IRI (sans contact inline)', () => { const client = { diff --git a/frontend/modules/commercial/utils/clientEdit.ts b/frontend/modules/commercial/utils/clientEdit.ts index 080f112..a0d4dff 100644 --- a/frontend/modules/commercial/utils/clientEdit.ts +++ b/frontend/modules/commercial/utils/clientEdit.ts @@ -23,6 +23,7 @@ import { } from '~/modules/commercial/utils/clientConsultation' import { ADDRESS_REQUIRED_NON_NULLABLE_KEYS, + blankEmptyRequired, MAIN_REQUIRED_NON_NULLABLE_KEYS, omitEmptyRequired, RIB_REQUIRED_NON_NULLABLE_KEYS, @@ -139,12 +140,35 @@ export function mapAccountingFormDraft(client: ClientDetail): AccountingFormDraf // ── Scoping strict des payloads PATCH ──────────────────────────────────────── +/** + * Options de construction d'un payload d'ecriture. + * - `forUpdate: false` (defaut, CREATION/POST) : champs requis vides OMIS -> 422 + * NotBlank (le back ne reçoit pas la cle, la propriete garde son defaut). + * - `forUpdate: true` (EDITION/PATCH d'une ligne existante) : champs requis vides + * envoyes en `''` -> 422 NotBlank (sinon une cle omise laisse la valeur serveur + * inchangee, faux 200 — cf. blankEmptyRequired). + */ +export interface BuildPayloadOptions { + forUpdate?: boolean +} + +/** Selectionne le finaliseur des champs requis selon création (omit) vs édition (blank). */ +function finalizeRequired>( + payload: T, + requiredKeys: readonly string[], + options: BuildPayloadOptions, +): T { + return options.forUpdate + ? blankEmptyRequired(payload, requiredKeys) + : omitEmptyRequired(payload, requiredKeys) +} + /** * Payload du bloc principal — groupe client:write:main UNIQUEMENT. La relation * Distributeur/Courtier est mutuellement exclusive (RG-1.03) : on ne renseigne * que la FK correspondant au type choisi, l'autre est forcee a null. */ -export function buildMainPayload(main: MainFormDraft): Record { +export function buildMainPayload(main: MainFormDraft, options: BuildPayloadOptions = {}): Record { // companyName omis si vide -> 422 NotBlank au lieu d'un 400 de type (ERP-119). // relationType : champ transitoire (non persiste cote back) qui porte // l'intention UI « ce client depend d'un distributeur / courtier ». Il sert @@ -152,14 +176,14 @@ export function buildMainPayload(main: MainFormDraft): Record { // la FK correspondante devient obligatoire -> 422 sur distributor / broker. // Sans equivalent derivable cote back (FK nullable), c'est la seule facon de // rester sur « on soumet, le back tranche » plutot qu'une garde front-only. - return omitEmptyRequired({ + return finalizeRequired({ companyName: main.companyName, categories: main.categoryIris, relationType: main.relationType, distributor: main.relationType === 'distributeur' ? main.distributorIri : null, broker: main.relationType === 'courtier' ? main.brokerIri : null, triageService: main.triageService, - }, MAIN_REQUIRED_NON_NULLABLE_KEYS) + }, MAIN_REQUIRED_NON_NULLABLE_KEYS, options) } /** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */ @@ -211,9 +235,10 @@ export function buildContactPayload(contact: ContactFormDraft): Record { - // postalCode / city / street omis si vides -> 422 NotBlank (ERP-119). - return omitEmptyRequired({ + // postalCode / city / street : omis a la creation, `''` en edition -> 422 NotBlank (ERP-119). + return finalizeRequired({ isProspect: address.isProspect, isDelivery: address.isDelivery, isBilling: address.isBilling, @@ -229,18 +254,18 @@ export function buildAddressPayload( contacts: address.contactIris, billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null, billingEmailSecondary: isBillingEmailRequired && address.hasSecondaryBillingEmail ? (address.billingEmailSecondary || null) : null, - }, ADDRESS_REQUIRED_NON_NULLABLE_KEYS) + }, ADDRESS_REQUIRED_NON_NULLABLE_KEYS, options) } /** Payload d'un RIB (sous-ressource client_rib). */ -export function buildRibPayload(rib: RibFormDraft): Record { - // 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({ +export function buildRibPayload(rib: RibFormDraft, options: BuildPayloadOptions = {}): Record { + // label / bic / iban : omis a la creation, `''` en edition -> 422 NotBlank au lieu + // d'un 400 de type (ou d'un faux 200 PATCH qui garderait l'ancienne valeur). ERP-119. + return finalizeRequired({ label: rib.label, bic: rib.bic, iban: rib.iban, - }, RIB_REQUIRED_NON_NULLABLE_KEYS) + }, RIB_REQUIRED_NON_NULLABLE_KEYS, options) } // ── Gating par permission ──────────────────────────────────────────────────── diff --git a/frontend/modules/commercial/utils/clientFormRules.ts b/frontend/modules/commercial/utils/clientFormRules.ts index 2a26c9f..6db346a 100644 --- a/frontend/modules/commercial/utils/clientFormRules.ts +++ b/frontend/modules/commercial/utils/clientFormRules.ts @@ -419,3 +419,28 @@ export function omitEmptyRequired>( return payload } + +/** + * Variante PATCH (edition d'une ligne EXISTANTE) : remplace les cles requises + * laissees vides par une chaine vide `''` au lieu de les OMETTRE. + * + * Pourquoi pas `omitEmptyRequired` en edition : un PATCH a semantique merge — une + * cle absente laisse la valeur serveur INCHANGEE. Vider un champ requis puis valider + * renverrait alors un 200 trompeur (l'ancienne valeur est conservee). En envoyant + * `''` (chaine valide), on evite le 400 de type (« must be string, NULL given ») et + * le Validator `NotBlank(trim)` rejette la valeur -> 422 avec propertyPath, mappee + * inline sous le champ. Mute et retourne le payload. + */ +export function blankEmptyRequired>( + payload: T, + requiredKeys: readonly string[], +): T { + for (const key of requiredKeys) { + const value = payload[key] + if (value === null || value === undefined || value === '') { + (payload as Record)[key] = '' + } + } + + return payload +}