diff --git a/backend/app/services/pl_cert_category_service.py b/backend/app/services/pl_cert_category_service.py index 3d143220f..454290cf4 100644 --- a/backend/app/services/pl_cert_category_service.py +++ b/backend/app/services/pl_cert_category_service.py @@ -127,12 +127,16 @@ def save_category(db: Session, user, data: dict, category_id=None): submitted = data['products'] if submitted is None: raise HTTPException(400, '产品关联集合不可为 null;清空请提交 []') - existing = { - r.id: r - for r in db.query(PlCertCategoryProduct).filter_by( - tenant_no=user.tenant_no, category_id=row.id, del_flag=0, - ).with_for_update().populate_existing().all() - } + # uk_cert_category_product 不含 del_flag,软删行仍占唯一键;无 id 再加须复用。 + all_links = ( + db.query(PlCertCategoryProduct) + .filter_by(tenant_no=user.tenant_no, category_id=row.id) + .with_for_update() + .populate_existing() + .all() + ) + existing = {r.id: r for r in all_links if r.del_flag == 0} + by_product = {r.product_id: r for r in all_links} seen = set() product_ids = set() for item in submitted: @@ -150,20 +154,46 @@ def save_category(db: Session, user, data: dict, category_id=None): raise HTTPException(400, '产品关联明细不属于当前品类') if rid in seen: raise HTTPException(400, '产品关联明细 ID 重复') + if pid != link.product_id: + conflict = by_product.get(pid) + if conflict is not None and conflict.id != link.id: + if conflict.del_flag == 0: + raise HTTPException(400, '同一品类不能重复关联同一产品') + # 目标 product_id 被软删行占用:恢复该行并软删当前行 + conflict.del_flag = 0 + conflict.scope_status = scope + conflict.update_by = user.id + link.del_flag = 1 + link.update_by = user.id + seen.add(conflict.id) + continue + if by_product.get(link.product_id) is link: + del by_product[link.product_id] + link.product_id = pid + by_product[pid] = link seen.add(rid) - link.product_id = pid link.scope_status = scope link.update_by = user.id else: - link = PlCertCategoryProduct( - **_audit(user), - category_id=row.id, - product_id=pid, - scope_status=scope, - ) - db.add(link) + # 关联表业务键 (category_id, product_id):复用有效或软删行,避免 uk 冲突 + link = by_product.get(pid) + if link is not None: + if link.del_flag == 0: + seen.add(link.id) + link.del_flag = 0 + link.scope_status = scope + link.update_by = user.id + else: + link = PlCertCategoryProduct( + **_audit(user), + category_id=row.id, + product_id=pid, + scope_status=scope, + ) + db.add(link) + by_product[pid] = link for rid, link in existing.items(): - if rid not in seen: + if rid not in seen and link.del_flag == 0: link.del_flag = 1 link.update_by = user.id db.flush() diff --git a/backend/app/services/pl_quota_allocation.py b/backend/app/services/pl_quota_allocation.py index 1300857b7..fa3f0c26d 100644 --- a/backend/app/services/pl_quota_allocation.py +++ b/backend/app/services/pl_quota_allocation.py @@ -337,7 +337,11 @@ def settle_allocations(reserved: Sequence[Allocation], actual_by_source: Mapping 异单位来源须逐行重新确认全部实际量(含零量行),不按预占比例推算。 超来源量或超任一证件预占量均要求先补充分配。 + 同市等仅购买证场景下,调用方仍可能对本函数传入空的运输证分组与全量实运量; + 无预占行时直接空结转,不把「缺实际量」套到另一类证件上。 """ + if not reserved: + return () keys, seqs, grouped = set(), set(), {} kinds = {r.permit_kind for r in reserved} if len(kinds) > 1 or kinds - {'purchase', 'transport'}: diff --git a/backend/app/services/pl_quota_cert_service.py b/backend/app/services/pl_quota_cert_service.py index dc31789f8..c00ed95a5 100644 --- a/backend/app/services/pl_quota_cert_service.py +++ b/backend/app/services/pl_quota_cert_service.py @@ -136,6 +136,26 @@ def _customer_display_name(db, user, customer_id): return (row[0] if row else None) or None +def _category_display_names(db, user, category_ids): + """按证用品类 ID 顺序返回启用中的品类名称;未知 ID 跳过。""" + from app.models.pl_quota_models import PlCertCategory + + ids = [str(item or '').strip() for item in (category_ids or []) if str(item or '').strip()] + if not ids: + return [] + rows = ( + db.query(PlCertCategory.id, PlCertCategory.category_name) + .filter( + PlCertCategory.tenant_no == user.tenant_no, + PlCertCategory.del_flag == 0, + PlCertCategory.id.in_(ids), + ) + .all() + ) + names = {row.id: (row.category_name or '').strip() for row in rows} + return [names[item] for item in ids if names.get(item)] + + def get_cert(db, user, cert_type, cert_id, current=False): row = _get_row(db, user, cert_type, cert_id, lock=current) # 只读列表/详情不得在未提交事务里改状态,否则会出现「页面已启用、库仍 draft」。 @@ -151,12 +171,17 @@ def get_cert(db, user, cert_type, cert_id, current=False): result['availableQty'] = row.total_qty - row.used_qty - row.reserved_qty result['singleUseConsumed'] = is_single_use_consumed(db, user, cert_type, row) result['customerName'] = _customer_display_name(db, user, getattr(row, 'customer_id', None)) + if cert_type == 'purchase': + names = _category_display_names(db, user, [getattr(row, 'category_id', None)]) + result['categoryName'] = names[0] if names else None if cert_type == 'transport': result['purchaseRefs'] = [_row_to_dict(r) for r in _refs(db, user, cert_id, lock=current)] result['referencesEditable'] = not _has_usage_beyond_zero_opening( db, user, cert_type, cert_id, lock=current ) - result['derivedCategoryIds'] = _transport_derived_categories(db, user, cert_id) + derived_ids = _transport_derived_categories(db, user, cert_id) + result['derivedCategoryIds'] = derived_ids + result['derivedCategoryNames'] = _category_display_names(db, user, derived_ids) return result @@ -182,7 +207,9 @@ def list_certs(db, user, cert_type, keyword=None, status=None, customer_id=None) result = [get_cert(db, user, cert_type, r.id) for r in query.order_by(model.create_time.desc()).all()] if cert_type == 'transport': for item in result: - item['derivedCategoryIds'] = _transport_derived_categories(db, user, item['id']) + derived_ids = _transport_derived_categories(db, user, item['id']) + item['derivedCategoryIds'] = derived_ids + item['derivedCategoryNames'] = _category_display_names(db, user, derived_ids) return result diff --git a/backend/app/services/pl_quota_usage_service.py b/backend/app/services/pl_quota_usage_service.py index c34914521..3fdcce20f 100644 --- a/backend/app/services/pl_quota_usage_service.py +++ b/backend/app/services/pl_quota_usage_service.py @@ -675,7 +675,17 @@ def _settle(db,user,r,certs,usages,items,plan): for kind,rows in settled.items(): for row in rows: base_totals[(kind,row.allocation.permit_id)]+=row.actual_permit_quantity credits={k:SimpleNamespace(reserved_qty=u.reserved_qty+u.used_qty-base_totals[k]) for k,u in usages.items()} - extra_request=r.model_copy(update={'sources':[source.model_copy(update={'quantity':excess[source.source_id]}) for source in r.sources if source.source_id in excess]}) + # 超额补扣需求仅为超出预占的部分。安排/完善并关闭写入的 manual_allocations + # 是整笔计划量,若原样带入会与超额需求冲突(证件明明够却报额度不足)。 + extra_update = { + 'sources': [ + source.model_copy(update={'quantity': excess[source.source_id]}) + for source in r.sources if source.source_id in excess + ], + } + if auto_cover: + extra_update['manual_allocations'] = [] + extra_request = r.model_copy(update=extra_update) try: result=_reserve(db,user,extra_request,plan,certs,credits,{},selected,preview=True,lock_preview=True) except InsufficientQuota as exc: diff --git a/backend/tests/test_pl_cert_category_differential_save.py b/backend/tests/test_pl_cert_category_differential_save.py new file mode 100644 index 000000000..beb472640 --- /dev/null +++ b/backend/tests/test_pl_cert_category_differential_save.py @@ -0,0 +1,117 @@ +"""证用品类产品关联差量保存:软删后再加回须复用唯一键行。""" +from datetime import datetime +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from app.models.pl_quota_models import PlCertCategory, PlCertCategoryProduct +from app.services import pl_cert_category_service as svc + + +@pytest.fixture +def db(monkeypatch): + engine = create_engine('sqlite://') + PlCertCategory.__table__.create(engine) + PlCertCategoryProduct.__table__.create(engine) + monkeypatch.setattr(svc, '_product_labels', lambda *a, **k: {}) + with Session(engine) as session: + yield session + engine.dispose() + + +def user(tenant='t', uid='editor'): + return SimpleNamespace(id=uid, tenant_no=tenant, dept_id='dept') + + +def seed_category(db, id='cat', **extra): + values = dict( + id=id, category_name='硫酸', category_code='H2SO4', status='1', + tenant_no='t', create_by='creator', create_dept_id='dept', + create_time=datetime(2020, 1, 1), del_flag=0, + ) + values.update(extra) + row = PlCertCategory(**values) + db.add(row) + db.flush() + return row + + +def seed_link(db, id='link', product_id='339E64E02E7C41F3', del_flag=0, **extra): + values = dict( + id=id, category_id='cat', product_id=product_id, scope_status='active', + tenant_no='t', create_by='creator', create_dept_id='dept', + create_time=datetime(2020, 1, 1), del_flag=del_flag, + ) + values.update(extra) + row = PlCertCategoryProduct(**values) + db.add(row) + db.flush() + return row + + +def test_readd_soft_deleted_product_reuses_unique_key_row(db): + seed_category(db) + link = seed_link(db, del_flag=1) + result = svc.save_category( + db, user(), + {'categoryName': '硫酸', 'products': [{'productId': link.product_id}]}, + category_id='cat', + ) + db.flush() + assert db.query(PlCertCategoryProduct).count() == 1 + assert link.del_flag == 0 + assert link.create_by == 'creator' + assert link.create_time == datetime(2020, 1, 1) + assert link.update_by == 'editor' + assert result['products'][0]['id'] == link.id + assert result['products'][0]['productId'] == link.product_id + + +def test_remove_then_readd_without_id_keeps_one_row(db): + seed_category(db) + link = seed_link(db) + svc.save_category( + db, user(), + {'categoryName': '硫酸', 'products': []}, + category_id='cat', + ) + assert link.del_flag == 1 + svc.save_category( + db, user(), + {'categoryName': '硫酸', 'products': [{'productId': link.product_id, 'scopeStatus': 'active'}]}, + category_id='cat', + ) + db.flush() + assert db.query(PlCertCategoryProduct).count() == 1 + assert link.del_flag == 0 + assert link.scope_status == 'active' + + +def test_no_id_reuses_active_row_by_product_id(db): + seed_category(db) + link = seed_link(db) + svc.save_category( + db, user(), + {'categoryName': '硫酸', 'products': [{'productId': link.product_id}]}, + category_id='cat', + ) + db.flush() + assert db.query(PlCertCategoryProduct).filter_by(del_flag=0).count() == 1 + assert link.del_flag == 0 + assert link.id == 'link' + + +def test_deleted_explicit_id_is_rejected(db): + seed_category(db) + link = seed_link(db, del_flag=1) + with pytest.raises(HTTPException) as exc: + svc.save_category( + db, user(), + {'categoryName': '硫酸', 'products': [{'id': link.id, 'productId': link.product_id}]}, + category_id='cat', + ) + assert exc.value.status_code == 400 + assert link.del_flag == 1 diff --git a/backend/tests/test_pl_customer_cert_quota.py b/backend/tests/test_pl_customer_cert_quota.py index b45a51336..4234c8b3b 100644 --- a/backend/tests/test_pl_customer_cert_quota.py +++ b/backend/tests/test_pl_customer_cert_quota.py @@ -754,6 +754,100 @@ def test_get_cert_customer_name_from_crm_not_consignee(monkeypatch): assert result['customerId'] == 'cust-kanghua' +def test_get_cert_purchase_category_name(monkeypatch): + """购买证详情回填证用品类名称,便于 H5/Web 只读展示。""" + from app.services import pl_quota_cert_service as svc + + class Col: + def __init__(self, name): + self.name = name + + row = SimpleNamespace( + id='p1', + cert_no='G1', + customer_id='cust1', + category_id='cat-hcl', + buyer_name='安庆市山峡化工有限公司', + valid_use_text='多次有效', + total_qty=Decimal('100'), + used_qty=Decimal('0'), + reserved_qty=Decimal('0'), + __table__=SimpleNamespace( + columns=[ + Col('id'), + Col('cert_no'), + Col('customer_id'), + Col('category_id'), + Col('buyer_name'), + Col('valid_use_text'), + Col('total_qty'), + Col('used_qty'), + Col('reserved_qty'), + ] + ), + ) + monkeypatch.setattr(svc, '_get_row', lambda *a, **k: row) + monkeypatch.setattr(svc, 'is_single_use_consumed', lambda *a, **k: False) + monkeypatch.setattr(svc, '_customer_display_name', lambda *a, **k: '甲客户') + monkeypatch.setattr(svc, '_category_display_names', lambda *a, **k: ['盐酸']) + + opening = MagicMock() + opening.filter_by.return_value.first.return_value = None + db = MagicMock() + db.query.return_value = opening + + result = svc.get_cert(db, SimpleNamespace(tenant_no='t'), 'purchase', 'p1') + assert result['customerName'] == '甲客户' + assert result['categoryName'] == '盐酸' + assert result['categoryId'] == 'cat-hcl' + + +def test_get_cert_transport_derived_category_names(monkeypatch): + """运输证详情回填引用购买证品类名称并集。""" + from app.services import pl_quota_cert_service as svc + + class Col: + def __init__(self, name): + self.name = name + + row = SimpleNamespace( + id='t1', + cert_no='Y1', + customer_id='cust1', + valid_use_text='多次有效', + total_qty=Decimal('10'), + used_qty=Decimal('0'), + reserved_qty=Decimal('0'), + __table__=SimpleNamespace( + columns=[ + Col('id'), + Col('cert_no'), + Col('customer_id'), + Col('valid_use_text'), + Col('total_qty'), + Col('used_qty'), + Col('reserved_qty'), + ] + ), + ) + monkeypatch.setattr(svc, '_get_row', lambda *a, **k: row) + monkeypatch.setattr(svc, 'is_single_use_consumed', lambda *a, **k: False) + monkeypatch.setattr(svc, '_customer_display_name', lambda *a, **k: '甲客户') + monkeypatch.setattr(svc, '_refs', lambda *a, **k: []) + monkeypatch.setattr(svc, '_has_usage_beyond_zero_opening', lambda *a, **k: False) + monkeypatch.setattr(svc, '_transport_derived_categories', lambda *a, **k: ['c1', 'c2']) + monkeypatch.setattr(svc, '_category_display_names', lambda *a, **k: ['硫酸', '盐酸']) + + opening = MagicMock() + opening.filter_by.return_value.first.return_value = None + db = MagicMock() + db.query.return_value = opening + + result = svc.get_cert(db, SimpleNamespace(tenant_no='t'), 'transport', 't1') + assert result['derivedCategoryIds'] == ['c1', 'c2'] + assert result['derivedCategoryNames'] == ['硫酸', '盐酸'] + + def _remote_source(**kw): from app.schemas.pl.quota_usage import UsageSource diff --git a/backend/tests/test_pl_quota_allocation.py b/backend/tests/test_pl_quota_allocation.py index 89b8c307a..e7703847e 100644 --- a/backend/tests/test_pl_quota_allocation.py +++ b/backend/tests/test_pl_quota_allocation.py @@ -249,6 +249,11 @@ def test_same_city_settlement_does_not_require_transport_pairing(): assert validate_allocation_pairing([demand], [purchase], [], []) is None +def test_empty_transport_kind_settle_ignores_source_actuals(): + """同市只结转购买证时,空的运输证分组对实运量应直接空结果,不报缺实际量。""" + assert settle_allocations([], {'city': D(10)}) == () + + @pytest.mark.parametrize('manual_values', [(8,10,0),(18,0,0)]) def test_zero_capacity_foreign_row_cannot_unlock_manual_reordering(manual_values): from app.services.pl_quota_allocation import Allocation diff --git a/backend/tests/test_pl_quota_usage.py b/backend/tests/test_pl_quota_usage.py index 2debf3777..99b547543 100644 --- a/backend/tests/test_pl_quota_usage.py +++ b/backend/tests/test_pl_quota_usage.py @@ -98,6 +98,31 @@ def test_zero_reservation_consumes_once_slot(case): assert c.db.query(c.m.PlPlanCertUsage).count()==2 with pytest.raises(ValueError): run(c,operation_id='other',plan_id='other',sources=sources) +def test_same_city_purchase_only_settle_without_transport(case): + """同市段只预占购买证时,归档结转不得因空运输证分组误报缺实际量。""" + c = case + sources = [dict( + source_stop_type='delivery', source_stop_id='stop', source_stop_product_id='sp', + plan_product_id='pp', contract_id='c', contract_product_id='cp', product_id='product', + quantity='1', unit='吨', fingerprint='fp', requires_transport=False, + )] + run(c, purchase_cert_ids=['p'], transport_cert_ids=[], sources=sources) + assert c.db.query(c.m.PlPlanCertUsage).count() == 1 + assert c.db.get(c.m.PlPurchaseCert, 'p').reserved_qty == 1 + run( + c, + operation_id='settle-same-city', + action='settle', + purchase_cert_ids=['p'], + transport_cert_ids=[], + sources=sources, + actual_by_source={'delivery:sp': '1'}, + ) + assert c.db.get(c.m.PlPurchaseCert, 'p').used_qty == 1 + assert c.db.get(c.m.PlPurchaseCert, 'p').reserved_qty == 0 + assert c.db.query(c.m.PlTransportCert).filter_by(id='t').one().used_qty == 0 + + def test_same_cert_balance_covers_actual_above_reservation(case): c = case run(c) @@ -108,6 +133,27 @@ def test_same_cert_balance_covers_actual_above_reservation(case): assert c.db.query(c.m.PlPlanCertUsage).count() == 2 +def test_auto_cover_ignores_arrangement_manuals_when_actual_exceeds_reservation(case): + """安排/完善并关闭写入的整笔 manual_allocations 不得挡住归档超额补扣。""" + c = case + manuals = [ + dict(permit_kind='purchase', source_id='load:sp', permit_id='p', source_quantity='29', permit_quantity='29'), + dict(permit_kind='transport', source_id='load:sp', permit_id='t', source_quantity='29', permit_quantity='29'), + ] + run(c, manual_allocations=manuals) + # 归档请求仍带着计划量人工行(与完善并关闭后 cert_configuration 回灌一致) + run( + c, + operation_id='settle-with-stale-manuals', + action='settle', + actual_by_source={'load:sp': '35'}, + manual_allocations=manuals, + ) + assert c.db.get(c.m.PlPurchaseCert, 'p').used_qty == 35 + assert c.db.get(c.m.PlPurchaseCert, 'p').reserved_qty == 0 + assert c.db.get(c.m.PlTransportCert, 't').used_qty == 35 + + def test_same_cert_without_enough_balance_asks_for_another_certificate(case): c = case c.db.get(c.m.PlPurchaseCert, 'p').total_qty = 30 diff --git a/web-admin/pnpm-workspace.yaml b/web-admin/pnpm-workspace.yaml index 18ab3382d..3da2c5f5b 100644 --- a/web-admin/pnpm-workspace.yaml +++ b/web-admin/pnpm-workspace.yaml @@ -9,5 +9,5 @@ allowBuilds: core-js: false es5-ext: false esbuild: true - protobufjs: false + protobufjs: true vue-demi: true diff --git a/web-admin/scripts/archive-quota-block-dialog.test.ts b/web-admin/scripts/archive-quota-block-dialog.test.ts index c2d50f1ad..88bcb6a4f 100644 --- a/web-admin/scripts/archive-quota-block-dialog.test.ts +++ b/web-admin/scripts/archive-quota-block-dialog.test.ts @@ -31,17 +31,24 @@ test('pending keywords still force alert mode without plan flags', () => { ) }) -test('unmarked legacy quota block still offers certificate ready', () => { +test('unmarked quota block only alerts, never offers certificate ready', () => { assert.equal( resolveArchiveQuotaBlockMode( '实际数量已保存,证件没有扣减。请把实际数量改小,或先补充购买证和运输证,再重新归档。', '用证没有完成', { quotaCertPendingFulfill: 0, quotaCertWaived: 0 } ), - 'confirm-ready' + 'alert-plain' ) }) test('non quota-title blocks stay plain alert', () => { assert.equal(resolveArchiveQuotaBlockMode('不能归档:缺证', '不能归档', {}), 'alert-plain') }) + +test('missing actual-quantity settle error does not offer certificate ready', () => { + assert.equal( + resolveArchiveQuotaBlockMode('必须明确提交每个来源的实际量', '用证没有完成', {}), + 'alert-plain' + ) +}) diff --git a/web-admin/scripts/mobile-cert-ready-h5.test.ts b/web-admin/scripts/mobile-cert-ready-h5.test.ts index 86f7e25b1..0adad11e6 100644 --- a/web-admin/scripts/mobile-cert-ready-h5.test.ts +++ b/web-admin/scripts/mobile-cert-ready-h5.test.ts @@ -39,6 +39,34 @@ test('H5 客户用证按客户列表展示证件', () => { assert.match(contractTab, /\/m\/pl\/plan-cert\/customer\//) assert.match(contractTab, /暂无客户用证数据/) assert.match(planCertIndex, /title: '客户用证'/) + assert.match(planCertIndex, /客户名称 \/ 编号/) + assert.doesNotMatch(planCertIndex, /合同编号 \/ 名称 \/ 销售或购买单位/) +}) + +test('H5 客户用证详情显式导入 WebView 壳,避免空白页', () => { + const customerDetail = readFileSync( + new URL('../src/views/m/pl/plan-cert/customer-detail.vue', import.meta.url), + 'utf8' + ) + assert.match(customerDetail, /import MpWebviewShell from '@\/layouts\/MpWebviewShell\.vue'/) + assert.match(customerDetail, /import MpBusinessHeader from '@\/components\/mp\/MpBusinessHeader\.vue'/) + assert.match(customerDetail, /@ready="onShellReady"/) +}) + +test('H5 证件详情与建档列表展示系统客户和证用品类', () => { + const faceModel = readFileSync( + new URL('../src/views/m/pl/plan-cert/face-model.ts', import.meta.url), + 'utf8' + ) + const archiveTab = readFileSync( + new URL('../src/views/m/pl/plan-cert/ArchiveTab.vue', import.meta.url), + 'utf8' + ) + assert.match(faceModel, /label: '证用品类'/) + assert.match(faceModel, /certCategoryLabel/) + assert.match(archiveTab, /系统客户/) + assert.match(archiveTab, /证用品类/) + assert.match(archiveTab, /certCategoryLabel/) }) test('H5 建档页使用关联购买证且无证面引用残留', () => { diff --git a/web-admin/src/api/pl/quota-cert.ts b/web-admin/src/api/pl/quota-cert.ts index 96b241b49..2f4bad88c 100644 --- a/web-admin/src/api/pl/quota-cert.ts +++ b/web-admin/src/api/pl/quota-cert.ts @@ -65,7 +65,9 @@ export interface QuotaCert extends Omit { createTime?: string certType?: CertType derivedCategoryIds?: string[] + derivedCategoryNames?: string[] customerName?: string | null + categoryName?: string | null } export interface ScopeProduct { id?: string diff --git a/web-admin/src/components/business/pl-plan-archive-dialog/index.vue b/web-admin/src/components/business/pl-plan-archive-dialog/index.vue index 948d410e5..a44aff631 100644 --- a/web-admin/src/components/business/pl-plan-archive-dialog/index.vue +++ b/web-admin/src/components/business/pl-plan-archive-dialog/index.vue @@ -1604,36 +1604,17 @@ async function showArchiveQuotaBlockDialog(message: string, title = '不能归档') { const html = `

${escapeArchiveBlockText(message)}

` const mode = resolveArchiveQuotaBlockMode(message, title, plan.value) - if (mode !== 'confirm-ready') { - const guide = - mode === 'alert-pending' - ? `${html}

该运输计划已走证书齐备,归档前须完善证件。请到「证书齐备 → 已标记待完善证书」上传购买证/运输证并执行完善并关闭后再归档。

` - : html - await ElMessageBox.alert(guide, title, { - type: 'warning', - confirmButtonText: '知道了', - dangerouslyUseHTMLString: true, - customClass: 'pl-arrange-block-msgbox', - closeOnClickModal: false - }).catch(() => undefined) - return undefined - } - // 仅旧规则(legacy)允许在归档时标记证书齐备以跳过额度 - const readyHtml = `${html}

如果购买证、运输证还没上传,可以标记证书齐备。仅旧规则计划标记后这次归档不再校验、不扣这两证额度;新规则计划请走「证书齐备 → 已标记待完善证书」。

` - try { - await ElMessageBox.confirm(readyHtml, title, { - type: 'warning', - confirmButtonText: '证书齐备', - cancelButtonText: '知道了', - dangerouslyUseHTMLString: true, - customClass: 'pl-arrange-block-msgbox', - closeOnClickModal: false, - distinguishCancelAndClose: true - }) - return 'ready' as const - } catch { - return undefined - } + const guide = + mode === 'alert-pending' + ? `${html}

该运输计划已走证书齐备,归档前须完善证件。请到「证书齐备 → 已标记待完善证书」上传购买证/运输证并执行完善并关闭后再归档。

` + : html + await ElMessageBox.alert(guide, title, { + type: 'warning', + confirmButtonText: '知道了', + dangerouslyUseHTMLString: true, + customClass: 'pl-arrange-block-msgbox', + closeOnClickModal: false + }).catch(() => undefined) } function applyArchiveOverageHighlight(items: Array>) { @@ -1668,13 +1649,7 @@ // 业务异常已作为一次请求提交;补齐条件后允许新尝试,不回放旧异常。 quotaArchiveAttempt = { signature: '', id: '' } const message = saved.quotaSettlementError - const choice = await showArchiveQuotaBlockDialog(message, '用证没有完成') - if (choice === 'ready' && current()) { - await planApi.markQuotaCertReady(targetId) - if (!current()) return - await submitArchiveWithOverageConfirm(targetId, frozenBody, current, confirmOverage) - return - } + await showArchiveQuotaBlockDialog(message, '用证没有完成') const error = new Error(message) as Error & { cancelled?: boolean } error.cancelled = true throw error diff --git a/web-admin/src/utils/pl-archive-quota-block.ts b/web-admin/src/utils/pl-archive-quota-block.ts index 1ddbc2ff3..b31d88593 100644 --- a/web-admin/src/utils/pl-archive-quota-block.ts +++ b/web-admin/src/utils/pl-archive-quota-block.ts @@ -1,6 +1,6 @@ -/** 归档用证拦截弹窗:已齐备/待完善只提醒,未齐备旧规则才可再点证书齐备。 */ +/** 归档用证拦截弹窗:只提醒,不在归档界面提供「证书齐备」操作。 */ -export type ArchiveQuotaBlockMode = 'alert-pending' | 'alert-plain' | 'confirm-ready' +export type ArchiveQuotaBlockMode = 'alert-pending' | 'alert-plain' export type ArchiveQuotaBlockPlanFlags = { quotaCertPendingFulfill?: number | boolean | null @@ -23,5 +23,5 @@ export function resolveArchiveQuotaBlockMode( const pendingByPlan = flagOn(plan?.quotaCertPendingFulfill) || flagOn(plan?.quotaCertWaived) if (title !== '用证没有完成') return 'alert-plain' if (pendingByPlan || pendingByMessage) return 'alert-pending' - return 'confirm-ready' + return 'alert-plain' } diff --git a/web-admin/src/views/m/pl/plan-cert/ArchiveTab.vue b/web-admin/src/views/m/pl/plan-cert/ArchiveTab.vue index e05032341..137c5bea9 100644 --- a/web-admin/src/views/m/pl/plan-cert/ArchiveTab.vue +++ b/web-admin/src/views/m/pl/plan-cert/ArchiveTab.vue @@ -25,6 +25,12 @@ }}
+
系统客户{{ row.customerName || '—' }}
+
证用品类{{ certCategoryLabel(row) }}
销售单位{{ salePartyName(row) || '—' }}
@@ -119,7 +125,7 @@ } from '@/views/pl/quota-cert/cert-list-face' import { useDictStore } from '@/store/modules/dict' import { getProductUnitLabel } from '@/utils/crm-product-display' - import { certStatusLabels } from './face-model' + import { certCategoryLabel, certStatusLabels } from './face-model' const props = defineProps<{ keyword: string diff --git a/web-admin/src/views/m/pl/plan-cert/customer-detail.vue b/web-admin/src/views/m/pl/plan-cert/customer-detail.vue index 009091c15..a8b42d20f 100644 --- a/web-admin/src/views/m/pl/plan-cert/customer-detail.vue +++ b/web-admin/src/views/m/pl/plan-cert/customer-detail.vue @@ -1,5 +1,5 @@