Merge pull request 'fix(pl): 优化计划用证行内绑定并保护已关联数据' (#12) from codex/issue-10-quota-cert into V3.0
This commit was merged in pull request #12.
This commit is contained in:
commit
4d10142bfb
12 files changed
+336
-55
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}
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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错误,锁定按钮禁用,提示可换行。该验证不等同于登录业务环境端到端或集成环境验收。
|
||||
- 未执行:真实业务环境联调、集成分支冒烟;未启停既有服务,未修改业务数据库。
|
||||
|
||||
- 独立审查发现图片识别可填入只读空引用;已用页面真实识别流程复现失败,并修复识别合并时跳过锁定引用,新增回归通过。
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user