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).
This commit is contained in:
@@ -689,7 +689,7 @@ async function submitMain(): Promise<void> {
|
|||||||
mainSubmitting.value = true
|
mainSubmitting.value = true
|
||||||
mainErrors.clearErrors()
|
mainErrors.clearErrors()
|
||||||
try {
|
try {
|
||||||
const updated = await api.patch<ClientDetail>(`/clients/${clientId}`, buildMainPayload(main), {
|
const updated = await api.patch<ClientDetail>(`/clients/${clientId}`, buildMainPayload(main, { forUpdate: true }), {
|
||||||
headers: { Accept: 'application/ld+json' },
|
headers: { Accept: 'application/ld+json' },
|
||||||
toast: false,
|
toast: false,
|
||||||
})
|
})
|
||||||
@@ -859,7 +859,10 @@ async function submitAddresses(): Promise<void> {
|
|||||||
addresses.value,
|
addresses.value,
|
||||||
addressErrors,
|
addressErrors,
|
||||||
async (address) => {
|
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) {
|
if (address.id === null) {
|
||||||
const created = await api.post<{ id: number }>(
|
const created = await api.post<{ id: number }>(
|
||||||
`/clients/${clientId}/addresses`,
|
`/clients/${clientId}/addresses`,
|
||||||
@@ -956,7 +959,9 @@ async function submitAccounting(): Promise<void> {
|
|||||||
ribs.value,
|
ribs.value,
|
||||||
ribErrors,
|
ribErrors,
|
||||||
async (rib) => {
|
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) {
|
if (rib.id === null) {
|
||||||
const created = await api.post<{ id: number }>(
|
const created = await api.post<{ id: number }>(
|
||||||
`/clients/${clientId}/ribs`,
|
`/clients/${clientId}/ribs`,
|
||||||
|
|||||||
@@ -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', () => {
|
describe('mapMainDraft — pre-remplissage bloc principal', () => {
|
||||||
it('resout la relation et extrait les IRI (sans contact inline)', () => {
|
it('resout la relation et extrait les IRI (sans contact inline)', () => {
|
||||||
const client = {
|
const client = {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from '~/modules/commercial/utils/clientConsultation'
|
} from '~/modules/commercial/utils/clientConsultation'
|
||||||
import {
|
import {
|
||||||
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
|
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
|
||||||
|
blankEmptyRequired,
|
||||||
MAIN_REQUIRED_NON_NULLABLE_KEYS,
|
MAIN_REQUIRED_NON_NULLABLE_KEYS,
|
||||||
omitEmptyRequired,
|
omitEmptyRequired,
|
||||||
RIB_REQUIRED_NON_NULLABLE_KEYS,
|
RIB_REQUIRED_NON_NULLABLE_KEYS,
|
||||||
@@ -139,12 +140,35 @@ export function mapAccountingFormDraft(client: ClientDetail): AccountingFormDraf
|
|||||||
|
|
||||||
// ── Scoping strict des payloads PATCH ────────────────────────────────────────
|
// ── 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<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 client:write:main UNIQUEMENT. La relation
|
* Payload du bloc principal — groupe client:write:main UNIQUEMENT. La relation
|
||||||
* Distributeur/Courtier est mutuellement exclusive (RG-1.03) : on ne renseigne
|
* Distributeur/Courtier est mutuellement exclusive (RG-1.03) : on ne renseigne
|
||||||
* que la FK correspondant au type choisi, l'autre est forcee a null.
|
* que la FK correspondant au type choisi, l'autre est forcee a null.
|
||||||
*/
|
*/
|
||||||
export function buildMainPayload(main: MainFormDraft): Record<string, unknown> {
|
export function buildMainPayload(main: MainFormDraft, options: BuildPayloadOptions = {}): Record<string, unknown> {
|
||||||
// companyName omis si vide -> 422 NotBlank au lieu d'un 400 de type (ERP-119).
|
// companyName omis si vide -> 422 NotBlank au lieu d'un 400 de type (ERP-119).
|
||||||
// relationType : champ transitoire (non persiste cote back) qui porte
|
// relationType : champ transitoire (non persiste cote back) qui porte
|
||||||
// l'intention UI « ce client depend d'un distributeur / courtier ». Il sert
|
// l'intention UI « ce client depend d'un distributeur / courtier ». Il sert
|
||||||
@@ -152,14 +176,14 @@ export function buildMainPayload(main: MainFormDraft): Record<string, unknown> {
|
|||||||
// la FK correspondante devient obligatoire -> 422 sur distributor / broker.
|
// la FK correspondante devient obligatoire -> 422 sur distributor / broker.
|
||||||
// Sans equivalent derivable cote back (FK nullable), c'est la seule facon de
|
// 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.
|
// rester sur « on soumet, le back tranche » plutot qu'une garde front-only.
|
||||||
return omitEmptyRequired({
|
return finalizeRequired({
|
||||||
companyName: main.companyName,
|
companyName: main.companyName,
|
||||||
categories: main.categoryIris,
|
categories: main.categoryIris,
|
||||||
relationType: main.relationType,
|
relationType: main.relationType,
|
||||||
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
|
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
|
||||||
broker: main.relationType === 'courtier' ? main.brokerIri : null,
|
broker: main.relationType === 'courtier' ? main.brokerIri : null,
|
||||||
triageService: main.triageService,
|
triageService: main.triageService,
|
||||||
}, MAIN_REQUIRED_NON_NULLABLE_KEYS)
|
}, MAIN_REQUIRED_NON_NULLABLE_KEYS, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */
|
/** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */
|
||||||
@@ -211,9 +235,10 @@ export function buildContactPayload(contact: ContactFormDraft): Record<string, u
|
|||||||
export function buildAddressPayload(
|
export function buildAddressPayload(
|
||||||
address: AddressFormDraft,
|
address: AddressFormDraft,
|
||||||
isBillingEmailRequired: boolean,
|
isBillingEmailRequired: boolean,
|
||||||
|
options: BuildPayloadOptions = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
// postalCode / city / street omis si vides -> 422 NotBlank (ERP-119).
|
// postalCode / city / street : omis a la creation, `''` en edition -> 422 NotBlank (ERP-119).
|
||||||
return omitEmptyRequired({
|
return finalizeRequired({
|
||||||
isProspect: address.isProspect,
|
isProspect: address.isProspect,
|
||||||
isDelivery: address.isDelivery,
|
isDelivery: address.isDelivery,
|
||||||
isBilling: address.isBilling,
|
isBilling: address.isBilling,
|
||||||
@@ -229,18 +254,18 @@ export function buildAddressPayload(
|
|||||||
contacts: address.contactIris,
|
contacts: address.contactIris,
|
||||||
billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null,
|
billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null,
|
||||||
billingEmailSecondary: isBillingEmailRequired && address.hasSecondaryBillingEmail ? (address.billingEmailSecondary || 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). */
|
/** Payload d'un RIB (sous-ressource client_rib). */
|
||||||
export function buildRibPayload(rib: RibFormDraft): Record<string, unknown> {
|
export function buildRibPayload(rib: RibFormDraft, options: BuildPayloadOptions = {}): Record<string, unknown> {
|
||||||
// label / bic / iban omis si vides -> 422 NotBlank au lieu d'un 400 de type
|
// label / bic / iban : omis a la creation, `''` en edition -> 422 NotBlank au lieu
|
||||||
// sur un RIB partiel (ex. IBAN seul). ERP-119.
|
// d'un 400 de type (ou d'un faux 200 PATCH qui garderait l'ancienne valeur). ERP-119.
|
||||||
return omitEmptyRequired({
|
return finalizeRequired({
|
||||||
label: rib.label,
|
label: rib.label,
|
||||||
bic: rib.bic,
|
bic: rib.bic,
|
||||||
iban: rib.iban,
|
iban: rib.iban,
|
||||||
}, RIB_REQUIRED_NON_NULLABLE_KEYS)
|
}, RIB_REQUIRED_NON_NULLABLE_KEYS, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Gating par permission ────────────────────────────────────────────────────
|
// ── Gating par permission ────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -419,3 +419,28 @@ export function omitEmptyRequired<T extends Record<string, unknown>>(
|
|||||||
|
|
||||||
return payload
|
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<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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user