Compare commits

...
Author SHA1 Message Date
wuzilongandCursor bf47a26ba0 fix(pl): 计划详情用关联证件附件替换危化品证书区。
Web 与 H5 详情将关联购买证/运输证移到装送货后并以附件卡片展示;用证接口回填证件附件 URL。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 14:46:31 +08:00
wuzilong d5f3a79ea5 fix(pl): 新规则安排不再被本地合同绑证缺口误拦。
H5 运输计划验收发现 customer_category 在服务端自动选证已通过时,前端仍用合同绑证空缺拦截安排;同步用证面板从占用回填证号并补专项单测。
2026-11-15 08:22:46 +08:00
wuzilong c5ae2d957c fix(pl): 对齐移动 H5 证书齐备 2.0 与合同用证提示。
H5 齐备页补齐待完善/完善并关闭,修正会误导归档可跳过额度的文案;合同用证增加新规则只读说明,并补相关单测。
2026-11-15 08:04:38 +08:00
wuzilong 9f855423d8 fix(pl): 客户品类规则预占不再依赖合同绑证 scope。
新规则自动选证后账本仍按合同绑证判定适用,导致预占报人工分配不适用;改为客户品类路径跳过 scope,明细合同绑证字段可空,并统一关联购买证文案。
2026-11-15 08:01:14 +08:00
wuzilongandCursor a6fc0aa0f3 fix(pl): 运输证解析回填客户,并将证面引用改名为关联购买证。
运输证按收货单位精确匹配系统客户;Web 与移动 H5 同步改名与回填逻辑。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 07:44:08 +08:00
32 changed files with 890 additions and 120 deletions

No files matched your search

+3 -3
View File
@@ -199,9 +199,9 @@ class PlPlanCertUsageItem(Base):
__tablename__ = "pl_plan_cert_usage_item"
id = Column(String(64), primary_key=True, comment='主键UUID')
usage_id = Column(String(64), nullable=False, comment='pl_plan_cert_usage.id')
contract_cert_link_id = Column(String(64), nullable=False, comment='合同与证件绑定ID,须与usage的证件相同')
contract_cert_product_id = Column(String(64), nullable=False, comment='本产品的适用范围明细ID:pl_contract_cert_product.id')
contract_scope_revision = Column(Integer, nullable=False, comment='本次确认的合同证件适用范围版本')
contract_cert_link_id = Column(String(64), nullable=True, comment='合同与证件绑定ID;客户品类规则可空')
contract_cert_product_id = Column(String(64), nullable=True, comment='合同证件产品范围明细ID;客户品类规则可空')
contract_scope_revision = Column(Integer, nullable=False, default=0, comment='合同证件适用范围版本;客户品类规则为0')
contract_id = Column(String(64), nullable=False, comment='本次履行的山峡合同ID')
contract_product_id = Column(String(64), nullable=False, comment='对应合同产品行ID')
plan_product_id = Column(String(64), nullable=False, comment='计划产品行ID')
@@ -129,7 +129,7 @@ def save_cert(db, user, cert_type, data, cert_id=None):
raise HTTPException(409, '证件合同范围已变更,请重试')
if cert_type == 'transport' and any(('purchase', pid) not in keys for pid in
_reference_ids(db, user, effective_id, refs, current=True)):
raise HTTPException(409, '证面引用已变更,请重试')
raise HTTPException(409, '关联购买证已变更,请重试')
prepared_face = certs.prepare_cert(db, user, cert_type, face, cert_id,
locked=locked, effective_id=effective_id)
# 用最终证面状态校验作废证不可扩大,不修改持久对象。
@@ -211,13 +211,13 @@ def _validate_refs(db, user, cert_id, submitted, historical, purchases, current=
if purchase_cert_id:
purchase = purchases.get(purchase_cert_id)
if purchase is None:
raise HTTPException(409, '证面引用已变更,请刷新后重新保存')
raise HTTPException(409, '关联购买证已变更,请刷新后重新保存')
if historical and _historical_ref_locked(previous, item):
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
@@ -241,7 +241,7 @@ def prepare_cert(db, user, cert_type, data, cert_id=None, *, locked=None, effect
purchases = {pid: (locked.get(('purchase', pid)) if locked is not None else
_get_row(db, user, 'purchase', pid, lock=True)) for pid in sorted(purchase_ids)}
if any(row is None for row in purchases.values()):
raise HTTPException(409, '证面引用已变更,请重试')
raise HTTPException(409, '关联购买证已变更,请重试')
row = (locked[(cert_type, cert_id)] if locked is not None else
_get_row(db, user, cert_type, cert_id, lock=True)) if cert_id else None
historical = bool(row and _has_history(db,user,cert_type,cert_id))
+1 -1
View File
@@ -89,7 +89,7 @@ def missing_purchase_link_message(demands, purchases, transports, cert_nos):
seen.add(key)
notices.append(
f'运输证 {move_text} 没有引用合同正在使用的购买证 {buy_text}。'
'请先打开该运输证,在「证面引用」的「对应购买证档案」中选择这张购买证,再安排运输'
'请先打开该运输证,在「关联购买证」中选择这张购买证档案,再安排运输'
)
return ';'.join(notices)
+6 -2
View File
@@ -351,7 +351,11 @@ def get_plan_quota(db, user, plan_id):
),
'usages': []}
for usage in usages:
_get_row(db, user, usage.cert_type, usage.cert_id)
cert = _get_row(db, user, usage.cert_type, usage.cert_id)
items = db.query(PlPlanCertUsageItem).filter_by(tenant_no=user.tenant_no, usage_id=usage.id, del_flag=0).order_by(PlPlanCertUsageItem.allocation_seq).all()
result['usages'].append({**_row_to_dict(usage), 'items': [_row_to_dict(row) for row in items]})
payload = {**_row_to_dict(usage), 'items': [_row_to_dict(row) for row in items]}
# 详情展示证面附件:用量行本身无附件字段,从证件档案回填
payload['attachmentUrls'] = getattr(cert, 'attachment_urls', None) or ''
payload['certNo'] = payload.get('certNo') or getattr(cert, 'cert_no', None) or usage.cert_no_snapshot
result['usages'].append(payload)
return result
+38 -8
View File
@@ -198,6 +198,8 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
if product is None or product.product_id!=s.product_id: raise ValueError('合同产品归属不符')
scopes={}; candidates={'purchase':[],'transport':[]}
skipped_for_vehicle=False
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
customer_rule = plan_uses_customer_category(plan)
for k in sorted(selected):
kind,pid=k; c=certs[k]
if not query(m.PlCertQtyLog,cert_type=kind,cert_id=pid,event_type='opening',del_flag=0).first(): raise ValueError('证件尚未确认期初')
@@ -216,11 +218,17 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
if c.opening_single_use_consumed or any(u.plan_id!=plan.id and (u.usage_status in ('reserved','in_transit','settled','exception') or (u.confirmation_snapshot or {}).get('executed')) for u in previous):
raise ValueError('一次有效证件已使用或已占用')
eligible=set()
for sid,s in sources.items():
link=query(m.PlContractCertLink,cert_type=kind,cert_id=pid,contract_id=s.contract_id,del_flag=0,link_status='active').first()
scope=None if link is None else query(m.PlContractCertProduct,contract_cert_link_id=link.id,contract_product_id=s.contract_product_id,del_flag=0,scope_status='active').first()
if scope:
eligible.add(sid); scopes[(k,sid)]=(link,scope)
if customer_rule:
# 新规则已由客户+品类整笔选证;账本不再依赖合同绑证 scope。
eligible=set(sources)
for sid in eligible:
scopes[(k,sid)]=(None,None)
else:
for sid,s in sources.items():
link=query(m.PlContractCertLink,cert_type=kind,cert_id=pid,contract_id=s.contract_id,del_flag=0,link_status='active').first()
scope=None if link is None else query(m.PlContractCertProduct,contract_cert_link_id=link.id,contract_product_id=s.contract_product_id,del_flag=0,scope_status='active').first()
if scope:
eligible.add(sid); scopes[(k,sid)]=(link,scope)
refs=frozenset(x.purchase_cert_id for x in query(m.PlTransportCertPurchaseRef,transport_cert_id=pid,del_flag=0).all() if x.purchase_cert_id) if kind=='transport' else frozenset()
own=usages[k].reserved_qty if k in usages else ZERO
candidates[kind].append(PermitCandidate(pid,c.total_qty-c.used_qty-c.reserved_qty+own,c.quantity_unit,c.valid_to,frozenset(eligible),refs))
@@ -279,8 +287,10 @@ def _reserve(db,user,r,plan,certs,usages,items,selected,preview=False,lock_previ
'sourceId':a.source_id,'certId':a.permit_id,'sourceQuantity':_canonical(a.source_quantity),
'permitQuantity':_canonical(a.permit_quantity),'sourceUnit':a.source_unit,
'permitUnit':a.permit_unit,'allocationSeq':a.allocation_seq,
'contractCertLinkId':link.id,'contractCertProductId':scope.id,
'scopeRevision':link.scope_revision,'certRevision':certs[k].revision,
'contractCertLinkId':None if link is None else link.id,
'contractCertProductId':None if scope is None else scope.id,
'scopeRevision':0 if link is None else link.scope_revision,
'certRevision':certs[k].revision,
'ownReservedQty':_canonical(usages[k].reserved_qty if k in usages else ZERO),
})
return result
@@ -309,7 +319,27 @@ def _reserve(db,user,r,plan,certs,usages,items,selected,preview=False,lock_previ
item=previous.get(a.source_id)
if item is None:
item=m.PlPlanCertUsageItem(**_audit(user),usage_id=u.id); db.add(item); items[k].append(item)
values=dict(contract_cert_link_id=link.id,contract_cert_product_id=scope.id,contract_scope_revision=link.scope_revision,contract_id=s.contract_id,contract_product_id=s.contract_product_id,plan_product_id=s.plan_product_id,source_stop_type=s.source_stop_type,source_stop_id=s.source_stop_id,source_stop_product_id=s.source_stop_product_id,related_stop_products=s.related_stop_products,allocation_seq=a.allocation_seq,source_unit=s.unit,planned_source_qty=s.quantity,allocated_source_qty=a.source_quantity,planned_cert_qty=a.permit_quantity,reserved_qty=a.permit_quantity,used_qty=ZERO,item_status='reserved',source_fingerprint=s.fingerprint,source_snapshot={**s.snapshot, 'measurementVersion': s.snapshot.get('measurementVersion', 'legacy_single_side'), 'productId': s.product_id},del_flag=0,reserve_input_mode=_reserve_input_mode(s.unit, c.quantity_unit),reserve_conversion_note=notes.get((k[0],k[1],a.source_id)),reserve_confirmed_by=user.id,reserve_confirmed_time=datetime.now(),update_by=user.id)
values=dict(
contract_cert_link_id=None if link is None else link.id,
contract_cert_product_id=None if scope is None else scope.id,
contract_scope_revision=0 if link is None else link.scope_revision,
contract_id=s.contract_id,contract_product_id=s.contract_product_id,plan_product_id=s.plan_product_id,
source_stop_type=s.source_stop_type,source_stop_id=s.source_stop_id,source_stop_product_id=s.source_stop_product_id,
related_stop_products=s.related_stop_products,allocation_seq=a.allocation_seq,source_unit=s.unit,
planned_source_qty=s.quantity,allocated_source_qty=a.source_quantity,planned_cert_qty=a.permit_quantity,
reserved_qty=a.permit_quantity,used_qty=ZERO,item_status='reserved',source_fingerprint=s.fingerprint,
source_snapshot={
**s.snapshot,
'measurementVersion': s.snapshot.get('measurementVersion', 'legacy_single_side'),
'productId': s.product_id,
'quotaRuleVersion': getattr(plan, 'cert_quota_rule_version', None),
'customerId': (s.snapshot or {}).get('customerId'),
'customerName': (s.snapshot or {}).get('customerName'),
},
del_flag=0,reserve_input_mode=_reserve_input_mode(s.unit, c.quantity_unit),
reserve_conversion_note=notes.get((k[0],k[1],a.source_id)),
reserve_confirmed_by=user.id,reserve_confirmed_time=datetime.now(),update_by=user.id,
)
for name,value in values.items(): setattr(item,name,value)
@@ -381,3 +381,85 @@ def test_same_city_source_skips_transport_requirement():
assert purchase_ids == ['buy1']
assert transport_ids == []
assert manuals[0].permit_id == 'buy1'
def test_candidate_context_customer_rule_skips_contract_scope(monkeypatch):
"""新规则账本候选不以合同绑证 scope 判定适用,否则预占会误报人工分配不适用。"""
from datetime import date
from decimal import Decimal
from types import SimpleNamespace
from app.services import pl_quota_usage_service as usage
from app.schemas.pl.quota_usage import UsageSource
plan = SimpleNamespace(id='plan', cert_quota_rule_version='customer_category', plan_no='P1')
source = UsageSource(
source_stop_type='delivery',
source_stop_id='s1',
source_stop_product_id='sp1',
plan_product_id='pp1',
contract_id='c1',
contract_product_id='cp1',
product_id='prod1',
quantity=Decimal('50'),
unit='dun',
fingerprint='f' * 64,
snapshot={'customerId': 'cust1'},
requires_transport=True,
)
request = SimpleNamespace(
sources=[source],
on_date=date(2026, 11, 15),
vehicle_no='皖A9TEST1',
manual_allocations=[],
)
cert = SimpleNamespace(
id='buy1', status='active', verified_time=date(2026, 11, 1), verified_by='u',
valid_from=date(2026, 11, 1), valid_to=date(2027, 1, 1), valid_use_text='多次有效',
total_qty=Decimal('100'), used_qty=Decimal('0'), reserved_qty=Decimal('0'),
quantity_unit='dun', vehicle_numbers_json=['皖A9TEST1'], opening_single_use_consumed=0,
revision=1, cert_no='G1',
)
class Q:
def __init__(self, row=None, rows=None):
self._row = row
self._rows = rows or ([] if row is None else [row])
def first(self):
return self._row
def all(self):
return self._rows
def fake_query(db, user, model, lock=False, **filters):
name = getattr(model, '__name__', '')
if name == 'PlCertQtyLog':
return Q(SimpleNamespace(id='opening'))
if name == 'PlTransportCertPurchaseRef':
return Q(rows=[])
if name in ('PlContractCertLink', 'PlContractCertProduct'):
raise AssertionError('customer_category 不应查询合同绑证 scope')
return Q()
monkeypatch.setattr(usage, '_query', fake_query)
monkeypatch.setattr(usage, '_contract', lambda *a, **k: SimpleNamespace(status='executing', contract_type='sale'))
monkeypatch.setattr(
'app.services.pl_cert_quota_rule_config_service.plan_uses_customer_category',
lambda p: True,
)
# product ownership check uses _query for CrmContractProduct
def fake_query2(db, user, model, lock=False, **filters):
name = getattr(model, '__name__', '')
if name == 'CrmContractProduct':
return Q(SimpleNamespace(product_id='prod1'))
return fake_query(db, user, model, lock=lock, **filters)
monkeypatch.setattr(usage, '_query', fake_query2)
sources, scopes, candidates = usage._candidate_context(
MagicMock(), SimpleNamespace(tenant_no='t', id='u'), request, plan,
{('purchase', 'buy1'): cert}, {}, {}, {('purchase', 'buy1')},
)
assert 'delivery:sp1' in sources
assert scopes[(('purchase', 'buy1'), 'delivery:sp1')] == (None, None)
assert candidates['purchase'][0].product_ids == frozenset({'delivery:sp1'})
+1 -1
View File
@@ -97,7 +97,7 @@ def test_plan_overview_accepts_web_funnel_aliases_and_validates_dates(api, monke
assert filters == {'plan_no': '001', 'license_plate': '皖A',
'plan_date_from': date(2026, 11, 1), 'plan_date_to': date(2026, 11, 3),
'quota_status_in': 'reserved,settled', 'supplier_name': None, 'customer_name': None,
'product_name': None, 'follow_status_in': None}
'product_name': None, 'follow_status_in': None, 'conversion_only': False}
assert client.get('/api/transport/quota-certs/plans', params={'planDateFrom': 'wrong'}).status_code == 422
assert received == []
+5
View File
@@ -251,6 +251,11 @@ def approval_case(business, monkeypatch):
c.db.commit()
c.user.to_tenant_user_dict=lambda:{}
monkeypatch.setattr(api,'get_tenant_no',lambda *a:'t')
# 安排入口要求已配置客户品类额度生效时间;本套用例聚焦共享运输/钉版本,不测切点本身
monkeypatch.setattr(
'app.services.pl_cert_quota_rule_config_service.assert_cert_quota_rule_configured',
lambda *a, **k: None,
)
c.api=api
return c
+2 -2
View File
@@ -314,11 +314,11 @@ def test_unlinked_reference_can_bind_purchase_archive_after_opening(ctx):
assert saved['purchaseRefs'][0]['purchaseCertId']==purchase_row['id']
assert saved['purchaseRefs'][0]['referenceNo']=='G35260264950502'
linked=saved['purchaseRefs'][0]
with pytest.raises(HTTPException, match='不能改变证面引用身份'):
with pytest.raises(HTTPException, match='不能改变关联购买证身份'):
s.save_cert(db,u,'transport',{'purchaseRefs':[{'id':linked['id'],'referenceType':'unknown','referenceNo':purchase_row['certNo'],'purchaseCertId':purchase_row['id']}]},row['id'])
with pytest.raises(HTTPException, match='不能移除'):
s.save_cert(db,u,'transport',{'purchaseRefs':[]},row['id'])
with pytest.raises(HTTPException, match='不能改变证面引用身份'):
with pytest.raises(HTTPException, match='不能改变关联购买证身份'):
s.save_cert(db,u,'transport',{'purchaseRefs':[
{k:linked[k] for k in ('id','referenceType','referenceNo','purchaseCertId')},
{'referenceType':'unknown','referenceNo':'NEW'},
+36
View File
@@ -317,3 +317,39 @@ def test_missing_contract_is_empty_binding_not_unreadable(case, contract_id):
assert rows[0]['bindingsReadable'] is True
assert rows[0]['certificates'] == []
assert rows[1]['bindingsReadable'] is False
def test_plan_quota_usages_include_cert_attachment_urls(case):
from app.models.crm_models import CrmContract
from app.models.pl_quota_models import (
PlPlanCertUsage, PlPlanCertUsageItem, PlPurchaseCert, PlTransportCert,
)
from app.services.pl_quota_planning import get_plan_quota
c = case
for model in (CrmContract, PlPlanCertUsage, PlPlanCertUsageItem, PlPurchaseCert, PlTransportCert):
model.__table__.create(c.db.bind)
c.db.add(PlPurchaseCert(
id='p1', cert_no='G-BUY', product_name_on_cert='硫酸', valid_use_text='一次有效',
buyer_name='买方', seller_name='卖方', total_qty=33, quantity_unit='吨',
attachment_urls='https://example.com/buy.jpg', tenant_no='t', status='active',
))
c.db.add(PlTransportCert(
id='t1', cert_no='Y-TR', product_name_on_cert='硫酸', valid_use_text='多次有效',
shipper_name='货主', total_qty=70, quantity_unit='吨', vehicle_numbers_json=['皖A1'],
attachment_urls='', tenant_no='t', status='active',
))
c.db.add(PlPlanCertUsage(
id='u1', tenant_no='t', plan_id='plan', plan_no_snapshot='PLAN',
cert_type='purchase', cert_id='p1', cert_no_snapshot='G-BUY', cert_revision=1,
quantity_unit='吨', reserved_qty=10, used_qty=0, usage_status='reserved',
))
c.db.add(PlPlanCertUsage(
id='u2', tenant_no='t', plan_id='plan', plan_no_snapshot='PLAN',
cert_type='transport', cert_id='t1', cert_no_snapshot='Y-TR', cert_revision=1,
quantity_unit='吨', reserved_qty=10, used_qty=0, usage_status='reserved',
))
c.db.flush()
usages = {u['certId']: u for u in get_plan_quota(c.db, c.user, 'plan')['usages']}
assert usages['p1']['attachmentUrls'] == 'https://example.com/buy.jpg'
assert usages['p1']['certNo'] == 'G-BUY'
assert usages['t1']['attachmentUrls'] in ('', None)
@@ -29,6 +29,8 @@ CREATE TABLE `pl_purchase_cert` (
`seller_legal_representative` varchar(100) DEFAULT NULL COMMENT '销售方法定代表人',
`seller_phone` varchar(100) DEFAULT NULL COMMENT '销售方电话',
`purchase_purpose` varchar(1000) DEFAULT NULL COMMENT '用途',
`customer_id` varchar(64) DEFAULT NULL COMMENT '系统客户ID;选证必填',
`category_id` varchar(64) DEFAULT NULL COMMENT '证用品类ID;选证必填',
`tenant_no` varchar(64) NOT NULL COMMENT '租户编号,由服务端当前用户上下文设置',
`order_num` int DEFAULT NULL COMMENT '公共排序号',
`remark` varchar(1000) DEFAULT NULL COMMENT '系统备注,与证面备注分开',
@@ -74,6 +76,7 @@ CREATE TABLE `pl_transport_cert` (
`route_text` text COMMENT '运输路线完整原文',
`consignee_name` varchar(255) DEFAULT NULL COMMENT '收货单位名称或姓名',
`applicant_name` varchar(255) DEFAULT NULL COMMENT '申请单位名称或姓名',
`customer_id` varchar(64) DEFAULT NULL COMMENT '系统客户ID;选证必填',
`tenant_no` varchar(64) NOT NULL COMMENT '租户编号,由服务端当前用户上下文设置',
`order_num` int DEFAULT NULL COMMENT '公共排序号',
`remark` varchar(1000) DEFAULT NULL COMMENT '系统备注,与证面备注分开',
@@ -0,0 +1,7 @@
-- 客户+品类规则下用证明细不再依赖合同绑证 scope;将合同绑定字段改为可空。
SET NAMES utf8mb4;
ALTER TABLE `pl_plan_cert_usage_item`
MODIFY COLUMN `contract_cert_link_id` varchar(64) DEFAULT NULL COMMENT '合同与证件绑定ID;客户品类规则可空',
MODIFY COLUMN `contract_cert_product_id` varchar(64) DEFAULT NULL COMMENT '合同证件产品范围明细ID;客户品类规则可空',
MODIFY COLUMN `contract_scope_revision` int(11) NOT NULL DEFAULT 0 COMMENT '合同证件适用范围版本;客户品类规则为0';
@@ -0,0 +1,7 @@
-- 客户+品类规则下用证明细不再依赖合同绑证 scope;将合同绑定字段改为可空。
SET NAMES utf8mb4;
ALTER TABLE `pl_plan_cert_usage_item`
MODIFY COLUMN `contract_cert_link_id` varchar(64) DEFAULT NULL COMMENT '合同与证件绑定ID;客户品类规则可空',
MODIFY COLUMN `contract_cert_product_id` varchar(64) DEFAULT NULL COMMENT '合同证件产品范围明细ID;客户品类规则可空',
MODIFY COLUMN `contract_scope_revision` int(11) NOT NULL DEFAULT 0 COMMENT '合同证件适用范围版本;客户品类规则为0';
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const readyTab = readFileSync(
new URL('../src/views/m/pl/plan-cert/ReadyTab.vue', import.meta.url),
'utf8'
)
const contractTab = readFileSync(
new URL('../src/views/m/pl/plan-cert/ContractTab.vue', import.meta.url),
'utf8'
)
const archiveForm = readFileSync(
new URL('../src/views/m/pl/plan-cert/archive-form.vue', import.meta.url),
'utf8'
)
const stopCard = readFileSync(
new URL('../src/views/m/pl/plan/PlanStopReadCard.vue', import.meta.url),
'utf8'
)
test('H5 证书齐备对齐 2.0:待完善列表与完善并关闭,且文案不宣称归档可跳过额度', () => {
assert.match(readyTab, /listPendingFulfill/)
assert.match(readyTab, /certReadyApi\.fulfill/)
assert.match(readyTab, /fulfillBatch/)
assert.match(readyTab, /已标记待完善/)
assert.match(readyTab, /完善并关闭/)
assert.match(readyTab, /归档前仍须/)
assert.doesNotMatch(readyTab, /安排和归档时都不再校验/)
assert.doesNotMatch(readyTab, /也不扣减额度/)
})
test('H5 合同用证页对新规则给出只读提示', () => {
assert.match(contractTab, /客户\+证用品类/)
assert.match(contractTab, /仅供历史查看/)
})
test('H5 建档页使用关联购买证且无证面引用残留', () => {
assert.match(archiveForm, /关联购买证/)
assert.doesNotMatch(archiveForm, /证面引用/)
})
test('H5 段次只读卡展示同市只需购买证', () => {
assert.match(stopCard, /是否同市/)
assert.match(stopCard, /同市只需购买证/)
})
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { toxicArrangeBlockMessage } from '../src/utils/pl-quota-cert-waiver'
const detail = readFileSync(new URL('../src/views/m/pl/plan/detail.vue', import.meta.url), 'utf8')
const stopCard = readFileSync(
new URL('../src/views/m/pl/plan/PlanStopReadCard.vue', import.meta.url),
'utf8'
)
const loadSection = readFileSync(
new URL('../src/views/m/pl/plan/MobilePlanLoadSection.vue', import.meta.url),
'utf8'
)
const deliverySection = readFileSync(
new URL('../src/views/m/pl/plan/MobilePlanDeliverySection.vue', import.meta.url),
'utf8'
)
const certFace = readFileSync(
new URL('../src/views/m/pl/plan-cert/CertFacePanel.vue', import.meta.url),
'utf8'
)
const arrangeSubmit = readFileSync(
new URL('../src/composables/usePlPlanArrangeSubmit.ts', import.meta.url),
'utf8'
)
const formDialog = readFileSync(
new URL('../src/components/business/pl-plan-form-dialog/index.vue', import.meta.url),
'utf8'
)
const detailActions = readFileSync(
new URL('../src/utils/mp-pl-plan-detail-actions.ts', import.meta.url),
'utf8'
)
test('H5 计划详情提供查看用证入口并挂载 CertFacePanel', () => {
assert.match(detail, /查看用证/)
assert.match(detail, /CertFacePanel/)
assert.match(detail, /quotaOpen/)
})
test('H5 装送段可编辑同市,只读卡标明同市只需购买证', () => {
assert.match(loadSection, /sameCity/)
assert.match(deliverySection, /sameCity/)
assert.match(stopCard, /同市只需购买证/)
})
test('H5 用证面板新规则只读提示,且占用明细可回填证号', () => {
assert.match(certFace, /客户\+品类自动选证/)
assert.match(certFace, /安排不提供手工选证/)
assert.match(certFace, /certNoSnapshot/)
assert.match(certFace, /sourceStopProductId/)
})
test('H5/共用安排提交不附带手工 quotaConfiguration', () => {
assert.doesNotMatch(arrangeSubmit, /quotaConfiguration/)
assert.match(formDialog, /toxicArrangeBlockMessage/)
assert.match(formDialog, /buildArrangePayloadFromPlanForm/)
})
test('新规则安排以服务端齐备/余量检查为准,不被本地合同绑证缺口误拦', () => {
const unbound = '合同HT-1尚未绑定购买证和运输证,请先到合同中绑定后再安排运输'
assert.equal(
toxicArrangeBlockMessage(
{ certQuotaRuleVersion: 'customer_category', arrangeBlockMessage: '' },
unbound
),
''
)
assert.equal(
toxicArrangeBlockMessage(
{
certQuotaRuleVersion: 'customer_category',
arrangeBlockMessage: '新规则下证书齐备只放行安排'
},
unbound
),
'新规则下证书齐备只放行安排'
)
})
test('H5 计划详情无运输归档操作(归档在 Web;齐备完善在 plan-cert)', () => {
assert.doesNotMatch(detailActions, /archive/)
assert.doesNotMatch(detailActions, /归档/)
assert.match(detailActions, /arrange/)
})
@@ -11,10 +11,14 @@ test('危化品证上传位于运输管理并复用现有 WebView 壳', async ()
const { WORKBENCH_MODULES } = await import(`data:text/javascript,${encodeURIComponent(source)}`)
const transport = WORKBENCH_MODULES.find((m) => m.key === 'pl')
const entry = transport.menus.find((m) => m.code === 'pl-plan-cert-webview')
assert.ok(entry, '运输管理缺少危化品证上传入口')
assert.equal(entry.name, '危化品证上传')
assert.ok(entry, '运输管理缺少运输证件管理入口')
assert.equal(entry.name, '运输证件管理')
assert.equal(entry.perms, 'mini:pl:plan-cert:list')
assert.equal(entry.enabled, true)
assert.ok(
Array.isArray(entry.anyPerms) && entry.anyPerms.includes('mini:pl:quota-cert:list'),
'入口应覆盖证件建档等子权限'
)
const api = await read('miniprogram/config/api.js')
const ast = ts.createSourceFile('api.js', api, ts.ScriptTarget.Latest, true)
let expression
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const viewTabs = readFileSync(
new URL('../src/components/business/pl-plan-view-tabs/index.vue', import.meta.url),
'utf8'
)
const h5Detail = readFileSync(
new URL('../src/views/m/pl/plan/detail.vue', import.meta.url),
'utf8'
)
test('Web 计划详情:关联证件在装送货后且无危化品证书信息区', () => {
assert.doesNotMatch(viewTabs, /危化品证书信息/)
assert.doesNotMatch(viewTabs, /PlPlanHazardCertViewTable/)
const loadIdx = viewTabs.indexOf('装送货信息')
const certIdx = viewTabs.indexOf('关联购买证 / 运输证')
const freightIdx = viewTabs.indexOf('运费信息')
assert.ok(loadIdx >= 0 && certIdx > loadIdx && freightIdx > certIdx)
assert.match(viewTabs, /AttachmentUploadField/)
assert.match(viewTabs, /attachmentUrls/)
})
test('H5 计划详情:关联购买证/运输证替换危化品证书信息并展示附件', () => {
assert.doesNotMatch(h5Detail, /危化品证书信息/)
assert.match(h5Detail, /关联购买证 \/ 运输证/)
assert.match(h5Detail, /关联证件/)
const loadIdx = h5Detail.indexOf('plan-sec-load')
const certIdx = h5Detail.indexOf('plan-sec-cert')
const freightIdx = h5Detail.indexOf('plan-sec-freight')
assert.ok(loadIdx >= 0 && certIdx > loadIdx && freightIdx > certIdx)
assert.match(h5Detail, /AttachmentUploadField|CertAttachments/)
assert.match(h5Detail, /attachmentUrls/)
})
+1 -1
View File
@@ -645,7 +645,7 @@ test('referenced bindings and references cannot be edited or removed', async ()
s.removeReference(0)
s.addReference()
assert.equal(s.form.value.purchaseRefs.length, 1)
assert.ok(h.messages.some((m) => m.includes('不能新增或移除证面引用')))
assert.ok(h.messages.some((m) => m.includes('不能新增或移除关联购买证')))
assert.equal(s.referenceRowLocked(s.form.value.purchaseRefs[0]), false)
} finally {
h.stop()
@@ -17,3 +17,20 @@ test('unmarked plans still block on the server message or the local certificate
assert.equal(toxicArrangeBlockMessage({ arrangeBlockMessage: '' }, unbound), unbound)
assert.equal(toxicArrangeBlockMessage(null, unbound), unbound)
})
test('customer_category plans trust the server arrange check and ignore local contract-binding gaps', () => {
assert.equal(
toxicArrangeBlockMessage(
{ certQuotaRuleVersion: 'customer_category', arrangeBlockMessage: '' },
unbound
),
''
)
assert.equal(
toxicArrangeBlockMessage(
{ certQuotaRuleVersion: 'customer_category', arrangeBlockMessage: '证件余量不足' },
unbound
),
'证件余量不足'
)
})
@@ -105,7 +105,7 @@
<ViewKvTable :fields="group.fields" mode="dual" />
</section>
<section v-if="tab.kind === 'transport'" class="quota-face-group">
<h4>证面引用</h4>
<h4>关联购买证</h4>
<el-table
v-if="tab.cert.purchaseRefs?.length"
:data="tab.cert.purchaseRefs"
@@ -125,7 +125,7 @@
}}</template>
</el-table-column>
</el-table>
<p v-else class="quota-note">没有证面引用。</p>
<p v-else class="quota-note">没有关联购买证。</p>
</section>
<section v-if="tab.cert.attachmentUrls" class="quota-face-group">
<h4>证件附件</h4>
@@ -757,13 +757,33 @@
return !label || label === '—' ? '' : label
}
function boundCerts(row: QuotaSourceOption) {
return (
const fromStop =
plan.value?.stopProducts?.find(
(item) =>
item.sourceStopType === row.sourceStopType &&
item.sourceStopProductId === row.sourceStopProductId
)?.certificates || []
)
if (fromStop.length) return fromStop
const sourceId = `${row.sourceStopType}:${row.sourceStopProductId}`
const fromUsage: { certType: CertType; certId: string; certNo: string; quantityUnit: string }[] =
[]
for (const usage of plan.value?.usages || []) {
const kind = String(usage.certType || '') as CertType
if (kind !== 'purchase' && kind !== 'transport') continue
const items = Array.isArray(usage.items) ? usage.items : []
const hit = items.some((item: Record<string, unknown>) => {
const typed = `${String(item.sourceStopType || '')}:${String(item.sourceStopProductId || '')}`
return String(item.sourceId || '') === sourceId || typed === sourceId
})
if (!hit) continue
fromUsage.push({
certType: kind,
certId: String(usage.certId || ''),
certNo: String(usage.certNoSnapshot || ''),
quantityUnit: String(usage.quantityUnit || '')
})
}
return fromUsage
}
function certsOf(row: QuotaSourceOption, kind: CertType) {
return boundCerts(row).filter((cert) => cert.certType === kind)
@@ -71,23 +71,6 @@
<p v-else-if="!loading" class="pl-plan-form-empty-hint">暂无车辆信息</p>
</section>
<section class="pl-plan-form-section pl-plan-form-section--plain">
<h3 class="pl-plan-form-section-title">关联购买证 / 运输证</h3>
<p v-if="plan?.quotaCertPendingFulfill" class="pl-plan-form-empty-hint">
证书齐备(待完善):请到「证书齐备 → 已标记待完善证书」挂证并完善并关闭
</p>
<div v-else-if="linkedCertRows.length" class="pl-plan-linked-certs">
<div v-for="item in linkedCertRows" :key="item.key" class="pl-plan-linked-cert-row">
<el-tag size="small" :type="item.kind === 'purchase' ? 'success' : 'warning'">
{{ item.kind === 'purchase' ? '购买证' : '运输证' }}
</el-tag>
<span>{{ item.certNo || item.certId }}</span>
<span class="text-slate-500">预占 {{ item.reservedQty }} / 已用 {{ item.usedQty }}</span>
</div>
</div>
<p v-else class="pl-plan-form-empty-hint">暂无关联证件</p>
</section>
<section class="pl-plan-form-section pl-plan-form-section--plain">
<h3 class="pl-plan-form-section-title">装送货信息</h3>
<PlPlanStopsSplitShell
@@ -251,8 +234,31 @@
</section>
<section class="pl-plan-form-section pl-plan-form-section--plain">
<h3 class="pl-plan-form-section-title">危化品证书信息</h3>
<PlPlanHazardCertViewTable :products="hazardProducts" />
<h3 class="pl-plan-form-section-title">关联购买证 / 运输证</h3>
<p v-if="plan?.quotaCertPendingFulfill" class="pl-plan-form-empty-hint">
证书齐备(待完善):请到「证书齐备 → 已标记待完善证书」挂证并完善并关闭
</p>
<div v-else-if="linkedCertRows.length" class="pl-plan-linked-certs">
<div v-for="item in linkedCertRows" :key="item.key" class="pl-plan-linked-cert-block">
<div class="pl-plan-linked-cert-block__meta">
<el-tag size="small" :type="item.kind === 'purchase' ? 'success' : 'warning'">
{{ item.kind === 'purchase' ? '购买证' : '运输证' }}
</el-tag>
<span class="pl-plan-linked-cert-block__no">{{ item.certNo || item.certId }}</span>
<span class="pl-plan-linked-cert-block__qty"
>预占 {{ item.reservedQty }} / 已用 {{ item.usedQty }}</span
>
</div>
<div class="pl-plan-linked-cert-block__slot">
<span class="pl-plan-linked-cert-block__label">证件附件</span>
<div v-if="item.attachmentUrls" class="pl-plan-linked-cert-block__attachment">
<AttachmentUploadField :model-value="item.attachmentUrls" disabled />
</div>
<span v-else class="pl-plan-linked-cert-block__missing">未上传</span>
</div>
</div>
</div>
<p v-else class="pl-plan-form-empty-hint">暂无关联证件</p>
</section>
<section class="pl-plan-form-section pl-plan-form-section--plain">
@@ -430,7 +436,6 @@
type BrandQtyRow
} from '@/components/business/brand-qty-chips-field/index.vue'
import PlPlanDriverFeedbackDialog from '@/components/business/pl-plan-driver-feedback-dialog/index.vue'
import PlPlanHazardCertViewTable from '@/components/business/pl-plan-hazard-cert-view-table/index.vue'
import PlPlanArchiveBrandViewBlock from '@/components/business/pl-plan-archive-brand-view-block/index.vue'
import PlPlanStopFeedbackPanel from '@/components/business/pl-plan-stop-feedback-panel/index.vue'
import PlPlanStopProductTable from '@/components/business/pl-plan-stop-product-table/index.vue'
@@ -476,7 +481,6 @@
} from '@/utils/pl-plan-stop-form'
import { buildArchiveBrandViewsBySegment } from '@/utils/pl-plan-archive-brand-view'
import { resolveFreightUnitLabel } from '@/composables/usePlFreightCalc'
import { isHazardProduct } from '@/composables/usePlToxicProductCertCheck'
import { useDictStore } from '@/store/modules/dict'
import { formatDate, formatDateTime } from '@/utils/format'
import {
@@ -1638,18 +1642,6 @@
return fields
})
/** 危化证区域展示 CRM 标准名(productRows 已回填 standardProductName;库内 productName 可能是别名) */
const hazardProducts = computed(() =>
products.value
.filter((item) => isHazardProduct(item))
.map((item) => {
const formRow = item.id ? planProductFormById.value.get(String(item.id)) : undefined
const standardName = String(formRow?.standardProductName || '').trim()
if (!standardName || standardName === item.productName) return item
return { ...item, productName: standardName }
})
)
const linkedCertRows = ref<
Array<{
key: string
@@ -1658,6 +1650,7 @@
certNo?: string
reservedQty: string | number
usedQty: string | number
attachmentUrls: string
}>
>([])
@@ -1670,9 +1663,10 @@
key: `${u.certType || u.cert_type}-${u.certId || u.cert_id}-${index}`,
kind: String(u.certType || u.cert_type || ''),
certId: String(u.certId || u.cert_id || ''),
certNo: u.certNo || u.cert_no,
certNo: u.certNo || u.cert_no || u.certNoSnapshot || u.cert_no_snapshot,
reservedQty: u.reservedQty ?? u.reserved_qty ?? '—',
usedQty: u.usedQty ?? u.used_qty ?? '—'
usedQty: u.usedQty ?? u.used_qty ?? '—',
attachmentUrls: String(u.attachmentUrls || u.attachment_urls || '').trim()
}))
} catch {
linkedCertRows.value = []
@@ -1932,6 +1926,93 @@
white-space: pre-wrap;
}
.pl-plan-linked-certs {
display: flex;
flex-direction: column;
gap: 12px;
}
.pl-plan-linked-cert-block {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: 10px 16px;
padding: 14px 16px;
border: 1px solid var(--border, #d9e8e2);
border-radius: 10px;
background: #fff;
}
.pl-plan-linked-cert-block__meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
min-width: 0;
}
.pl-plan-linked-cert-block__no {
font-size: 14px;
font-weight: 600;
color: #1e293b;
word-break: break-all;
}
.pl-plan-linked-cert-block__qty {
font-size: 13px;
color: var(--mint-text-muted, #64748b);
}
.pl-plan-linked-cert-block__slot {
display: inline-flex;
align-items: flex-start;
gap: 8px;
}
.pl-plan-linked-cert-block__label {
flex-shrink: 0;
color: var(--mint-text);
font-size: 13px;
line-height: 1.4;
white-space: nowrap;
}
.pl-plan-linked-cert-block__attachment {
display: inline-flex;
flex-shrink: 0;
:deep(.attachment-upload) {
width: auto;
}
:deep(.attachment-list) {
gap: 8px;
}
:deep(.attachment-item) {
width: 140px;
margin-bottom: 0;
border-radius: 8px;
}
:deep(.attachment-preview) {
height: 96px;
}
}
.pl-plan-linked-cert-block__missing {
display: inline-flex;
align-items: center;
justify-content: center;
width: 140px;
height: 96px;
border: 1px dashed var(--border, #d9e8e2);
border-radius: 8px;
background: var(--table-head, #f8fafc);
color: var(--mint-text-muted);
font-size: 12px;
}
.pl-plan-kv-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
+7 -1
View File
@@ -1,10 +1,16 @@
/** 已标记证书齐备时,只保留服务端仍要拦住的来源问题,不再用本地两证缺口补一条拦截。 */
export function toxicArrangeBlockMessage(
quota: { quotaCertWaived?: boolean; arrangeBlockMessage?: string } | null | undefined,
quota: {
quotaCertWaived?: boolean
arrangeBlockMessage?: string
certQuotaRuleVersion?: string | null
} | null | undefined,
unboundMessage: string
) {
const serverMessage = (quota?.arrangeBlockMessage || '').trim()
if (quota?.quotaCertWaived) return serverMessage
// 新规则由服务端自动选证;本地「合同绑证」缺口不能再当安排拦截条件
if (quota?.certQuotaRuleVersion === 'customer_category') return serverMessage
return serverMessage || unboundMessage
}
@@ -8,7 +8,10 @@
<template v-else-if="plan">
<section class="mp-wv-section mp-cert-quota">
<p class="mp-wv-card__meta">当前车号 {{ plan.licensePlate || '待安排' }}</p>
<p v-if="plan.canSaveConfiguration === false" class="plan-empty-hint">
<p v-if="plan.certQuotaRuleVersion === 'customer_category'" class="plan-empty-hint">
新规则计划由系统按客户+品类自动选证,本页仅查看占用;安排不提供手工选证。
</p>
<p v-else-if="plan.canSaveConfiguration === false" class="plan-empty-hint">
已安排计划请从调整运输提交用证变更;已归档计划仅查看占用。
</p>
<h3>全部装送段用证</h3>
@@ -122,8 +125,8 @@
</div>
</div>
<template v-if="face.kind === 'transport'">
<h3>证面引用</h3>
<p v-if="!face.cert.purchaseRefs?.length" class="plan-empty-hint">没有证面引用。</p>
<h3>关联购买证</h3>
<p v-if="!face.cert.purchaseRefs?.length" class="plan-empty-hint">没有关联购买证。</p>
<article
v-for="(refRow, index) in face.cert.purchaseRefs || []"
:key="refRow.id || index"
@@ -274,12 +277,32 @@
for (const [key, ids] of Object.entries(next.pairs)) pairs[key] = ids
}
function bound(row: QuotaSourceOption, kind: CertType) {
return (
const fromStop = (
plan.value?.stopProducts?.find(
(item) =>
item.sourceStopType === row.sourceStopType && item.sourceStopProductId === row.sourceStopProductId
)?.certificates || []
).filter((cert) => cert.certType === kind)
if (fromStop.length) return fromStop
// 新规则无合同绑证时,安排后从占用明细回填证号
const sourceId = `${row.sourceStopType}:${row.sourceStopProductId}`
const fromUsage: { certType: CertType; certId: string; certNo: string; quantityUnit: string }[] = []
for (const usage of plan.value?.usages || []) {
if (String(usage.certType || '') !== kind) continue
const items = Array.isArray(usage.items) ? usage.items : []
const hit = items.some((item: Record<string, unknown>) => {
const typed = `${String(item.sourceStopType || '')}:${String(item.sourceStopProductId || '')}`
return String(item.sourceId || '') === sourceId || typed === sourceId
})
if (!hit) continue
fromUsage.push({
certType: kind,
certId: String(usage.certId || ''),
certNo: String(usage.certNoSnapshot || ''),
quantityUnit: String(usage.quantityUnit || '')
})
}
return fromUsage
}
function display(cert: QuotaCert, key: string) {
if (key === 'status') return certStatusLabels[cert.status] || cert.status || '—'
@@ -1,5 +1,8 @@
<template>
<main class="mp-h5-list-content mp-plan-cert-list">
<p class="mp-contract-quota-tip">
新规则已生效:安排选证改为客户+证用品类。本页合同用证仅供历史查看与旧规则计划,请勿再作为新计划的用证操作入口。
</p>
<p v-if="error" class="mp-wv-empty" role="alert">
{{ error }}
<button class="mp-wv-btn" type="button" @click="reload">重试</button>
@@ -127,3 +130,15 @@
)
void init()
</script>
<style scoped>
.mp-contract-quota-tip {
margin: 8px 12px 0;
padding: 10px 12px;
border-radius: 8px;
background: #ecfdf5;
color: #065f46;
font-size: 12px;
line-height: 1.5;
}
</style>
+152 -19
View File
@@ -1,5 +1,24 @@
<template>
<main class="mp-h5-list-content mp-plan-cert-list">
<van-tabs v-model:active="listMode" class="mp-customer-view-tabs" shrink :ellipsis="false">
<van-tab name="mark" title="待标记齐备" />
<van-tab name="fulfill" title="已标记待完善" />
</van-tabs>
<p class="mp-cert-ready-tip">
新规则下证书齐备只放行安排;归档前须上传真证并执行「完善并关闭」。旧规则齐备不进待完善列表。
</p>
<div v-if="listMode === 'fulfill'" class="mp-cert-ready-actions">
<button class="mp-wv-btn" type="button" @click="goUpload('purchase')">上传购买证</button>
<button class="mp-wv-btn" type="button" @click="goUpload('transport')">上传运输证</button>
<button
class="mp-wv-btn mp-wv-btn--primary"
type="button"
:disabled="!selectedIds.length || batching"
@click="fulfillBatch"
>
{{ batching ? '批量完善中…' : `批量完善并关闭${selectedIds.length ? `(${selectedIds.length})` : ''}` }}
</button>
</div>
<p v-if="error" class="mp-wv-empty" role="alert">
{{ error }}
<button class="mp-wv-btn" type="button" @click="reload">重试</button>
@@ -7,14 +26,26 @@
<van-list v-model:loading="loading" :finished="finished" finished-text="" @load="load">
<article v-for="row in rows" :key="row.id" class="mp-wv-card mp-wv-flat-card">
<div class="mp-wv-card__head">
<div class="mp-cert-ready-select" v-if="listMode === 'fulfill'">
<van-checkbox :model-value="selectedIds.includes(row.id)" @click.stop="toggleSelect(row.id)" />
</div>
<div>
<div class="mp-wv-card__no">{{ row.planNo }}</div>
<div class="mp-wv-card__sub">
{{ row.licensePlate || '未安排车辆' }} · {{ row.planDate || '未定日期' }}
</div>
</div>
<span class="status-tag" :class="row.quotaCertWaived ? 'status-tag--success' : 'status-tag--warning'">
{{ row.quotaCertWaived ? '已齐备' : '未齐备' }}
<span
class="status-tag"
:class="row.quotaCertWaived ? 'status-tag--success' : 'status-tag--warning'"
>
{{
listMode === 'fulfill'
? '待完善'
: row.quotaCertWaived
? '已齐备'
: '未齐备'
}}
</span>
</div>
<div class="mp-wv-card__kv">
@@ -31,7 +62,10 @@
<span>确认时间</span><span>{{ formatCertReadyTime(row.quotaCertWaivedTime) }}</span>
</div>
</div>
<div v-if="!row.quotaCertWaived" class="mp-wv-card-actions mp-wv-card-actions--single">
<div
v-if="listMode === 'mark' && !row.quotaCertWaived"
class="mp-wv-card-actions mp-wv-card-actions--single"
>
<button
class="mp-wv-btn mp-wv-btn--primary"
type="button"
@@ -41,9 +75,22 @@
{{ markingId === row.id ? '正在确认…' : '证书齐备' }}
</button>
</div>
<div v-else-if="listMode === 'fulfill'" class="mp-wv-card-actions mp-wv-card-actions--single">
<button
class="mp-wv-btn mp-wv-btn--primary"
type="button"
:disabled="markingId === row.id"
@click="fulfillOne(row)"
>
{{ markingId === row.id ? '正在完善…' : '完善并关闭' }}
</button>
</div>
</article>
</van-list>
<van-empty v-if="!loading && finished && !error && !rows.length" description="暂无需要证书齐备的计划" />
<van-empty
v-if="!loading && finished && !error && !rows.length"
:description="listMode === 'fulfill' ? '暂无待完善证书的计划' : '暂无需要证书齐备的计划'"
/>
</main>
</template>
@@ -59,18 +106,23 @@
planDateFrom: string
planDateTo: string
}>()
const router = useRouter()
const listMode = ref<'mark' | 'fulfill'>('mark')
const rows = ref<CertReadyPlan[]>([])
const loading = ref(false)
const finished = ref(false)
const error = ref('')
const page = ref(1)
const markingId = ref('')
const batching = ref(false)
const selectedIds = ref<string[]>([])
let epoch = 0
function reload() {
epoch++
page.value = 1
rows.value = []
selectedIds.value = []
loading.value = true
finished.value = false
error.value = ''
@@ -81,15 +133,22 @@
const current = epoch
loading.value = true
try {
const data = await certReadyApi.list({
page: page.value,
pageSize: 20,
keyword: props.keyword || undefined,
waived: props.waived || undefined,
followStatusIn: props.followStatus || undefined,
planDateFrom: props.planDateFrom || undefined,
planDateTo: props.planDateTo || undefined
})
const data =
listMode.value === 'fulfill'
? await certReadyApi.listPendingFulfill({
page: page.value,
pageSize: 20,
keyword: props.keyword || undefined
})
: await certReadyApi.list({
page: page.value,
pageSize: 20,
keyword: props.keyword || undefined,
waived: props.waived || undefined,
followStatusIn: props.followStatus || undefined,
planDateFrom: props.planDateFrom || undefined,
planDateTo: props.planDateTo || undefined
})
if (current !== epoch) return
const list = data?.list || []
rows.value = page.value === 1 ? list : rows.value.concat(list)
@@ -108,9 +167,9 @@
async function mark(row: CertReadyPlan) {
try {
await ElMessageBox.confirm(
'确认后,这条运输计划在安排和归档时都不再校验购买证、运输证,也不扣减额度。',
'证书齐备',
{ type: 'warning', confirmButtonText: '确认齐备', cancelButtonText: '取消' }
'确认后可先安排运输;新规则下归档前仍须到「已标记待完善」上传证件并执行完善并关闭。确认人和时间会记在本行。',
'标记齐备',
{ type: 'warning', confirmButtonText: '确认标记', cancelButtonText: '取消' }
)
} catch {
return
@@ -127,11 +186,85 @@
}
}
async function fulfillOne(row: CertReadyPlan) {
markingId.value = row.id
try {
await certReadyApi.fulfill(row.id)
ElMessage.success('已完善并关闭')
reload()
} catch (reason) {
ElMessage.error(certReadyErrorText(reason, '完善并关闭失败'))
} finally {
markingId.value = ''
}
}
function toggleSelect(id: string) {
selectedIds.value = selectedIds.value.includes(id)
? selectedIds.value.filter((item) => item !== id)
: selectedIds.value.concat(id)
}
async function fulfillBatch() {
if (!selectedIds.value.length) return
batching.value = true
try {
const res = await certReadyApi.fulfillBatch(selectedIds.value)
const failed = (res?.results || []).filter((item) => !item.ok)
if (failed.length) {
ElMessage.warning(`部分失败:${failed.map((f) => f.message || f.planId).join(';')}`)
} else {
ElMessage.success('批量完善完成')
}
reload()
} catch (reason) {
ElMessage.error(certReadyErrorText(reason, '批量完善失败'))
} finally {
batching.value = false
}
}
function goUpload(kind: 'purchase' | 'transport') {
void router.push(`/m/pl/plan-cert/archive/${kind}/create`)
}
watch(listMode, () => reload())
watch(
() =>
[props.keyword, props.waived, props.followStatus, props.planDateFrom, props.planDateTo].join(
'\0'
),
[
props.keyword,
listMode.value === 'mark' ? props.waived : '',
listMode.value === 'mark' ? props.followStatus : '',
listMode.value === 'mark' ? props.planDateFrom : '',
listMode.value === 'mark' ? props.planDateTo : ''
].join('\0'),
() => reload()
)
</script>
<style scoped>
.mp-cert-ready-tip {
margin: 8px 12px 0;
padding: 10px 12px;
border-radius: 8px;
background: #ecfdf5;
color: #065f46;
font-size: 12px;
line-height: 1.5;
}
.mp-cert-ready-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 10px 12px 0;
}
.mp-cert-ready-select {
margin-right: 8px;
display: flex;
align-items: flex-start;
padding-top: 2px;
}
.mp-wv-card__head {
align-items: flex-start;
}
</style>
@@ -60,8 +60,8 @@
</article>
</section>
<section v-if="kind === 'transport'" class="mp-wv-section">
<h3>证面引用</h3>
<p v-if="!(cert.purchaseRefs || []).length" class="plan-empty-hint">没有证面引用。</p>
<h3>关联购买证</h3>
<p v-if="!(cert.purchaseRefs || []).length" class="plan-empty-hint">没有关联购买证。</p>
<article v-for="(row, index) in cert.purchaseRefs || []" :key="row.id || index" class="mp-cert-block">
<div class="detail-field-grid">
<div class="mp-wv-kv">
@@ -49,7 +49,7 @@
<section class="mp-wv-section">
<h3>证件附件</h3>
<p class="plan-empty-hint"
>一次仅上传并解析一张证件图片或 PDF。解析只补充还没手工填写的空字段。删除证件附件后,证面信息、绑定合同产品和证面引用会清空。</p
>一次仅上传并解析一张证件图片或 PDF。解析只补充还没手工填写的空字段。删除证件附件后,证面信息、绑定合同产品和关联购买证会清空。</p
>
<AttachmentUploadField
ref="attachments"
@@ -305,7 +305,7 @@
</section>
<section v-if="kind === 'transport'" class="mp-wv-section">
<h3>证面引用</h3>
<h3>关联购买证</h3>
<p v-if="form.referencesEditable === false" class="plan-empty-hint">
已有额度流水。尚未关联购买证档案的引用可以补全;已关联的引用不能修改或移除。
</p>
@@ -921,7 +921,7 @@
}
function addReference() {
if (form.value.referencesEditable === false) {
ElMessage.warning('已有额度流水,不能新增或移除证面引用')
ElMessage.warning('已有额度流水,不能新增或移除关联购买证')
return
}
form.value.purchaseRefs = [
@@ -1034,17 +1034,17 @@
touch('categoryId')
categoryOpen.value = false
}
async function matchBuyerCustomer(name: string, current: number) {
const buyer = name.trim()
if (!buyer) return
async function matchBuyerCustomer(name: string, current: number, partyLabel = '证面单位') {
const party = name.trim()
if (!party) return
try {
const result = await fetchCustomerOptions({ keyword: buyer, limit: 200 })
const result = await fetchCustomerOptions({ keyword: party, limit: 200 })
if (current !== epoch) return
const matches = exactCustomerMatches(extractCustomers(result), buyer)
const matches = exactCustomerMatches(extractCustomers(result), party)
if (matches.length === 1) {
const matched = {
id: String(matches[0].id),
customerName: matches[0].customerName || buyer
customerName: matches[0].customerName || party
}
if (!systemCustomer.value.id) {
systemCustomer.value = matched
@@ -1058,8 +1058,8 @@
}
ElMessage.warning(
matches.length
? '有多个客户与购买单位同名,请手工选择'
: '未找到与购买单位完全一致的客户,请手工选择'
? `有多个客户与${partyLabel}同名,请手工选择`
: `未找到与${partyLabel}完全一致的客户,请手工选择`
)
} catch {
if (current === epoch) ElMessage.warning('客户匹配失败,请手工选择')
@@ -1544,10 +1544,14 @@
if (targetKind === 'purchase') {
const buyer = String(form.value.buyerName || '').trim()
const productName = String(form.value.productNameOnCert || '').trim()
if (buyer) await matchBuyerCustomer(buyer, current)
if (buyer) await matchBuyerCustomer(buyer, current, '购买单位')
if (productName) await matchCertCategory(productName, current)
}
if (targetKind === 'transport') await linkRecognizedPurchaseCerts(current, targetKind, image)
if (targetKind === 'transport') {
const consignee = String(form.value.consigneeName || '').trim()
if (consignee) await matchBuyerCustomer(consignee, current, '收货单位')
await linkRecognizedPurchaseCerts(current, targetKind, image)
}
if (current !== epoch || kind.value !== targetKind || recognitionImage.value !== image) return
ElMessage.success(
Object.keys(result.invalidFields || {}).length
@@ -19,6 +19,10 @@
<div
><small>联系方式</small><span>{{ stop.contact || '—' }}</span></div
>
<div
><small>是否同市</small
><span>{{ Number(stop.sameCity) === 1 ? '是(同市只需购买证)' : '否' }}</span></div
>
<div class="wide"
><small>{{ title }}地址</small><span>{{ displayAddress || '—' }}</span></div
>
+117 -2
View File
@@ -248,7 +248,37 @@
</section>
<section id="plan-sec-cert" class="mp-wv-section mp-h5-anchor">
<h3>危化品证书信息</h3>
<h3>关联购买证 / 运输证</h3>
<p v-if="plan.quotaCertPendingFulfill" class="plan-empty-hint">
证书齐备(待完善):请到「证书齐备 → 已标记待完善证书」挂证并完善并关闭
</p>
<div v-else-if="linkedCertRows.length" class="plan-linked-certs">
<div v-for="item in linkedCertRows" :key="item.key" class="plan-linked-cert-block">
<div class="plan-linked-cert-block__meta">
<span
class="status-tag"
:class="
item.kind === 'purchase' ? 'status-tag--success' : 'status-tag--warning'
"
>{{ item.kind === 'purchase' ? '购买证' : '运输证' }}</span
>
<span class="plan-linked-cert-block__no">{{ item.certNo || item.certId }}</span>
<span class="plan-vehicle-card__meta"
>预占 {{ item.reservedQty }} / 已用 {{ item.usedQty }}</span
>
</div>
<div class="plan-linked-cert-block__slot">
<div class="plan-subcard__label">证件附件</div>
<AttachmentUploadField
v-if="item.attachmentUrls"
:model-value="item.attachmentUrls"
disabled
/>
<p v-else class="plan-linked-cert-block__missing">未上传</p>
</div>
</div>
</div>
<p v-else class="plan-empty-hint">暂无关联证件</p>
<div class="plan-cert-actions">
<van-button block plain type="primary" @click="quotaOpen = true">查看用证</van-button>
<van-button
@@ -536,10 +566,40 @@
const errorText = ref('')
const plan = ref<Plan | null>(null)
const quotaOpen = ref(false)
const linkedCertRows = ref<
Array<{
key: string
kind: string
certId: string
certNo?: string
reservedQty: string | number
usedQty: string | number
attachmentUrls: string
}>
>([])
const needsLegalUseDocument = computed(() =>
(plan.value?.products || []).some((row) => resolveProductHazardFlags(row).isExplosive)
)
const quotaTitle = computed(() => `计划用证 · ${plan.value?.planNo || ''}`)
async function loadLinkedCerts(planId: string) {
linkedCertRows.value = []
try {
const { planQuotaApi } = await import('@/api/pl/quota-cert')
const data = await planQuotaApi.get(planId)
linkedCertRows.value = (data?.usages || []).map((u: any, index: number) => ({
key: `${u.certType || u.cert_type}-${u.certId || u.cert_id}-${index}`,
kind: String(u.certType || u.cert_type || ''),
certId: String(u.certId || u.cert_id || ''),
certNo: u.certNo || u.cert_no || u.certNoSnapshot || u.cert_no_snapshot,
reservedQty: u.reservedQty ?? u.reserved_qty ?? '—',
usedQty: u.usedQty ?? u.used_qty ?? '—',
attachmentUrls: String(u.attachmentUrls || u.attachment_urls || '').trim()
}))
} catch {
linkedCertRows.value = []
}
}
const deliveryIndex = ref(0)
const moreOpen = ref(false)
const voidOpen = ref(false)
@@ -568,7 +628,7 @@
{ key: 'base', label: '基础信息', anchor: 'plan-sec-base' },
{ key: 'transport', label: '运输信息', anchor: 'plan-sec-transport' },
{ key: 'load', label: '装送货', anchor: 'plan-sec-load' },
{ key: 'cert', label: '危化品证', anchor: 'plan-sec-cert' },
{ key: 'cert', label: '关联证件', anchor: 'plan-sec-cert' },
{ key: 'freight', label: '运费', anchor: 'plan-sec-freight' },
{ key: 'remark', label: '备注', anchor: 'plan-sec-remark' }
]
@@ -751,6 +811,7 @@
loadVersion++
actionVersion++
plan.value = null
linkedCertRows.value = []
readDetail.value = null
partialErrors.value = []
errorText.value = ''
@@ -807,9 +868,12 @@
readDetail.value = detail
partialErrors.value = [...detail.errors, ...(arrangeFailed ? ['安排信息'] : [])]
plan.value = data
await loadLinkedCerts(id)
if (!current()) return
} catch {
if (!current()) return
plan.value = null
linkedCertRows.value = []
resetArrangeActions()
errorText.value = '计划详情加载失败或记录不存在,请重试'
} finally {
@@ -1516,6 +1580,57 @@
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 12px;
}
.plan-linked-certs {
display: flex;
flex-direction: column;
gap: 12px;
}
.plan-linked-cert-block {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border: 1px solid #d9e8e2;
border-radius: 10px;
background: #fff;
}
.plan-linked-cert-block__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
}
.plan-linked-cert-block__no {
font-size: 14px;
font-weight: 600;
color: #1e293b;
word-break: break-all;
}
.plan-linked-cert-block__slot {
display: flex;
flex-direction: column;
gap: 8px;
}
.plan-linked-cert-block__missing {
display: flex;
align-items: center;
justify-content: center;
width: 140px;
height: 96px;
margin: 0;
border: 1px dashed #d9e8e2;
border-radius: 8px;
background: #f8fafc;
color: #64748b;
font-size: 12px;
}
.mp-plan-quota-sheet {
+21 -14
View File
@@ -172,7 +172,7 @@
<template #beside>
<div class="cert-upload-notes">
<p>一次仅上传并解析一张证件图片或 PDF;保存成功后自动清空,继续处理下一张</p>
<p>删除证件附件后,证面信息、绑定合同产品和证面引用会清空</p>
<p>删除证件附件后,证面信息、绑定合同产品和关联购买证会清空</p>
<p v-if="form.entryMode === 'image_parse' || !!form.attachmentUrls"
>请核对证号、数量、单位、有效次数和引用关系。解析只补充未编辑的空字段。</p
>
@@ -421,7 +421,7 @@
</el-collapse-item>
</el-collapse>
<section v-if="formKind === 'transport'" class="view-section">
<h3 class="dialog-section-title">证面引用</h3>
<h3 class="dialog-section-title">关联购买证</h3>
<el-button
class="mb-3"
type="primary"
@@ -808,23 +808,27 @@
}
/** 解析后回填【客户与品类】系统客户:名称完全一致且唯一才选中。 */
async function matchSystemCustomer(name: string, protectedFields: Set<string>) {
const buyer = name.trim()
if (!buyer || form.value.customerId || protectedFields.has('customerId')) return
async function matchSystemCustomer(
name: string,
protectedFields: Set<string>,
partyLabel = '证面单位'
) {
const party = name.trim()
if (!party || form.value.customerId || protectedFields.has('customerId')) return
try {
const result = await fetchCustomerOptions({ keyword: buyer, limit: 200 })
const result = await fetchCustomerOptions({ keyword: party, limit: 200 })
if (form.value.customerId || protectedFields.has('customerId')) return
const matches = exactCustomerMatches(extractCustomerRows(result), buyer)
const matches = exactCustomerMatches(extractCustomerRows(result), party)
if (matches.length === 1) {
await ensureCustomerOption(matches[0].id, matches[0].customerName || buyer)
await ensureCustomerOption(matches[0].id, matches[0].customerName || party)
form.value.customerId = matches[0].id
edited.add('customerId')
return
}
ElMessage.warning(
matches.length
? '有多个客户与购买单位同名,请手工选择系统客户'
: '未找到与购买单位完全一致的客户,请手工选择系统客户'
? `有多个客户与${partyLabel}同名,请手工选择系统客户`
: `未找到与${partyLabel}完全一致的客户,请手工选择系统客户`
)
} catch {
ElMessage.warning('系统客户匹配失败,请手工选择')
@@ -1059,7 +1063,7 @@
{ title: '证件附件', fields: [], slot: 'attachment' }
)
if (kind.value === 'transport') {
sections.push({ title: '证面引用', fields: [], slot: 'references' })
sections.push({ title: '关联购买证', fields: [], slot: 'references' })
}
} else {
sections.push({ title: '', fields: [], slot: 'contracts' })
@@ -1626,14 +1630,17 @@
if (targetKind === 'purchase') {
const buyer = String(form.value.buyerName || '').trim()
const productName = String(form.value.productNameOnCert || '').trim()
if (buyer) await matchSystemCustomer(buyer, protectedFields)
if (buyer) await matchSystemCustomer(buyer, protectedFields, '购买单位')
if (productName) await matchCertCategory(productName, protectedFields)
if (form.value.bindingsEditable !== false && buyer) {
await purchaseBinder.value?.matchBuyerCustomer(buyer)
}
}
if (targetKind === 'transport')
if (targetKind === 'transport') {
const consignee = String(form.value.consigneeName || '').trim()
if (consignee) await matchSystemCustomer(consignee, protectedFields, '收货单位')
await linkRecognizedPurchaseCerts(session, targetKind, targetImage)
}
if (
session !== editSession ||
!formVisible.value ||
@@ -1746,7 +1753,7 @@
}
function referencesLocked() {
if (form.value.referencesEditable !== false) return false
ElMessage.warning('已有额度流水,不能新增或移除证面引用')
ElMessage.warning('已有额度流水,不能新增或移除关联购买证')
return true
}
function addReference() {
+1 -1
View File
@@ -170,7 +170,7 @@ export function newCert(kind: CertType): CertBody {
return body as unknown as CertBody
}
/** 删除当前证件附件后,清掉证面、合同绑定和证面引用,保留备注与录入方式。 */
/** 删除当前证件附件后,清掉证面、合同绑定和关联购买证,保留备注与录入方式。 */
export function clearCertAfterAttachmentRemoved<T extends Record<string, any>>(
kind: CertType,
form: T