fix(front) : 422 sur champ requis vide en edition fournisseur (ERP-96)

En edition (PATCH merge), omettre la cle d'un champ requis vide laissait la valeur
serveur inchangee -> faux 200 (l'ancien code postal etait conserve). Nouveau helper
blankEmptyRequired + flag forUpdate sur les builders : a la creation (POST) on omet
toujours la cle (NotBlank), en edition d'une ligne existante on envoie '' (chaine
valide, pas de 400 de type) pour declencher NotBlank 422 inline sous le champ.
Applique au bloc principal, aux adresses et aux RIB (selon id !== null).
This commit is contained in:
2026-06-11 08:36:47 +02:00
parent 18fdf9354f
commit 8eff37186d
4 changed files with 85 additions and 15 deletions
@@ -590,7 +590,7 @@ async function submitMain(): Promise<void> {
mainSubmitting.value = true
mainErrors.clearErrors()
try {
const updated = await api.patch<SupplierDetail>(`/suppliers/${supplierId}`, buildMainPayload(main), {
const updated = await api.patch<SupplierDetail>(`/suppliers/${supplierId}`, buildMainPayload(main, { forUpdate: true }), {
headers: { Accept: 'application/ld+json' },
toast: false,
})
@@ -750,7 +750,10 @@ async function submitAddresses(): Promise<void> {
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<void> {
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`,
@@ -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)
@@ -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<string, unknown> {
return omitEmptyRequired({
export interface BuildPayloadOptions {
forUpdate?: boolean
}
/** Selectionne le finaliseur des champs requis selon création (omit) vs édition (blank). */
function finalizeRequired<T extends Record<string, unknown>>(
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<string, unknown> {
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<s
* `bennes` (entier, 0 par defaut) + `triageProvider` (booleen). Pas d'email de
* facturation (difference M1).
*/
export function buildAddressPayload(address: SupplierAddressFormDraft): Record<string, unknown> {
return omitEmptyRequired({
export function buildAddressPayload(address: SupplierAddressFormDraft, options: BuildPayloadOptions = {}): Record<string, unknown> {
return finalizeRequired({
addressType: address.addressType,
country: address.country,
postalCode: address.postalCode || null,
@@ -213,14 +237,14 @@ export function buildAddressPayload(address: SupplierAddressFormDraft): Record<s
contacts: address.contactIris,
bennes: address.bennes !== null && address.bennes !== '' ? Number(address.bennes) : null,
triageProvider: address.triageProvider,
}, ADDRESS_REQUIRED_NON_NULLABLE_KEYS)
}, ADDRESS_REQUIRED_NON_NULLABLE_KEYS, options)
}
/** Payload d'un RIB (sous-ressource supplier_rib). */
export function buildRibPayload(rib: SupplierRibFormDraft): Record<string, unknown> {
return omitEmptyRequired({
export function buildRibPayload(rib: SupplierRibFormDraft, options: BuildPayloadOptions = {}): Record<string, unknown> {
return finalizeRequired({
label: rib.label,
bic: rib.bic,
iban: rib.iban,
}, RIB_REQUIRED_NON_NULLABLE_KEYS)
}, RIB_REQUIRED_NON_NULLABLE_KEYS, options)
}
@@ -217,3 +217,28 @@ export function omitEmptyRequired<T extends Record<string, unknown>>(
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<T extends Record<string, unknown>>(
payload: T,
requiredKeys: readonly string[],
): T {
for (const key of requiredKeys) {
const value = payload[key]
if (value === null || value === undefined || value === '') {
(payload as Record<string, unknown>)[key] = ''
}
}
return payload
}