feat(front) : page Modification fournisseur (/suppliers/{id}/edit) (ERP-96) #85

Merged
tristan merged 13 commits from feature/ERP-96-suppliers-edit into develop 2026-06-11 07:26:33 +00:00
4 changed files with 101 additions and 14 deletions
Showing only changes of commit 1f22fd142a - Show all commits
@@ -689,7 +689,7 @@ async function submitMain(): Promise<void> {
mainSubmitting.value = true
mainErrors.clearErrors()
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' },
toast: false,
})
@@ -859,7 +859,10 @@ async function submitAddresses(): Promise<void> {
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<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 }>(
`/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', () => {
it('resout la relation et extrait les IRI (sans contact inline)', () => {
const client = {
+36 -11
View File
@@ -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<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
* 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<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).
// 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<string, unknown> {
// 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<string, u
export function buildAddressPayload(
address: AddressFormDraft,
isBillingEmailRequired: boolean,
options: BuildPayloadOptions = {},
): Record<string, unknown> {
// 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<string, unknown> {
// 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<string, unknown> {
// 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 ────────────────────────────────────────────────────
@@ -419,3 +419,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
* `''` (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
}