From 136a85dda7bf0a3e0f91f85fc9fa38796005d8fc Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Sun, 1 Nov 2026 22:36:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(pl):=20=E6=94=AF=E6=8C=81=E8=AF=81?= =?UTF-8?q?=E4=BB=B6=E5=BB=BA=E6=A1=A3=E6=97=B6=E5=8E=9F=E5=AD=90=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E5=A4=9A=E5=90=88=E5=90=8C=E4=BA=A7=E5=93=81=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E5=B9=B6=E4=BF=9D=E6=8A=A4=E5=8F=97=E9=99=90=E5=85=B3?= =?UTF-8?q?=E7=B3=BB=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web-admin/scripts/quota-cert-form.test.ts | 206 +++++++++++++++- web-admin/src/views/pl/quota-cert/index.vue | 220 +++++++++++++++++- .../src/views/pl/quota-cert/model.test.ts | 51 ++++ web-admin/src/views/pl/quota-cert/model.ts | 18 ++ 4 files changed, 484 insertions(+), 11 deletions(-) diff --git a/web-admin/scripts/quota-cert-form.test.ts b/web-admin/scripts/quota-cert-form.test.ts index b9e8f75b2..ec3085c0c 100644 --- a/web-admin/scripts/quota-cert-form.test.ts +++ b/web-admin/scripts/quota-cert-form.test.ts @@ -20,6 +20,13 @@ function harness() { writes: any[] = [], parses: any[] = [], messages: string[] = [] + let certData: any = {} + let saveError = false + let pendingSave: ReturnType | undefined + let pendingGet: ReturnType | undefined + const user = vue.reactive({ accessToken: 'synthetic', getUserInfo: {} }) + const contractRequests: any[] = [] + const productRequests: any[] = [] let confirmSwitch = true let pendingOptions: ReturnType | undefined const env = { @@ -31,7 +38,7 @@ function harness() { usePersistedListColumns, useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }), useRoute: () => ({ query: {} }), - useUserStore: () => ({ accessToken: 'synthetic', getUserInfo: {} }), + useUserStore: () => user, onMounted: () => {}, onBeforeUnmount: () => {}, fetchGetMenuCodes: async () => ({ all: true }), @@ -45,18 +52,37 @@ function harness() { } }, quotaCertApi: { + contracts: (keyword: string) => { + const pending = deferred() + contractRequests.push({ keyword, ...pending }) + return pending.promise + }, + products: (id: string) => { + const pending = deferred() + productRequests.push({ id, ...pending }) + return pending.promise + }, + certificateContracts: async () => [ + { id: 'old', contractNo: '历史合同', contractName: '真实标签' } + ], list: async (kind: string) => { reads.push(kind) return pendingOptions ? pendingOptions.promise : [] }, - get: async (kind: string, id: string) => ({ - ...model.newCert(kind as any), - id, - certNo: 'EXISTING', - attachmentUrls: 'a.png,b.png' - }), + get: async (kind: string, id: string) => + pendingGet + ? pendingGet.promise + : { + ...model.newCert(kind as any), + id, + certNo: 'EXISTING', + attachmentUrls: 'a.png,b.png', + ...certData + }, save: async (kind: string, body: any, id?: string) => { writes.push({ kind, body, id }) + if (saveError) throw new Error('保存失败') + if (pendingSave) await pendingSave.promise }, recognize: (kind: string, url: string) => { const pending = deferred() @@ -89,6 +115,17 @@ function harness() { state.formRef.value = { validate: async () => true, clearValidate: () => {} } return { state, + user, + deferSave: () => (pendingSave = deferred()), + deferGet: () => (pendingGet = deferred()), + contractRequests, + productRequests, + setCert: (value: any) => { + certData = value + }, + failSave: () => { + saveError = true + }, reads, writes, parses, @@ -264,3 +301,158 @@ test('closed or switched forms reject delayed recognition results', async () => } } }) + +test('多合同一次保存且失败保留输入;受限可见关系只展示并省略提交', async () => { + const h = harness(), + s = h.state + const bindings = [ + { + id: 'b', + contractId: 'old', + linkStatus: 'inactive', + remark: '保留', + orderNum: 4, + products: [ + { + id: 'scope', + contractProductId: 'p', + scopeStatus: 'inactive', + remark: '范围', + orderNum: 2 + } + ] + } + ] + try { + h.setCert({ contractBindings: bindings, bindingsEditable: true }) + await s.edit({ id: 'existing' }) + assert.deepEqual(s.form.value.contractBindings, bindings) + assert.equal(s.bindingContracts.value[0].contractNo, '历史合同') + s.addContractBinding('new') + assert.equal(s.form.value.contractBindings.length, 2) + h.failSave() + await assert.rejects(s.save(), /保存失败/) + assert.equal(h.writes.length, 1) + assert.equal(h.writes[0].body.contractBindings[0].products[0].id, 'scope') + assert.equal(s.formVisible.value, true) + assert.equal(s.form.value.contractBindings.length, 2) + s.formVisible.value = false + h.setCert({ contractBindings: bindings, bindingsEditable: false }) + await s.edit({ id: 'limited' }) + s.addContractBinding('forbidden') + assert.equal(s.form.value.contractBindings.length, 1) + await assert.rejects(s.save()) + assert.equal('contractBindings' in h.writes[1].body, false) + } finally { + h.stop() + } +}) + +test('产品勾选往返保留原范围ID;关闭重开后拒绝旧合同和产品请求', async () => { + const h = harness(), + s = h.state + try { + await s.edit() + s.addContractBinding('c') + s.form.value.contractBindings[0].products = [ + { id: 'scope', contractProductId: 'p', scopeStatus: 'inactive' } + ] + s.setBindingProducts(0, ['p', 'p2']) + assert.equal(s.form.value.contractBindings[0].products[0].id, 'scope') + assert.equal(s.form.value.contractBindings[0].products[0].scopeStatus, 'inactive') + const search = s.searchBindingContracts('old') + const products = s.loadBindingProducts('c') + s.formVisible.value = false + await s.edit() + h.contractRequests.at(-1).resolve([{ id: 'stale', contractNo: '旧合同' }]) + h.productRequests.forEach((r) => r.resolve([{ id: 'stale', productName: '旧产品' }])) + await Promise.all([search, products]) + assert.deepEqual(s.bindingContracts.value, []) + assert.deepEqual(s.bindingProducts.value, {}) + } finally { + h.stop() + } +}) + +test('移除后重选同一合同保留主关联和产品范围身份', async () => { + const h = harness(), + s = h.state + try { + h.setCert({ + contractBindings: [ + { + id: 'binding', + contractId: 'old', + linkStatus: 'inactive', + products: [{ id: 'scope', contractProductId: 'p', scopeStatus: 'inactive' }] + } + ] + }) + await s.edit({ id: 'cert' }) + s.removeContractBinding(0) + s.addContractBinding('old') + assert.equal(s.form.value.contractBindings[0].id, 'binding') + assert.equal(s.form.value.contractBindings[0].products[0].id, 'scope') + } finally { + h.stop() + } +}) + +test('后发合同搜索覆盖先发,租户切换使保存和读取旧结果失效', async () => { + const h = harness(), + s = h.state + try { + await s.edit() + const first = s.searchBindingContracts('first'), + second = s.searchBindingContracts('second') + h.contractRequests[1].resolve([{ id: 'second', contractNo: 'SECOND', contractName: '' }]) + await second + h.contractRequests[0].resolve([{ id: 'first', contractNo: 'FIRST', contractName: '' }]) + await first + assert.equal(s.bindingContracts.value[0].id, 'second') + const pending = h.deferSave() + const saving = s.save() + await vue.nextTick() + h.user.accessToken = '' + pending.resolve({}) + await saving + assert.equal(h.messages.includes('证件已保存'), false) + assert.equal(s.formVisible.value, false) + const get = h.deferGet(), + editing = s.edit({ id: 'old' }) + h.user.getUserInfo = { tenantNo: 'other' } + get.resolve({ ...model.newCert('purchase'), contractBindings: [] }) + await editing + assert.equal(s.formVisible.value, false) + } finally { + h.stop() + } +}) + +test('新增多合同连同证面成功提交一次,移除全部提交明确空集合', async () => { + const h = harness(), + s = h.state + try { + await s.edit() + s.addContractBinding('one') + s.addContractBinding('two') + s.setBindingProducts(0, ['p1']) + s.setBindingProducts(1, ['p2']) + s.form.value.certNo = 'MULTI' + await s.save() + assert.equal(h.writes.length, 1) + assert.equal(h.writes[0].body.certNo, 'MULTI') + assert.deepEqual( + h.writes[0].body.contractBindings.map((b: any) => b.contractId), + ['one', 'two'] + ) + assert.equal(s.formVisible.value, false) + h.setCert({ contractBindings: [{ id: 'b', contractId: 'one', products: [] }] }) + await s.edit({ id: 'existing' }) + s.removeContractBinding(0) + await s.save() + assert.deepEqual(h.writes[1].body.contractBindings, []) + } finally { + h.stop() + } +}) diff --git a/web-admin/src/views/pl/quota-cert/index.vue b/web-admin/src/views/pl/quota-cert/index.vue index 4b7638efb..40f87e38e 100644 --- a/web-admin/src/views/pl/quota-cert/index.vue +++ b/web-admin/src/views/pl/quota-cert/index.vue @@ -261,6 +261,87 @@ >添加引用 +
+

绑定合同产品

+

+ 当前仅展示可见合同,绑定范围不可编辑;证面信息仍可保存。 +

+ + + + + + + + + + + + + + + + + +
@@ -413,6 +494,8 @@ quotaCertApi, type CertType, type QuotaCert, + type CertificateContractBinding, + type ContractProduct, type ContractOption } from '@/api/pl/quota-cert' import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue' @@ -524,12 +607,104 @@ } } let editSession = 0 + let bindingSearchEpoch = 0 + const bindingContracts = ref([]) + const bindingProducts = ref>({}) + const bindingSearching = ref(false) + const removedBindings = new Map() + const originalScopes = new Map() + function bindingContractLabel(id: string) { + const contract = bindingContracts.value.find((item) => item.id === id) + return contract + ? [contract.contractNo, contract.contractName].filter(Boolean).join(' · ') + : '合同信息不可用' + } + function bindingProductOptions(binding: CertificateContractBinding): ContractProduct[] { + const options = bindingProducts.value[binding.contractId] || [] + return [ + ...options, + ...binding.products + .filter((scope) => !options.some((product) => product.id === scope.contractProductId)) + .map((scope) => ({ + id: scope.contractProductId, + productId: '', + productName: '产品信息不可用' + })) + ] + } + async function searchBindingContracts(keyword: string) { + const session = editSession, + current = ++bindingSearchEpoch + bindingSearching.value = true + try { + const result = await quotaCertApi.contracts(keyword) + if (session !== editSession || current !== bindingSearchEpoch || !formVisible.value) return + const selected = new Set( + (form.value.contractBindings || []).map((b: CertificateContractBinding) => b.contractId) + ) + bindingContracts.value = [ + ...bindingContracts.value.filter((c) => selected.has(c.id)), + ...result.filter( + (c) => !selected.has(c.id) || !bindingContracts.value.some((old) => old.id === c.id) + ) + ] + } finally { + if (session === editSession && current === bindingSearchEpoch) bindingSearching.value = false + } + } + async function loadBindingProducts(contractId: string) { + const session = editSession + const result = await quotaCertApi.products(contractId) + if ( + session !== editSession || + !formVisible.value || + !form.value.contractBindings?.some( + (b: CertificateContractBinding) => b.contractId === contractId + ) + ) + return + bindingProducts.value = { ...bindingProducts.value, [contractId]: result } + } + function addContractBinding(contractId: string) { + if ( + !contractId || + saving.value || + form.value.bindingsEditable === false || + form.value.contractBindings?.some( + (b: CertificateContractBinding) => b.contractId === contractId + ) + ) + return + form.value.contractBindings ||= [] + form.value.contractBindings.push( + removedBindings.get(contractId) || { contractId, linkStatus: 'active', products: [] } + ) + void loadBindingProducts(contractId).catch(() => {}) + } + function removeContractBinding(index: number) { + if (saving.value || form.value.bindingsEditable === false) return + const [removed] = form.value.contractBindings.splice(index, 1) + removedBindings.set(removed.contractId, removed) + } + function setBindingProducts(index: number, ids: string[]) { + if (saving.value || form.value.bindingsEditable === false) return + const binding = form.value.contractBindings[index] as CertificateContractBinding + const previous = [...binding.products, ...(originalScopes.get(binding.contractId) || [])] + binding.products = ids.map( + (id) => + previous.find((p) => p.contractProductId === id) || { + contractProductId: id, + scopeStatus: 'active' + } + ) + } watch( formVisible, (visible) => { if (!visible) { editSession++ recognizing.value = false + bindingSearching.value = false } }, { flush: 'sync' } @@ -551,9 +726,18 @@ if (saving.value) return const session = ++editSession const targetKind = kind.value - const value = row - ? certPayload(targetKind, await quotaCertApi.get(targetKind, row.id)) - : newCert(targetKind) + const cert = row ? await quotaCertApi.get(targetKind, row.id) : null + const value = cert + ? { + ...certPayload(targetKind, cert), + contractBindings: (cert.contractBindings || []).map((b) => ({ + ...b, + products: b.products.map((p) => ({ ...p })) + })), + bindingsEditable: cert.bindingsEditable !== false + } + : { ...newCert(targetKind), contractBindings: [], bindingsEditable: true } + const contracts = row ? await quotaCertApi.certificateContracts(targetKind, row.id) : [] const options = targetKind === 'transport' ? await quotaCertApi.list('purchase') : [] if (session !== editSession || kind.value !== targetKind) return editingId.value = row?.id || '' @@ -561,10 +745,22 @@ selectedFormKind.value = targetKind edited.clear() recognitionImage.value = '' + bindingContracts.value = contracts + bindingProducts.value = {} + bindingSearching.value = false + originalScopes.clear() + removedBindings.clear() + for (const binding of value.contractBindings) + originalScopes.set( + binding.contractId, + binding.products.map((p) => ({ ...p })) + ) form.value = value purchases.value = options recognizing.value = false formVisible.value = true + for (const binding of value.contractBindings) + void loadBindingProducts(binding.contractId).catch(() => {}) } async function changeFormKind(value: string | number | boolean | undefined) { try { @@ -582,6 +778,7 @@ const session = editSession const hasContent = formFields.value.some(([key]) => form.value[key] != null && form.value[key] !== '') || + form.value.contractBindings?.length || form.value.purchaseRefs?.length || form.value.vehicleNumbersJson?.length if (hasContent) { @@ -601,7 +798,20 @@ const { entryMode, attachmentUrls, remark, orderNum } = form.value formKind.value = value selectedFormKind.value = value - form.value = { ...newCert(value), entryMode, attachmentUrls, remark, orderNum } + form.value = { + ...newCert(value), + entryMode, + attachmentUrls, + remark, + orderNum, + contractBindings: [], + bindingsEditable: true + } + bindingContracts.value = [] + bindingProducts.value = {} + bindingSearching.value = false + originalScopes.clear() + removedBindings.clear() purchases.value = [] edited.clear() formRef.value?.clearValidate() @@ -633,6 +843,7 @@ certPayload(targetKind, form.value), editingId.value || undefined ) + if (session !== editSession || disposed) return formVisible.value = false ElMessage.success('证件已保存') if (kind.value !== targetKind) kind.value = targetKind @@ -771,6 +982,7 @@ onMounted(init) onBeforeUnmount(() => { disposed = true + editSession++ listEpoch++ authEpoch++ }) diff --git a/web-admin/src/views/pl/quota-cert/model.test.ts b/web-admin/src/views/pl/quota-cert/model.test.ts index e88ff3eb7..d49e698b3 100644 --- a/web-admin/src/views/pl/quota-cert/model.test.ts +++ b/web-admin/src/views/pl/quota-cert/model.test.ts @@ -79,3 +79,54 @@ test('停用产品范围和停用主关联均不展示为可用', () => { ) assert.equal(bindingProductEnabled({ ...binding, linkStatus: 'inactive' }, 'p'), false) }) + +test('证件绑定保留ID和业务字段,受限或遗漏集合不提交', () => { + const contractBindings = [ + { + id: 'b', + contractId: 'c', + linkStatus: 'inactive', + remark: '备注', + orderNum: 2, + scopeRevision: 7, + products: [ + { + id: 's', + contractProductId: 'p', + scopeStatus: 'inactive', + remark: '范围', + orderNum: 3, + productName: '只读' + } + ] + } + ] + assert.deepEqual(certPayload('purchase', { contractBindings }).contractBindings, [ + { + id: 'b', + contractId: 'c', + linkStatus: 'inactive', + remark: '备注', + orderNum: 2, + products: [ + { id: 's', contractProductId: 'p', scopeStatus: 'inactive', remark: '范围', orderNum: 3 } + ] + } + ]) + assert.equal( + 'contractBindings' in certPayload('purchase', { contractBindings, bindingsEditable: false }), + false + ) + assert.equal('contractBindings' in certPayload('purchase', {}), false) + assert.deepEqual(certPayload('purchase', { contractBindings: [] }).contractBindings, []) +}) +test('识别不能修改合同绑定或只读字段', () => { + const form = { ...newCert('purchase'), contractBindings: [] } + mergeCandidates( + form, + { contractBindings: [{ contractId: 'c', products: [] }], bindingsEditable: false } as any, + new Set() + ) + assert.deepEqual(form.contractBindings, []) + assert.equal('bindingsEditable' in form, false) +}) diff --git a/web-admin/src/views/pl/quota-cert/model.ts b/web-admin/src/views/pl/quota-cert/model.ts index ec3d5dbae..f5cfb067a 100644 --- a/web-admin/src/views/pl/quota-cert/model.ts +++ b/web-admin/src/views/pl/quota-cert/model.ts @@ -67,6 +67,16 @@ export function certPayload(kind: CertType, form: Record): CertBody .map((k) => [k, r[k]]) ) ) + if (form.bindingsEditable !== false && Array.isArray(form.contractBindings)) { + const pick = (row: Record, keys: string[]) => + Object.fromEntries(keys.filter((key) => key in row).map((key) => [key, row[key]])) + result.contractBindings = form.contractBindings.map((binding: Record) => ({ + ...pick(binding, ['id', 'contractId', 'linkStatus', 'remark', 'orderNum']), + products: binding.products.map((product: Record) => + pick(product, ['id', 'contractProductId', 'scopeStatus', 'remark', 'orderNum']) + ) + })) + } return result as CertBody } @@ -76,7 +86,15 @@ export function mergeCandidates( edited: Set ) { const target = form as unknown as Record + const allowed = new Set([ + ...commonFields.map(([key]) => key), + ...purchaseFields.map(([key]) => key), + ...transportFields.map(([key]) => key), + 'vehicleNumbersJson', + 'purchaseRefs' + ]) for (const [key, value] of Object.entries(candidates)) { + if (!allowed.has(key)) continue const current = target[key] if ( !edited.has(key) &&