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

+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) &&