From d30a406aa6cc336e372105e3112338f70f928273 Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Mon, 2 Nov 2026 12:45:55 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(pl):=20=E4=BC=98=E5=8C=96=E8=AF=81?= =?UTF-8?q?=E4=BB=B6=E8=A1=8C=E5=86=85=E7=BB=91=E5=AE=9A=E5=B9=B6=E4=BF=9D?= =?UTF-8?q?=E6=8A=A4=E5=B7=B2=E5=85=B3=E8=81=94=E6=95=B0=E6=8D=AE=E5=85=8D?= =?UTF-8?q?=E4=BA=8E=E8=AF=AF=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/services/pl_quota_cert_bindings.py | 9 +- backend/app/services/pl_quota_cert_service.py | 15 +- .../app/services/pl_quota_contract_service.py | 13 +- backend/tests/test_pl_quota_cert_archive.py | 4 +- backend/tests/test_pl_quota_cert_bindings.py | 39 ++++ .../quota-cert-inline-bindings/design.md | 9 + .../quota-cert-inline-bindings/proposal.md | 3 + .../specs/quota-cert-inline-bindings/spec.md | 17 ++ .../quota-cert-inline-bindings/tasks.md | 8 + web-admin/scripts/quota-cert-form.test.ts | 54 ++++++ web-admin/src/api/pl/quota-cert.ts | 2 + web-admin/src/views/pl/quota-cert/index.vue | 181 +++++++++++++----- 12 files changed, 300 insertions(+), 54 deletions(-) create mode 100644 openspec/changes/quota-cert-inline-bindings/design.md create mode 100644 openspec/changes/quota-cert-inline-bindings/proposal.md create mode 100644 openspec/changes/quota-cert-inline-bindings/specs/quota-cert-inline-bindings/spec.md create mode 100644 openspec/changes/quota-cert-inline-bindings/tasks.md diff --git a/backend/app/services/pl_quota_cert_bindings.py b/backend/app/services/pl_quota_cert_bindings.py index 2f2cc0aff..7a508d028 100644 --- a/backend/app/services/pl_quota_cert_bindings.py +++ b/backend/app/services/pl_quota_cert_bindings.py @@ -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} diff --git a/backend/app/services/pl_quota_cert_service.py b/backend/app/services/pl_quota_cert_service.py index b7ea7e5a2..684d151a4 100644 --- a/backend/app/services/pl_quota_cert_service.py +++ b/backend/app/services/pl_quota_cert_service.py @@ -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 diff --git a/backend/app/services/pl_quota_contract_service.py b/backend/app/services/pl_quota_contract_service.py index 98e7622ab..5f790d13b 100644 --- a/backend/app/services/pl_quota_contract_service.py +++ b/backend/app/services/pl_quota_contract_service.py @@ -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')) diff --git a/backend/tests/test_pl_quota_cert_archive.py b/backend/tests/test_pl_quota_cert_archive.py index 14e7cc163..df978a509 100644 --- a/backend/tests/test_pl_quota_cert_archive.py +++ b/backend/tests/test_pl_quota_cert_archive.py @@ -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 diff --git a/backend/tests/test_pl_quota_cert_bindings.py b/backend/tests/test_pl_quota_cert_bindings.py index 7ed78e93a..d5f8c7e05 100644 --- a/backend/tests/test_pl_quota_cert_bindings.py +++ b/backend/tests/test_pl_quota_cert_bindings.py @@ -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'] diff --git a/openspec/changes/quota-cert-inline-bindings/design.md b/openspec/changes/quota-cert-inline-bindings/design.md new file mode 100644 index 000000000..e434e9975 --- /dev/null +++ b/openspec/changes/quota-cert-inline-bindings/design.md @@ -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、合并及关单,完成可授权范围后保留交付待办。 diff --git a/openspec/changes/quota-cert-inline-bindings/proposal.md b/openspec/changes/quota-cert-inline-bindings/proposal.md new file mode 100644 index 000000000..0d1f49a85 --- /dev/null +++ b/openspec/changes/quota-cert-inline-bindings/proposal.md @@ -0,0 +1,3 @@ +# 工单 #10:计划用证行内绑定与关联保护 + +仅修改 Web 计划用证弹窗和共享后端保护,用户于 2026-11-02 明确确认。新增能力 `quota-cert-inline-bindings`:统一证面引用和合同产品的添加行入口,阻止已关联行的业务身份调整。无数据库结构变更,不调整 H5 / 小程序界面。 diff --git a/openspec/changes/quota-cert-inline-bindings/specs/quota-cert-inline-bindings/spec.md b/openspec/changes/quota-cert-inline-bindings/specs/quota-cert-inline-bindings/spec.md new file mode 100644 index 000000000..9ae880859 --- /dev/null +++ b/openspec/changes/quota-cert-inline-bindings/specs/quota-cert-inline-bindings/spec.md @@ -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 保持。 diff --git a/openspec/changes/quota-cert-inline-bindings/tasks.md b/openspec/changes/quota-cert-inline-bindings/tasks.md new file mode 100644 index 000000000..ad68d9a99 --- /dev/null +++ b/openspec/changes/quota-cert-inline-bindings/tasks.md @@ -0,0 +1,8 @@ +# 实施与验证 +- [x] 完整读取工单、确认 Web+后端范围并批准 Figma v1。 +- [ ] 在 test_pl_quota_cert_bindings.py 补充关联锁定、产品集合变更拒绝、租户/软删除隔离测试,先观察失败。 +- [ ] 在 quota-cert-form.test.ts 补充新增空行、切换合同、锁定行和保存校验测试,先观察失败。 +- [ ] 修改证件与合同服务的只读状态和保存保护,保持原事务、ID 与数据权限。 +- [ ] 修改 quota-cert/index.vue 与 API 类型;复用原样式和 payload 白名单。 +- [ ] 执行相关 pytest、前端脚本、lint 和构建;完成实际视觉验证及审查。 +- [ ] 在授权内交付;PR、合并、关单需另有授权,不提前记完成。 diff --git a/web-admin/scripts/quota-cert-form.test.ts b/web-admin/scripts/quota-cert-form.test.ts index db3501e8c..2a061ea41 100644 --- a/web-admin/scripts/quota-cert-form.test.ts +++ b/web-admin/scripts/quota-cert-form.test.ts @@ -512,3 +512,57 @@ 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() + } +}) diff --git a/web-admin/src/api/pl/quota-cert.ts b/web-admin/src/api/pl/quota-cert.ts index 875ba8dc3..640174519 100644 --- a/web-admin/src/api/pl/quota-cert.ts +++ b/web-admin/src/api/pl/quota-cert.ts @@ -51,6 +51,7 @@ export interface QuotaCert extends Omit { 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' diff --git a/web-admin/src/views/pl/quota-cert/index.vue b/web-admin/src/views/pl/quota-cert/index.vue index 8380d31bb..5271bd3d3 100644 --- a/web-admin/src/views/pl/quota-cert/index.vue +++ b/web-admin/src/views/pl/quota-cert/index.vue @@ -220,10 +220,23 @@ @change="edited.add('vehicleNumbersJson')" />

证面引用

+ 添加引用 +

已有用证关联,不能编辑或移除证面引用,请联系管理员。