feat(pl): 支持证件建档时原子保存多合同产品绑定并保护受限关系。

This commit is contained in:
wuzilong committed 2026-11-01 22:36:49 +08:00
1 parent d8a9aef90a
commit 136a85dda7
4 files changed
+484 -11

No files matched your search

+199 -7
View File
@@ -20,6 +20,13 @@ function harness() {
writes: any[] = [],
parses: any[] = [],
messages: string[] = []
let certData: any = {}
let saveError = false
let pendingSave: ReturnType<typeof deferred> | undefined
let pendingGet: ReturnType<typeof deferred> | undefined
const user = vue.reactive({ accessToken: 'synthetic', getUserInfo: {} })
const contractRequests: any[] = []
const productRequests: any[] = []
let confirmSwitch = true
let pendingOptions: ReturnType<typeof deferred> | 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()
}
})
+216 -4
View File
@@ -261,6 +261,87 @@
><el-icon><Plus /></el-icon>添加引用</el-button
>
</section>
<section class="view-section">
<h3 class="dialog-section-title">绑定合同产品</h3>
<p v-if="form.bindingsEditable === false" class="form-tip">
当前仅展示可见合同,绑定范围不可编辑;证面信息仍可保存。
</p>
<el-select
v-if="form.bindingsEditable !== false"
:model-value="undefined"
filterable
remote
clearable
placeholder="搜索合同编号或名称,添加合同"
:remote-method="searchBindingContracts"
:loading="bindingSearching"
:disabled="saving"
@visible-change="(open: boolean) => open && searchBindingContracts('')"
@change="addContractBinding"
>
<el-option
v-for="contract in bindingContracts"
:key="contract.id"
:value="contract.id"
:label="contract.contractNo + ' · ' + contract.contractName"
:disabled="
form.contractBindings?.some(
(b: CertificateContractBinding) => b.contractId === contract.id
)
"
/>
</el-select>
<el-table :data="form.contractBindings || []" stripe>
<el-table-column label="合同" min-width="220">
<template #default="{ row }">{{ bindingContractLabel(row.contractId) }}</template>
</el-table-column>
<el-table-column label="适用合同产品" min-width="360">
<template #default="{ row, $index }">
<el-select
multiple
:model-value="row.products.map((p: any) => p.contractProductId)"
:disabled="
saving || form.bindingsEditable === false || !bindingProducts[row.contractId]
"
placeholder="勾选适用产品"
style="width: 100%"
@change="(ids: string[]) => setBindingProducts($index, ids)"
@visible-change="(open: boolean) => open && loadBindingProducts(row.contractId)"
>
<el-option
v-for="product in bindingProductOptions(row)"
:key="product.id"
:value="product.id"
:label="
product.productName +
(product.model ? ' / ' + product.model : '') +
(product.unit ? '(' + product.unit + ')' : '')
"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="状态" min-width="100">
<template #default="{ row }"
><el-tag :type="row.linkStatus === 'inactive' ? 'info' : 'success'" size="small">{{
row.linkStatus === 'inactive' ? '已停用' : '启用'
}}</el-tag></template
>
</el-table-column>
<el-table-column
v-if="form.bindingsEditable !== false"
label="操作"
min-width="260"
fixed="right"
>
<template #default="{ $index }"
><el-button :disabled="saving" @click="removeContractBinding($index)"
><el-icon><Delete /></el-icon>移除合同</el-button
></template
>
</el-table-column>
</el-table>
</section>
<el-form-item v-if="editingId" label="停止新增使用">
<el-select v-model="form.status" clearable placeholder="保持当前状态">
<el-option label="停用(可复核恢复)" value="suspended" />
@@ -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<ContractOption[]>([])
const bindingProducts = ref<Record<string, ContractProduct[]>>({})
const bindingSearching = ref(false)
const removedBindings = new Map<string, CertificateContractBinding>()
const originalScopes = new Map<string, CertificateContractBinding['products']>()
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++
})
@@ -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)
})
@@ -67,6 +67,16 @@ export function certPayload(kind: CertType, form: Record<string, any>): CertBody
.map((k) => [k, r[k]])
)
)
if (form.bindingsEditable !== false && Array.isArray(form.contractBindings)) {
const pick = (row: Record<string, unknown>, keys: string[]) =>
Object.fromEntries(keys.filter((key) => key in row).map((key) => [key, row[key]]))
result.contractBindings = form.contractBindings.map((binding: Record<string, any>) => ({
...pick(binding, ['id', 'contractId', 'linkStatus', 'remark', 'orderNum']),
products: binding.products.map((product: Record<string, unknown>) =>
pick(product, ['id', 'contractProductId', 'scopeStatus', 'remark', 'orderNum'])
)
}))
}
return result as CertBody
}
@@ -76,7 +86,15 @@ export function mergeCandidates(
edited: Set<string>
) {
const target = form as unknown as Record<string, unknown>
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) &&