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..5f9b81846 --- /dev/null +++ b/openspec/changes/quota-cert-inline-bindings/tasks.md @@ -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错误,锁定按钮禁用,提示可换行。该验证不等同于登录业务环境端到端或集成环境验收。 +- 未执行:真实业务环境联调、集成分支冒烟;未启停既有服务,未修改业务数据库。 + +- 独立审查发现图片识别可填入只读空引用;已用页面真实识别流程复现失败,并修复识别合并时跳过锁定引用,新增回归通过。 diff --git a/web-admin/scripts/quota-cert-form.test.ts b/web-admin/scripts/quota-cert-form.test.ts index db3501e8c..37ac91567 100644 --- a/web-admin/scripts/quota-cert-form.test.ts +++ b/web-admin/scripts/quota-cert-form.test.ts @@ -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() + } +}) 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..0b7493ef4 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')" />

证面引用

+ 添加引用 +

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