fix(pl): 证用品类差量复用软删关联,并修正归档用证结转与证面品类展示。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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'}:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 建档页使用关联购买证且无证面引用残留', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user