diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue index dc043ce..74f22d7 100644 --- a/frontend/modules/commercial/pages/clients/[id]/edit.vue +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -303,7 +303,7 @@ class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" > { 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`, @@ -950,13 +953,18 @@ async function submitAccounting(): Promise { try { // 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs // tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2. - // Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex. - // IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline. + // On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable : + // sinon (ex. l'unique RIB existant supprime, remplace par un bloc vide), on la + // soumet pour declencher la 422 NotBlank inline plutot que de laisser le DELETE + // echouer en « dernier RIB d'une LCR » (message plat sans propertyPath). + const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r)) const ribHasError = await submitRows( 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`, @@ -970,10 +978,10 @@ async function submitAccounting(): Promise { } }, error => showError(error), - // On ne saute QUE les amorces neuves (id null) totalement vides. Un - // RIB existant vide est soumis -> 422 NotBlank inline (sinon la modif - // serait perdue en silence avec un faux toast de succes). - rib => rib.id === null && isRibBlank(rib), + // On ne saute une amorce neuve (id null) totalement vide que si un autre RIB + // est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank + // inline (sinon la modif serait perdue en silence avec un faux toast succes). + rib => hasSubmittableRib && rib.id === null && isRibBlank(rib), ) if (ribHasError) return diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue index 56d7297..67be80f 100644 --- a/frontend/modules/commercial/pages/clients/new.vue +++ b/frontend/modules/commercial/pages/clients/new.vue @@ -302,7 +302,7 @@ > { try { // 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs // tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2. - // Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex. - // IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline. + // On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable : + // sinon (LCR sans aucun RIB rempli) on la soumet -> 422 NotBlank inline. + const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r)) const ribHasError = await submitRows( ribs.value, ribErrors, @@ -941,10 +942,10 @@ async function submitAccounting(): Promise { } }, error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }), - // On ne saute QUE les amorces neuves (id null) totalement vides. Un - // RIB existant vide est soumis -> 422 NotBlank inline (sinon la modif - // serait perdue en silence avec un faux toast de succes). - rib => rib.id === null && isRibBlank(rib), + // On ne saute une amorce neuve (id null) totalement vide que si un autre RIB + // est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank + // inline (sinon la modif serait perdue en silence avec un faux toast succes). + rib => hasSubmittableRib && rib.id === null && isRibBlank(rib), ) if (ribHasError) return diff --git a/frontend/modules/commercial/pages/suppliers/[id]/edit.vue b/frontend/modules/commercial/pages/suppliers/[id]/edit.vue new file mode 100644 index 0000000..8936e8c --- /dev/null +++ b/frontend/modules/commercial/pages/suppliers/[id]/edit.vue @@ -0,0 +1,927 @@ + + + diff --git a/frontend/modules/commercial/pages/suppliers/new.vue b/frontend/modules/commercial/pages/suppliers/new.vue index 9f7ab4b..3101184 100644 --- a/frontend/modules/commercial/pages/suppliers/new.vue +++ b/frontend/modules/commercial/pages/suppliers/new.vue @@ -266,7 +266,7 @@ class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" > { tabSubmitting.value = true accountingErrors.clearErrors() try { - // 1) POST/PATCH des RIB d'abord (erreurs inline par ligne). Seuls les blocs - // RIB TOTALEMENT vides (amorce neuve) sont ignores. + // 1) POST/PATCH des RIB d'abord (erreurs inline par ligne). On ne saute une + // amorce neuve vide QUE s'il reste un autre RIB soumettable : sinon (LCR sans + // aucun RIB rempli) on la soumet pour declencher la 422 NotBlank inline. + const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r)) const ribHasError = await submitRows( ribs.value, ribErrors, @@ -802,7 +804,7 @@ async function submitAccounting(): Promise { } }, error => toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(error) }), - rib => rib.id === null && isRibBlank(rib), + rib => hasSubmittableRib && rib.id === null && isRibBlank(rib), ) if (ribHasError) return 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/__tests__/supplierEdit.spec.ts b/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts index 11b2570..8d9f3d6 100644 --- a/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts +++ b/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts @@ -6,7 +6,12 @@ import { buildInformationPayload, buildMainPayload, buildRibPayload, + mapAccountingFormDraft, + mapInformationDraft, + mapMainDraft, + resolveTabEditability, } from '../supplierEdit' +import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation' import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/supplierForm' describe('buildMainPayload (groupe supplier:write:main)', () => { @@ -17,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)', () => { @@ -86,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) @@ -113,3 +134,85 @@ describe('buildRibPayload (sous-ressource supplier_rib)', () => { expect(payload.iban).toBe('FR1420041010050500013M02606') }) }) + +describe('mapMainDraft — pre-remplissage bloc principal (companyName + categories, pas de relation M2)', () => { + it('extrait companyName et les IRI de categories', () => { + const draft = mapMainDraft({ + '@id': '/api/suppliers/85', id: 85, + companyName: 'DOD862875', + categories: [{ '@id': '/api/categories/2279', code: 'NEGOCIANT' }], + } as SupplierDetail) + expect(draft.companyName).toBe('DOD862875') + expect(draft.categoryIris).toEqual(['/api/categories/2279']) + }) + + it('gere les cles omises (skip_null_values) sans planter', () => { + const draft = mapMainDraft({ '@id': '/api/suppliers/2', id: 2 } as SupplierDetail) + expect(draft.companyName).toBeNull() + expect(draft.categoryIris).toEqual([]) + }) +}) + +describe('mapInformationDraft — pre-remplissage onglet Information (+ volumeForecast M2)', () => { + it('tronque foundedAt, stringifie employeesCount et volumeForecast', () => { + const draft = mapInformationDraft({ + '@id': '/api/suppliers/85', id: 85, + foundedAt: '2008-04-01T00:00:00+02:00', employeesCount: 42, volumeForecast: 8000, + } as SupplierDetail) + expect(draft.foundedAt).toBe('2008-04-01') + expect(draft.employeesCount).toBe('42') + expect(draft.volumeForecast).toBe('8000') + }) + + it('cles omises -> null (volumeForecast inclus)', () => { + const draft = mapInformationDraft({ '@id': '/api/suppliers/1', id: 1 } as SupplierDetail) + expect(draft.foundedAt).toBeNull() + expect(draft.employeesCount).toBeNull() + expect(draft.volumeForecast).toBeNull() + expect(draft.description).toBeNull() + }) +}) + +describe('mapAccountingFormDraft — pre-remplissage onglet Comptabilite', () => { + it('extrait les scalaires et les IRI des referentiels embarques', () => { + const draft = mapAccountingFormDraft({ + '@id': '/api/suppliers/85', id: 85, + siren: '123456789', accountNumber: 'F0001', nTva: 'FR00123456789', + tvaMode: { '@id': '/api/tva_modes/30', label: 'France (ventes)' }, + paymentType: '/api/payment_types/14', + } as SupplierDetail) + expect(draft.siren).toBe('123456789') + expect(draft.tvaModeIri).toBe('/api/tva_modes/30') + expect(draft.paymentTypeIri).toBe('/api/payment_types/14') + expect(draft.bankIri).toBeNull() + }) + + it('cles comptables absentes (gating par omission) -> scalaires/IRI null', () => { + const draft = mapAccountingFormDraft({ '@id': '/api/suppliers/1', id: 1 } as SupplierDetail) + expect(draft.siren).toBeNull() + expect(draft.tvaModeIri).toBeNull() + expect(draft.bankIri).toBeNull() + }) +}) + +describe('resolveTabEditability — gating par role (matrice § 2.7)', () => { + it('Admin : tout editable', () => { + expect(resolveTabEditability({ canManage: true, canAccountingView: true, canAccountingManage: true })) + .toEqual({ businessEditable: true, accountingVisible: true, accountingEditable: true }) + }) + + it('Bureau / Commerciale (manage seul) : metier editable, Comptabilite masquee', () => { + expect(resolveTabEditability({ canManage: true, canAccountingView: false, canAccountingManage: false })) + .toEqual({ businessEditable: true, accountingVisible: false, accountingEditable: false }) + }) + + it('Compta (accounting seul) : metier readonly, Comptabilite editable', () => { + expect(resolveTabEditability({ canManage: false, canAccountingView: true, canAccountingManage: true })) + .toEqual({ businessEditable: false, accountingVisible: true, accountingEditable: true }) + }) + + it('Sans permission d\'edition : rien d\'editable', () => { + expect(resolveTabEditability({ canManage: false, canAccountingView: false, canAccountingManage: false })) + .toEqual({ businessEditable: false, accountingVisible: false, accountingEditable: false }) + }) +}) 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 +} diff --git a/frontend/modules/commercial/utils/supplierEdit.ts b/frontend/modules/commercial/utils/supplierEdit.ts index a0f793a..2f8a5e5 100644 --- a/frontend/modules/commercial/utils/supplierEdit.ts +++ b/frontend/modules/commercial/utils/supplierEdit.ts @@ -1,19 +1,24 @@ /** - * Helpers purs de payload de l'ecran « Ajouter un fournisseur » (M2 Commercial), - * partages avec la future modification (96) — miroir de `clientEdit.ts` (M1). + * Helpers purs des ecrans « Ajouter » / « Modifier » un fournisseur (M2 + * Commercial) — miroir de `clientEdit.ts` (M1). Deux responsabilites, toutes deux + * testables unitairement (cf. supplierEdit.spec.ts) : + * 1. Pre-remplissage : mapper le payload `GET /api/suppliers/{id}` (embed + + * scalaires) vers les brouillons « plats » edites par la page de modification. + * 2. Scoping STRICT des payloads PATCH (mode strict RG-2.16 / ERP-74) : chaque + * onglet n'envoie QUE les champs de SON groupe de serialisation, jamais un + * payload mixte (un champ hors-permission = 403 sur l'integralite cote back). * - * Scoping STRICT des payloads (mode strict, aligne ERP-74/RG) : chaque onglet - * n'envoie QUE les champs de SON groupe de serialisation, jamais un payload mixte - * (un champ hors-permission = 403 sur l'integralite cote back). Ces helpers ne - * touchent ni a l'API ni a l'etat reactif. + * Ces helpers ne touchent ni a l'API ni a l'etat reactif. */ import { ADDRESS_REQUIRED_NON_NULLABLE_KEYS, + blankEmptyRequired, MAIN_REQUIRED_NON_NULLABLE_KEYS, omitEmptyRequired, RIB_REQUIRED_NON_NULLABLE_KEYS, } from '~/modules/commercial/utils/supplierFormRules' +import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/supplierConsultation' import type { SupplierAddressFormDraft, SupplierContactFormDraft, @@ -53,15 +58,118 @@ export interface AccountingFormDraft { bankIri: string | null } +/** Permissions de l'utilisateur courant pertinentes pour l'edition d'un fournisseur. */ +export interface SupplierEditAbilities { + /** `commercial.suppliers.manage` : bloc principal + onglets metier. */ + canManage: boolean + /** `commercial.suppliers.accounting.view` : visibilite de l'onglet Comptabilite. */ + canAccountingView: boolean + /** `commercial.suppliers.accounting.manage` : edition de l'onglet Comptabilite. */ + canAccountingManage: boolean +} + +/** Editabilite resolue par zone d'onglet (deduite des permissions). */ +export interface TabEditability { + /** Bloc principal + onglets Information / Contacts / Adresses editables. */ + businessEditable: boolean + /** Onglet Comptabilite present (affiche). */ + accountingVisible: boolean + /** Onglet Comptabilite editable. */ + accountingEditable: boolean +} + +// ── Pre-remplissage (GET detail -> brouillons) ────────────────────────────── + +/** Mappe le detail fournisseur vers le brouillon du bloc principal. */ +export function mapMainDraft(supplier: SupplierDetail): MainFormDraft { + return { + companyName: supplier.companyName ?? null, + categoryIris: (supplier.categories ?? []).map(c => c['@id']), + } +} + +/** Mappe le detail fournisseur vers le brouillon de l'onglet Information. */ +export function mapInformationDraft(supplier: SupplierDetail): InformationFormDraft { + return { + description: supplier.description ?? null, + competitors: supplier.competitors ?? null, + // MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime. + foundedAt: supplier.foundedAt ? supplier.foundedAt.slice(0, 10) : null, + employeesCount: supplier.employeesCount != null ? String(supplier.employeesCount) : null, + revenueAmount: supplier.revenueAmount ?? null, + profitAmount: supplier.profitAmount ?? null, + directorName: supplier.directorName ?? null, + // Volume previsionnel (entier, specifique fournisseur) en chaine pour la saisie. + volumeForecast: supplier.volumeForecast != null ? String(supplier.volumeForecast) : null, + } +} + +/** Mappe les champs comptables du detail vers le brouillon de l'onglet (scalaires + IRI). */ +export function mapAccountingFormDraft(supplier: SupplierDetail): AccountingFormDraft { + return { + siren: supplier.siren ?? null, + accountNumber: supplier.accountNumber ?? null, + nTva: supplier.nTva ?? null, + tvaModeIri: iriOf(supplier.tvaMode), + paymentDelayIri: iriOf(supplier.paymentDelay), + paymentTypeIri: iriOf(supplier.paymentType), + bankIri: iriOf(supplier.bank), + } +} + +/** + * Resout l'editabilite par zone a partir des permissions (option 1 ERP-74, + * miroir UI du re-gating champ-par-champ du SupplierProcessor) : + * - bloc principal + Information/Contacts/Adresses : editables ssi `manage` ; + * - Comptabilite : visible ssi `accounting.view`, editable ssi `accounting.manage`. + * + * Produit le comportement attendu : + * - Admin : tout editable. + * - Bureau / Commerciale (manage, sans accounting) : metier editable, Compta masquee. + * - Compta (accounting seul, sans manage) : metier readonly, Compta editable. + */ +export function resolveTabEditability(abilities: SupplierEditAbilities): TabEditability { + return { + businessEditable: abilities.canManage, + accountingVisible: abilities.canAccountingView, + accountingEditable: abilities.canAccountingManage, + } +} + +// ── Scoping strict des payloads PATCH/POST ────────────────────────────────── + +/** + * 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 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 omis si vide -> 422 NotBlank au lieu d'un 400 de type (ERP-119). + * companyName vide -> 422 NotBlank (omis a la creation, `''` en edition — ERP-119). */ -export function buildMainPayload(main: MainFormDraft): Record { - return omitEmptyRequired({ +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. */ @@ -116,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, @@ -129,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 +}