diff --git a/frontend/modules/commercial/pages/suppliers/[id]/edit.vue b/frontend/modules/commercial/pages/suppliers/[id]/edit.vue index 6720480..0e117e0 100644 --- a/frontend/modules/commercial/pages/suppliers/[id]/edit.vue +++ b/frontend/modules/commercial/pages/suppliers/[id]/edit.vue @@ -590,7 +590,7 @@ async function submitMain(): Promise { mainSubmitting.value = true mainErrors.clearErrors() try { - const updated = await api.patch(`/suppliers/${supplierId}`, buildMainPayload(main), { + const updated = await api.patch(`/suppliers/${supplierId}`, buildMainPayload(main, { forUpdate: true }), { headers: { Accept: 'application/ld+json' }, toast: false, }) @@ -750,7 +750,10 @@ async function submitAddresses(): Promise { addresses.value, addressErrors, async (address) => { - const body = buildAddressPayload(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, { forUpdate: address.id !== null }) if (address.id === null) { const created = await api.post<{ id: number }>( `/suppliers/${supplierId}/addresses`, @@ -843,7 +846,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 }>( `/suppliers/${supplierId}/ribs`, diff --git a/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts b/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts index 581cc24..8d9f3d6 100644 --- a/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts +++ b/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts @@ -22,11 +22,17 @@ describe('buildMainPayload (groupe supplier:write:main)', () => { }) }) - it('omet companyName vide (-> 422 NotBlank, ERP-119)', () => { + it('CREATION : omet companyName vide (-> 422 NotBlank, ERP-119)', () => { const payload = buildMainPayload({ companyName: null, categoryIris: [] }) expect('companyName' in payload).toBe(false) expect(payload.categories).toEqual([]) }) + + it('EDITION (forUpdate) : companyName vide envoye en `\'\'` (PATCH -> 422 NotBlank, pas un faux 200)', () => { + const payload = buildMainPayload({ companyName: '', categoryIris: [] }, { forUpdate: true }) + expect('companyName' in payload).toBe(true) + expect(payload.companyName).toBe('') + }) }) describe('buildInformationPayload (groupe supplier:write:information)', () => { @@ -91,6 +97,16 @@ describe('buildAddressPayload (sous-ressource supplier_address — specificites expect('addressType' in payload).toBe(false) }) + it('EDITION (forUpdate) : un champ requis vide est envoye en `\'\'` (et NON omis) pour declencher la 422 NotBlank au PATCH', () => { + // Bug edition : omettre la cle d'un champ requis vide laisse le PATCH garder + // l'ancienne valeur (faux 200). En `forUpdate`, on envoie `''` -> NotBlank 422. + const payload = buildAddressPayload({ ...emptyAddress(), addressType: 'DEPART', postalCode: '' }, { forUpdate: true }) + expect('postalCode' in payload).toBe(true) + expect(payload.postalCode).toBe('') + // Un champ requis renseigne reste tel quel. + expect(payload.addressType).toBe('DEPART') + }) + it('n\'expose jamais d\'email de facturation (difference M1)', () => { const payload = buildAddressPayload({ ...emptyAddress(), addressType: 'DEPART' }) expect('billingEmail' in payload).toBe(false) diff --git a/frontend/modules/commercial/utils/supplierEdit.ts b/frontend/modules/commercial/utils/supplierEdit.ts index 55ac9d7..2f8a5e5 100644 --- a/frontend/modules/commercial/utils/supplierEdit.ts +++ b/frontend/modules/commercial/utils/supplierEdit.ts @@ -13,6 +13,7 @@ import { ADDRESS_REQUIRED_NON_NULLABLE_KEYS, + blankEmptyRequired, MAIN_REQUIRED_NON_NULLABLE_KEYS, omitEmptyRequired, RIB_REQUIRED_NON_NULLABLE_KEYS, @@ -138,14 +139,37 @@ export function resolveTabEditability(abilities: SupplierEditAbilities): TabEdit // ── Scoping strict des payloads PATCH/POST ────────────────────────────────── /** - * Payload du bloc principal — groupe supplier:write:main UNIQUEMENT. - * companyName omis si vide -> 422 NotBlank au lieu d'un 400 de type (ERP-119). + * Options de construction d'un payload d'ecriture. + * - `forUpdate: false` (defaut, CREATION/POST) : les champs requis vides sont OMIS + * -> 422 NotBlank a l'insert (le back ne reçoit pas la cle). + * - `forUpdate: true` (EDITION/PATCH d'une ligne existante) : les champs requis + * vides sont envoyes en `''` -> 422 NotBlank (sinon une cle omise laisse la valeur + * serveur inchangee, faux 200 — cf. blankEmptyRequired). */ -export function buildMainPayload(main: MainFormDraft): Record { - return omitEmptyRequired({ +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 supplier:write:main UNIQUEMENT. + * companyName vide -> 422 NotBlank (omis a la creation, `''` en edition — ERP-119). + */ +export function buildMainPayload(main: MainFormDraft, options: BuildPayloadOptions = {}): Record { + return finalizeRequired({ companyName: main.companyName, categories: main.categoryIris, - }, MAIN_REQUIRED_NON_NULLABLE_KEYS) + }, MAIN_REQUIRED_NON_NULLABLE_KEYS, options) } /** Payload de l'onglet Information — groupe supplier:write:information UNIQUEMENT. */ @@ -200,8 +224,8 @@ export function buildContactPayload(contact: SupplierContactFormDraft): Record { - return omitEmptyRequired({ +export function buildAddressPayload(address: SupplierAddressFormDraft, options: BuildPayloadOptions = {}): Record { + return finalizeRequired({ addressType: address.addressType, country: address.country, postalCode: address.postalCode || null, @@ -213,14 +237,14 @@ export function buildAddressPayload(address: SupplierAddressFormDraft): Record { - return omitEmptyRequired({ +export function buildRibPayload(rib: SupplierRibFormDraft, options: BuildPayloadOptions = {}): Record { + return finalizeRequired({ label: rib.label, bic: rib.bic, iban: rib.iban, - }, RIB_REQUIRED_NON_NULLABLE_KEYS) + }, RIB_REQUIRED_NON_NULLABLE_KEYS, options) } diff --git a/frontend/modules/commercial/utils/supplierFormRules.ts b/frontend/modules/commercial/utils/supplierFormRules.ts index a2cbc75..5f6a505 100644 --- a/frontend/modules/commercial/utils/supplierFormRules.ts +++ b/frontend/modules/commercial/utils/supplierFormRules.ts @@ -217,3 +217,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 + * `''`, la propriete `?string` est bien deserialisee (pas de 400 de type, contrairement + * a `null` sur une colonne non-nullable), puis le Validator `NotBlank(trim)` la rejette + * -> 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 +}