feat(pl): 支持证件建档时原子保存多合同产品绑定并保护受限关系。
This commit is contained in:
1 parent
d8a9aef90a
commit
136a85dda7
4 files changed
+484
-11
No files matched your search
@@ -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) &&
|
||||
|
||||
Reference in new issue
Block a user