fix(pl): 证用品类差量复用软删关联,并修正归档用证结转与证面品类展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-11-15 18:52:33 +08:00
1 parent 6f20c0ff8a
commit f865d8ed99
19 files changed
+445 -65

No files matched your search

@@ -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()
@@ -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'}:
+29 -2
View File
@@ -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
+11 -1
View File
@@ -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:
@@ -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
@@ -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
@@ -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
+46
View File
@@ -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
+1 -1
View File
@@ -9,5 +9,5 @@ allowBuilds:
core-js: false
es5-ext: false
esbuild: true
protobufjs: false
protobufjs: true
vue-demi: true
@@ -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'
)
})
@@ -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 建档页使用关联购买证且无证面引用残留', () => {
+2
View File
@@ -65,7 +65,9 @@ export interface QuotaCert extends Omit<CertBody, 'status'> {
createTime?: string
certType?: CertType
derivedCategoryIds?: string[]
derivedCategoryNames?: string[]
customerName?: string | null
categoryName?: string | null
}
export interface ScopeProduct {
id?: string
@@ -1604,36 +1604,17 @@
async function showArchiveQuotaBlockDialog(message: string, title = '不能归档') {
const html = `<p class="pl-arrange-block-text">${escapeArchiveBlockText(message)}</p>`
const mode = resolveArchiveQuotaBlockMode(message, title, plan.value)
if (mode !== 'confirm-ready') {
const guide =
mode === 'alert-pending'
? `${html}<p class="pl-arrange-block-text">该运输计划已走证书齐备,归档前须完善证件。请到「证书齐备 → 已标记待完善证书」上传购买证/运输证并执行完善并关闭后再归档。</p>`
: 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}<p class="pl-arrange-block-text">如果购买证、运输证还没上传,可以标记证书齐备。仅旧规则计划标记后这次归档不再校验、不扣这两证额度;新规则计划请走「证书齐备 → 已标记待完善证书」。</p>`
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}<p class="pl-arrange-block-text">该运输计划已走证书齐备,归档前须完善证件。请到「证书齐备 → 已标记待完善证书」上传购买证/运输证并执行完善并关闭后再归档。</p>`
: html
await ElMessageBox.alert(guide, title, {
type: 'warning',
confirmButtonText: '知道了',
dangerouslyUseHTMLString: true,
customClass: 'pl-arrange-block-msgbox',
closeOnClickModal: false
}).catch(() => undefined)
}
function applyArchiveOverageHighlight(items: Array<Record<string, unknown>>) {
@@ -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
@@ -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'
}
@@ -25,6 +25,12 @@
}}</span>
</div>
<div class="mp-wv-card__kv">
<div class="mp-wv-card__kv-row"
><span>系统客户</span><span>{{ row.customerName || '—' }}</span></div
>
<div class="mp-wv-card__kv-row"
><span>证用品类</span><span>{{ certCategoryLabel(row) }}</span></div
>
<div class="mp-wv-card__kv-row"
><span>销售单位</span><span>{{ salePartyName(row) || '—' }}</span></div
>
@@ -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
@@ -1,5 +1,5 @@
<template>
<MpWebviewShell @ready="ready = true">
<MpWebviewShell @ready="onShellReady">
<MpBusinessHeader title="客户用证" back-label="返回运输证件管理" @back="router.back()" />
<main v-if="ready" class="mp-h5-list-content mp-h5-detail-page mp-plan-cert-list">
<p v-if="error" class="mp-wv-empty" role="alert">
@@ -34,11 +34,19 @@
import { useRoute, useRouter } from 'vue-router'
import { fetchGetMenuCodes } from '@/api/auth'
import { quotaStatisticsApi, type CustomerStatistics } from '@/api/pl/quota-cert'
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
import CustomerCertQuotaTab from '@/views/m/crm/contract/CustomerCertQuotaTab.vue'
import '@/styles/mp-webview.scss'
import './cert-mobile.scss'
const route = useRoute()
const router = useRouter()
const ready = ref(false)
function onShellReady() {
ready.value = true
}
const authReady = ref(false)
const allowed = ref(false)
const loading = ref(true)
@@ -30,6 +30,22 @@ const longFaceKeys = new Set([
export type FaceField = { label: string; value: string; wide?: boolean }
export type FaceGroup = { title: string; fields: FaceField[] }
export function certCategoryLabel(
cert: Pick<QuotaCert, 'certType' | 'categoryName' | 'derivedCategoryNames'> & {
certType?: CertType | null
},
kind?: CertType
) {
const certKind = kind || cert.certType
if (certKind === 'transport') {
const names = (cert.derivedCategoryNames || [])
.map((item) => String(item || '').trim())
.filter(Boolean)
return names.join('、') || '—'
}
return String(cert.categoryName || '').trim() || '—'
}
export function certFaceGroups(
cert: QuotaCert,
kind: CertType,
@@ -51,6 +67,11 @@ export function certFaceGroups(
label: '系统客户',
value: String(cert.customerName || '').trim() || '—',
wide: true
},
{
label: '证用品类',
value: certCategoryLabel(cert, kind),
wide: true
}
)
}
+1 -1
View File
@@ -361,7 +361,7 @@
]
const placeholder = computed(() =>
tab.value === 'contract'
? '合同编号 / 名称 / 销售或购买单位'
? '客户名称 / 编号'
: tab.value === 'plan' || tab.value === 'ready'
? '计划编号 / 供方 / 需方'
: '证件编号 / 品名 / 销售或购买单位'
@@ -1656,7 +1656,7 @@ async function handleArchive(row: Plan) {
ElMessage.warning('无运输计划归档操作权限')
return
}
// 打开归档弹窗:已归档走调整;未归档/已暂存可直接打开(证书齐备仅在「确认归档」时校验,以便支持暂存)
// 打开归档弹窗:已归档走调整;未归档/已暂存可直接打开(用证校验在「确认归档」时执行,以便支持暂存)
archivePlanId.value = row.id
archiveDialogVisible.value = true
}