fix(pl): 优化计划用证行内绑定并保护已关联数据 #12

Merged
cykj merged 3 commits from codex/issue-10-quota-cert into V3.0 2026-11-02 06:19:19 +00:00
12 changed files with 336 additions and 55 deletions

No files matched your search

@@ -6,7 +6,7 @@ from fastapi import HTTPException
from pydantic import BaseModel, Field
from app.crud.crm.data_scope_helpers import apply_crm_owner_scope
from app.models.crm_models import CrmContract
from app.models.pl_quota_models import PlContractCertLink, PlContractCertProduct, PlTransportCertPurchaseRef
from app.models.pl_quota_models import PlContractCertLink, PlContractCertProduct, PlTransportCertPurchaseRef, PlPlanCertUsageItem
from app.schemas.pl.quota_cert import InputBody
from app.schemas.pl.quota_scope import ScopeProductBody
from app.services import pl_quota_cert_service as certs
@@ -59,8 +59,15 @@ def get_cert(db, user, cert_type, cert_id, current=False):
total_links = len(rows(db.query(PlContractCertLink.id).filter_by(tenant_no=user.tenant_no,
cert_type=cert_type, cert_id=cert_id, del_flag=0)))
result['bindingsEditable'] = total_links == len(links)
locked_links = {item.contract_cert_link_id for item in rows(
db.query(PlPlanCertUsageItem.contract_cert_link_id).filter(
PlPlanCertUsageItem.tenant_no == user.tenant_no,
PlPlanCertUsageItem.del_flag == 0,
PlPlanCertUsageItem.contract_cert_link_id.in_([link.id for link in links]),
))} if links else set()
result['contractBindings'] = [{
'id': link.id, 'contractId': link.contract_id, 'linkStatus': link.link_status,
'editLocked': link.id in locked_links,
'remark': link.remark, 'orderNum': link.order_num,
'products': [{'id': p.id, 'contractProductId': p.contract_product_id,
'scopeStatus': p.scope_status, 'remark': p.remark, 'orderNum': p.order_num}
+11 -4
View File
@@ -74,6 +74,7 @@ def get_cert(db, user, cert_type, cert_id, current=False):
result['availableQty'] = row.total_qty - row.used_qty - row.reserved_qty
if cert_type == 'transport':
result['purchaseRefs'] = [_row_to_dict(r) for r in _refs(db, user, cert_id, lock=current)]
result['referencesEditable'] = not _has_history(db, user, cert_type, cert_id, lock=current)
return result
@@ -92,8 +93,14 @@ def _audit(user):
return dict(id=str(uuid4()), tenant_no=user.tenant_no, create_by=user.id, create_dept_id=user.dept_id or '', update_by=user.id)
def _has_history(db, user, cert_type, cert_id):
return any(db.query(model.id).filter_by(tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id).with_for_update().first() is not None for model in (PlCertQtyLog, PlPlanCertUsage))
def _has_history(db, user, cert_type, cert_id, lock=True):
for model in (PlCertQtyLog, PlPlanCertUsage):
query = db.query(model.id).filter_by(tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id)
if lock:
query = query.with_for_update()
if query.first() is not None:
return True
return False
def _validate_refs(db, user, cert_id, submitted, historical, purchases, current=False):
@@ -133,11 +140,11 @@ def _validate_refs(db, user, cert_id, submitted, historical, purchases, current=
if purchase.cert_no != item['reference_no']:
raise HTTPException(400, '引用证号与购买证不一致')
if historical and (not previous or previous.del_flag == 1 or any(getattr(previous,k) != v for k,v in item.items() if k in ('reference_type','reference_no','purchase_cert_id'))):
raise HTTPException(400, '已有历史用量,不能改变证面引用身份')
raise HTTPException(400, '已有历史用量,不能改变证面引用身份,请联系管理员')
pending.append((previous, item))
removed = [r for rid,r in old.items() if rid not in ids]
if historical and removed:
raise HTTPException(400, '已有历史用量,不能移除证面引用')
raise HTTPException(400, '已有历史用量,不能移除证面引用,请联系管理员')
return pending, removed
@@ -66,7 +66,7 @@ def _assert_unreferenced(db, user, *, link_id=None, product_id=None):
else:
query = query.filter_by(contract_cert_link_id=link_id)
if query.with_for_update().populate_existing().first():
raise HTTPException(409, '适用范围已有用证引用,不能直接移除或停用')
raise HTTPException(409, '适用范围已有用证引用,不能编辑或移除,请联系管理员')
def _prepare_products(db, user, contract_id, link, submitted):
@@ -149,6 +149,17 @@ def prepare_contract_bindings(db, user, contract, values, certs, keys):
if previous and item.get('link_status') == 'inactive' and previous.link_status != 'inactive':
_assert_unreferenced(db, user, link_id=previous.id)
products = _prepare_products(db, user, contract_id, previous, item['products']) if 'products' in item else None
if previous:
changed = any(getattr(previous, key) != value for key, value in item.items()
if key not in ('id', 'products', 'cert_type', 'cert_id'))
if products is not None:
changed = changed or bool(products[1]) or any(
row is None or row.del_flag != 0 or any(
getattr(row, key) != value for key, value in product.items() if key != 'id'
) for row, product in products[0]
)
if changed:
_assert_unreferenced(db, user, link_id=previous.id)
if certs[key].status == 'void':
expands = (previous is None or previous.del_flag != 0 or
(previous.link_status != 'active' and item.get('link_status') == 'active'))
+3 -1
View File
@@ -140,12 +140,14 @@ def test_refs_duplicate_cross_owner_and_used_removal(ctx):
first=s.save_cert(db,u,'transport',transport(purchaseRefs=[{'referenceType':'unknown','referenceNo':'A'}]))
data=transport(); data['certNo']='Y2'
second=s.save_cert(db,u,'transport',data)
assert first['referencesEditable'] is True
ref=first['purchaseRefs'][0]
child={k:ref[k] for k in ('id','referenceType','referenceNo')}
for cert_id,refs in ((first['id'],[child,child]),(second['id'],[child])):
with pytest.raises(HTTPException): s.save_cert(db,u,'transport',{'purchaseRefs':refs},cert_id)
s.confirm_cert(db,u,'transport',first['id'],opening())
with pytest.raises(HTTPException): s.save_cert(db,u,'transport',{'purchaseRefs':[]},first['id'])
assert s.get_cert(db,u,'transport',first['id'])['referencesEditable'] is False
with pytest.raises(HTTPException, match='联系管理员'): s.save_cert(db,u,'transport',{'purchaseRefs':[]},first['id'])
assert len(s.get_cert(db,u,'transport',first['id'])['purchaseRefs'])==1
@@ -253,3 +253,42 @@ def test_plain_get_does_not_acquire_write_locks(case):
finally:
event.remove(c.db, 'do_orm_execute', capture)
assert len(result['contractBindings']) == 2 and not any(reads)
def add_usage_item(c, link, tenant='t', deleted=0):
c.db.add(c.m.PlPlanCertUsageItem(id='locked-item', usage_id='usage', tenant_no=tenant,
contract_cert_link_id=link['id'], contract_cert_product_id=link['products'][0]['id'],
contract_scope_revision=1, contract_id='c1', contract_product_id='p1', plan_product_id='pp',
source_stop_type='delivery', source_stop_id='stop', source_stop_product_id='sp',
allocation_seq=1, source_unit='吨', planned_source_qty=10, del_flag=deleted))
c.db.flush()
@pytest.mark.parametrize('change', ['add_product', 'remove', 'switch_contract'])
def test_referenced_binding_edit_is_rejected_before_face_write(case, change):
c = case
result = save(c, {'contractBindings': [scopes()[0]]})
link = result['contractBindings'][0]
add_usage_item(c, link)
data = [scopes()[0]]
if change == 'add_product': data[0]['products'].append({'contractProductId': 'p2'})
if change == 'remove': data = []
if change == 'switch_contract': data = [scopes()[1]]
with pytest.raises(HTTPException, match='联系管理员'):
save(c, {'remark': 'must not write', 'contractBindings': data})
assert c.db.query(c.m.PlPurchaseCert).filter_by(id='g1').one().remark is None
assert c.db.query(c.m.PlContractCertProduct).filter_by(del_flag=0).count() == 1
@pytest.mark.parametrize('tenant,deleted,locked', [('t',0,True), ('other',0,False), ('t',1,False)])
def test_binding_readonly_status_respects_tenant_and_soft_delete(case, tenant, deleted, locked):
from app.services import pl_quota_cert_bindings as svc
c = case
result = save(c, {'contractBindings': [scopes()[0]]})
link = result['contractBindings'][0]
add_usage_item(c, link, tenant, deleted)
result = svc.get_cert(c.db, c.user, 'purchase', 'g1')
assert result['contractBindings'][0]['editLocked'] is locked
unchanged = save(c, {'contractBindings': [scopes()[0]]})
assert unchanged['contractBindings'][0]['id'] == link['id']
assert unchanged['contractBindings'][0]['products'][0]['id'] == link['products'][0]['id']
@@ -0,0 +1,9 @@
# 设计
用户明确批准原型 v1:https://www.figma.com/design/ImDcikQnCX5GiJQLwUHwDC/shanxia2.0?node-id=32-300 。复用 Fresh Mint、现有 quota-cert/index.vue 的 Element Plus 弹窗和表格。选择行内合同下拉,避免继续使用独立检索框或新增第二层选择弹窗。
引用添加按钮放在表格上方;合同绑定先新增空行,再选择合同和产品。切换未关联合同重置产品,恢复已移除的同一合同使用原关联及产品 ID,禁止重复合同与未填合同行保存。
后端输出只读 referencesEditable 和绑定 editLocked,前端禁用相关控件并显示联系管理员提示。证面引用沿用已有历史记录保护;合同行存在当前租户有效用证明细时,合同身份、产品集合和状态不得变动,允许不变保存。保存前锁读复验,在任何写入前拒绝整单。只读标记不进入写入 payload。
测试使用 SQLite 内存库及现有 Vue 脚本 harness;测试不连接业务库。还需构建、差异审查与实际弹窗视觉验证。工程未授权 PR、合并及关单,完成可授权范围后保留交付待办。
@@ -0,0 +1,3 @@
# 工单 #10:计划用证行内绑定与关联保护
仅修改 Web 计划用证弹窗和共享后端保护,用户于 2026-11-02 明确确认。新增能力 `quota-cert-inline-bindings`:统一证面引用和合同产品的添加行入口,阻止已关联行的业务身份调整。无数据库结构变更,不调整 H5 / 小程序界面。
@@ -0,0 +1,17 @@
## ADDED Requirements
### Requirement: 行内合同产品选择
Web SHALL 将添加引用按钮置于引用表上方,合同绑定 SHALL 使用添加行和行内合同、产品选择。
#### Scenario: 新增与切换合同
- WHEN 新增空行或切换未关联合同
- THEN 产品在选定合同后加载,切换时清除旧选择,禁止重复合同和空合同保存。
#### Scenario: 身份往返
- WHEN 不变保存或移除后重选原合同
- THEN 保留已有有效关系及明细 ID,不提交只读标记。
### Requirement: 关联保护
后端 SHALL 在写入前检查当前租户的引用,前端 SHALL 显示对应只读状态。
#### Scenario: 已有关联
- WHEN 已关联行被编辑或移除,包括绕过前端提交或弹窗打开后发生关联
- THEN 整单拒绝并提示联系管理员,保留原数据。
#### Scenario: 未关联与不变保存
- WHEN 未关联行调整或关联行不变保存
- THEN 合法调整可保存,不变行 ID 保持。
@@ -0,0 +1,18 @@
# 实施与验证
- [x] 完整读取工单、确认 Web+后端范围并批准 Figma v1。
- [x] 在 test_pl_quota_cert_bindings.py 补充关联锁定、产品集合变更拒绝、租户/软删除隔离测试,先观察失败。
- [x] 在 quota-cert-form.test.ts 补充新增空行、切换合同、锁定行和保存校验测试,先观察失败。
- [x] 修改证件与合同服务的只读状态和保存保护,保持原事务、ID 与数据权限。
- [x] 修改 quota-cert/index.vue 与 API 类型;复用原样式和 payload 白名单。
- [ ] 执行相关 pytest、前端脚本、lint 和构建;完成实际视觉验证及审查。
- [ ] 在授权内交付;PR、合并、关单需另有授权,不提前记完成。
## 已执行验证
- 后端相关 SQLite 内存测试:78 passed(含6项新增保护用例、引用只读状态断言)。
- 前端真实页面脚本 harness:17 passed;包含新增空行、切换合同、引用/合同锁定与既有ID往返。
- 改动页面和API文件 ESLint:通过;生产 Vite build:通过,有既有组件命名与大包提示。
- 全仓 vue-tsc --noEmit:未通过,输出1326行,改动页面和API类型无诊断;其他文件类型错误未纳入本工单修复。
- Chrome 1440×1000 合成数据组件渲染:使用实际两区模板、Element Plus 和构建CSS;新增空行/锁定状态无JS错误,锁定按钮禁用,提示可换行。该验证不等同于登录业务环境端到端或集成环境验收。
- 未执行:真实业务环境联调、集成分支冒烟;未启停既有服务,未修改业务数据库。
- 独立审查发现图片识别可填入只读空引用;已用页面真实识别流程复现失败,并修复识别合并时跳过锁定引用,新增回归通过。
+77
View File
@@ -512,3 +512,80 @@ test('产品加载失败可再次展开重试,加载中不能改选择', async
h.stop()
}
})
test('inline binding adds an empty row, requires contract, and resets products on change', async () => {
const h = harness(),
s = h.state
try {
await s.edit()
s.addContractBinding()
assert.equal(s.form.value.contractBindings.length, 1)
assert.equal(s.form.value.contractBindings[0].contractId, '')
await s.save()
assert.equal(h.writes.length, 0)
s.changeBindingContract(0, 'c1')
h.productRequests.at(-1).resolve([{ id: 'p1', productName: '产品1' }])
await Promise.resolve()
await Promise.resolve()
s.setBindingProducts(0, ['p1'])
s.changeBindingContract(0, 'c2')
assert.deepEqual(s.form.value.contractBindings[0].products, [])
assert.equal(s.form.value.contractBindings[0].contractId, 'c2')
} finally {
h.stop()
}
})
test('referenced bindings and references cannot be edited or removed', async () => {
const h = harness(),
s = h.state
try {
h.setCert({
referencesEditable: false,
contractBindings: [
{
id: 'b',
contractId: 'c',
editLocked: true,
products: [{ id: 'p', contractProductId: 'p1' }]
}
],
purchaseRefs: [{ id: 'r', referenceType: 'unknown', referenceNo: 'ref' }]
})
s.kind.value = 'transport'
await s.edit({ id: 'cert' })
s.removeContractBinding(0)
assert.equal(s.form.value.contractBindings.length, 1)
s.changeBindingContract(0, 'other')
assert.equal(s.form.value.contractBindings[0].contractId, 'c')
s.removeReference(0)
s.addReference()
assert.equal(s.form.value.purchaseRefs.length, 1)
assert.ok(h.messages.some((m) => m.includes('联系管理员')))
} finally {
h.stop()
}
})
test('recognition cannot populate locked empty references while other empty fields remain fillable', async () => {
const h = harness(),
s = h.state
try {
h.setCert({ referencesEditable: false, purchaseRefs: [], attachmentUrls: 'one.png' })
s.kind.value = 'transport'
await s.edit({ id: 'cert' })
const parsing = s.recognize()
h.parses[0].resolve({
candidates: {
purchaseRefs: [{ referenceType: 'unknown', referenceNo: 'OCR-NEW' }],
shipperName: '合成托运单位'
},
invalidFields: {}
})
await parsing
assert.deepEqual(s.form.value.purchaseRefs, [])
assert.equal(s.form.value.shipperName, '合成托运单位')
} finally {
h.stop()
}
})
+2
View File
@@ -51,6 +51,7 @@ export interface QuotaCert extends Omit<CertBody, 'status'> {
id: string
status: 'draft' | 'active' | 'suspended' | 'void'
bindingsEditable?: boolean
referencesEditable?: boolean
revision: number
reservedQty: string | number
usedQty: string | number
@@ -347,6 +348,7 @@ export const quotaAdjustmentApi = {
export type QuotaQuantity = string | number
export interface CertificateContractBinding {
editLocked?: boolean
id?: string
contractId: string
linkStatus?: 'active' | 'inactive'
+137 -48
View File
@@ -220,10 +220,23 @@
@change="edited.add('vehicleNumbersJson')"
/></el-form-item>
<h3 class="dialog-section-title">证面引用</h3>
<el-button
class="mb-3"
type="primary"
:disabled="saving || form.referencesEditable === false"
@click="addReference"
><el-icon><Plus /></el-icon>添加引用</el-button
>
<p v-if="form.referencesEditable === false" class="form-tip"
>已有用证关联,不能编辑或移除证面引用,请联系管理员。</p
>
<el-table :data="form.purchaseRefs" row-key="id" stripe>
<el-table-column label="引用类型" min-width="160"
><template #default="{ row }"
><el-select v-model="row.referenceType" @change="edited.add('purchaseRefs')"
><el-select
v-model="row.referenceType"
:disabled="saving || form.referencesEditable === false"
@change="edited.add('purchaseRefs')"
><el-option
v-for="(label, value) in refLabels"
:key="value"
@@ -234,12 +247,14 @@
><template #default="{ row }"
><el-input
v-model="row.referenceNo"
:disabled="saving || form.referencesEditable === false"
@input="edited.add('purchaseRefs')" /></template
></el-table-column>
<el-table-column label="对应购买证档案" min-width="200"
><template #default="{ row }"
><el-select
v-model="row.purchaseCertId"
:disabled="saving || form.referencesEditable === false"
filterable
clearable
@change="edited.add('purchaseRefs')"
@@ -251,58 +266,75 @@
></el-table-column>
<el-table-column label="操作" min-width="260" fixed="right"
><template #default="{ $index }"
><el-button @click="removeReference($index)"
><el-button
:disabled="saving || form.referencesEditable === false"
@click="removeReference($index)"
><el-icon><Delete /></el-icon>移除引用</el-button
></template
></el-table-column
>
</el-table>
<el-button @click="addReference"
><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
<el-button
v-if="form.bindingsEditable !== false"
:model-value="undefined"
filterable
remote
clearable
placeholder="搜索合同编号或名称,添加合同"
:remote-method="searchBindingContracts"
:loading="bindingSearching"
class="mb-3"
type="primary"
:disabled="saving"
@visible-change="(open: boolean) => open && searchBindingContracts('')"
@change="addContractBinding"
@click="addContractBinding()"
><el-icon><Plus /></el-icon>添加合同</el-button
>
<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>
<template #default="{ row, $index }">
<el-select
:model-value="row.contractId"
filterable
remote
placeholder="请选择合同"
:remote-method="searchBindingContracts"
:loading="bindingSearching"
:disabled="saving || form.bindingsEditable === false || row.editLocked"
@visible-change="(open: boolean) => open && searchBindingContracts('')"
@change="(id: string) => changeBindingContract($index, id)"
>
<el-option
v-for="contract in bindingContracts"
:key="contract.id"
:value="contract.id"
:label="contract.contractNo + ' · ' + contract.contractName"
:disabled="
form.contractBindings.some(
(b: CertificateContractBinding, i: number) =>
i !== $index && b.contractId === contract.id
)
"
/>
<el-option
v-if="row.contractId && !bindingContracts.some((c) => c.id === row.contractId)"
:value="row.contractId"
label="合同信息不可用"
/>
</el-select>
<p v-if="row.editLocked" class="form-tip" style="white-space: normal"
>已有用证关联,不能编辑或移除,请联系管理员。</p
>
</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"
:disabled="
saving || form.bindingsEditable === false || row.editLocked || !row.contractId
"
:loading="bindingProductLoading[row.contractId]"
placeholder="勾选适用产品"
:placeholder="row.contractId ? '勾选适用产品' : '请先选择合同'"
style="width: 100%"
@change="(ids: string[]) => setBindingProducts($index, ids)"
@visible-change="(open: boolean) => open && loadBindingProducts(row.contractId)"
@@ -334,8 +366,10 @@
min-width="260"
fixed="right"
>
<template #default="{ $index }"
><el-button :disabled="saving" @click="removeContractBinding($index)"
<template #default="{ row, $index }"
><el-button
:disabled="saving || row.editLocked"
@click="removeContractBinding($index)"
><el-icon><Delete /></el-icon>移除合同</el-button
></template
>
@@ -615,12 +649,6 @@
const bindingProductLoading = ref<Record<string, boolean>>({})
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 [
@@ -676,26 +704,66 @@
if (isCurrent()) bindingProductLoading.value[contractId] = false
}
}
function addContractBinding(contractId: string) {
function addContractBinding(contractId = '') {
if (
!contractId ||
saving.value ||
form.value.bindingsEditable === false ||
form.value.contractBindings?.some(
(b: CertificateContractBinding) => b.contractId === contractId
)
(contractId &&
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(() => {})
if (contractId) void loadBindingProducts(contractId).catch(() => {})
}
function changeBindingContract(index: number, contractId: string) {
const previous = form.value.contractBindings[index] as CertificateContractBinding
if (
saving.value ||
form.value.bindingsEditable === false ||
!previous ||
previous.contractId === contractId
)
return
if (previous.editLocked) {
ElMessage.warning('已有用证关联,不能编辑或移除,请联系管理员')
return
}
if (
form.value.contractBindings.some(
(b: CertificateContractBinding, i: number) => i !== index && b.contractId === contractId
)
) {
ElMessage.warning('该合同已添加,请在原行选择产品')
return
}
if (previous.contractId) {
removedBindings.set(previous.contractId, previous)
bindingProductEpochs.set(
previous.contractId,
(bindingProductEpochs.get(previous.contractId) || 0) + 1
)
delete bindingProductLoading.value[previous.contractId]
}
form.value.contractBindings[index] = removedBindings.get(contractId) || {
contractId,
linkStatus: 'active',
products: []
}
if (contractId) void loadBindingProducts(contractId).catch(() => {})
}
function removeContractBinding(index: number) {
if (saving.value || form.value.bindingsEditable === false) return
if (form.value.contractBindings[index]?.editLocked) {
ElMessage.warning('已有用证关联,不能编辑或移除,请联系管理员')
return
}
const [removed] = form.value.contractBindings.splice(index, 1)
removedBindings.set(removed.contractId, removed)
if (removed.contractId) removedBindings.set(removed.contractId, removed)
bindingProductEpochs.set(
removed.contractId,
(bindingProductEpochs.get(removed.contractId) || 0) + 1
@@ -706,7 +774,11 @@
function setBindingProducts(index: number, ids: string[]) {
if (saving.value || form.value.bindingsEditable === false) return
const binding = form.value.contractBindings[index] as CertificateContractBinding
if (bindingProductLoading.value[binding.contractId]) return
if (binding.editLocked) {
ElMessage.warning('已有用证关联,不能编辑或移除,请联系管理员')
return
}
if (!binding.contractId || bindingProductLoading.value[binding.contractId]) return
const previous = [...binding.products, ...(originalScopes.get(binding.contractId) || [])]
binding.products = ids.map(
(id) =>
@@ -752,7 +824,8 @@
...b,
products: b.products.map((p) => ({ ...p }))
})),
bindingsEditable: cert.bindingsEditable !== false
bindingsEditable: cert.bindingsEditable !== false,
referencesEditable: cert.referencesEditable !== false
}
: { ...newCert(targetKind), contractBindings: [], bindingsEditable: true }
const contracts = row ? await quotaCertApi.certificateContracts(targetKind, row.id) : []
@@ -848,6 +921,13 @@
}
async function save() {
if (saving.value) return
if (
form.value.bindingsEditable !== false &&
form.value.contractBindings?.some((b: CertificateContractBinding) => !b.contractId)
) {
ElMessage.warning('请选择每行绑定的合同,或移除未填写行')
return
}
if (certAttachments.value?.hasUploading()) {
ElMessage.warning('请等待附件上传完成')
return
@@ -902,7 +982,9 @@
!(form.value.attachmentUrls || '').split(',').includes(targetImage)
)
return
mergeCandidates(form.value as any, result.candidates, edited)
const protectedFields =
form.value.referencesEditable === false ? new Set([...edited, 'purchaseRefs']) : edited
mergeCandidates(form.value as any, result.candidates, protectedFields)
await nextTick()
if (session !== editSession || !formVisible.value) return
formRef.value?.clearValidate()
@@ -915,7 +997,13 @@
if (session === editSession) recognizing.value = false
}
}
function referencesLocked() {
if (form.value.referencesEditable !== false) return false
ElMessage.warning('已有用证关联,不能编辑或移除证面引用,请联系管理员')
return true
}
function addReference() {
if (saving.value || referencesLocked()) return
form.value.purchaseRefs.push({
referenceType: 'unknown',
referenceNo: '',
@@ -924,6 +1012,7 @@
edited.add('purchaseRefs')
}
function removeReference(index: number) {
if (saving.value || referencesLocked()) return
form.value.purchaseRefs.splice(index, 1)
edited.add('purchaseRefs')
}