feat(pl): 装送货段增加同市标记,并同步额度证件与计划表单。

同市易制毒段只校验购买证;同步 Web/H5 表单、额度统计展示与相关回归测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-11-12 16:34:58 +08:00
1 parent d58f6ea5bd
commit 522b4be705
40 files changed
+576 -107

No files matched your search

+1
View File
@@ -115,4 +115,5 @@ api_key
# personal-dev:begin
/.local/agent/
/.local/secrets/
# personal-dev:end
+2
View File
@@ -536,6 +536,7 @@ class PlPlanLoad(Base):
notes = Column(String(500), nullable=True, comment="注意事项")
generate_dibang = Column(SmallInteger, nullable=False, default=0, comment="是否生成地磅单 0-否 1-是")
dibang_direction = Column(String(20), nullable=True, comment="地磅方向 outbound-出库 inbound-入库 none-无")
same_city = Column(SmallInteger, nullable=False, default=0, comment="是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证")
tenant_no = Column(String(64), nullable=False, default="shanxia2.0", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
@@ -599,6 +600,7 @@ class PlPlanDelivery(Base):
notes = Column(String(500), nullable=True, comment="注意事项")
generate_dibang = Column(SmallInteger, nullable=False, default=0, comment="是否生成地磅单 0-否 1-是")
dibang_direction = Column(String(20), nullable=True, comment="地磅方向 outbound-出库 inbound-入库 none-无")
same_city = Column(SmallInteger, nullable=False, default=0, comment="是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证")
tenant_no = Column(String(64), nullable=False, default="shanxia2.0", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
+2
View File
@@ -182,6 +182,7 @@ class PlanLoadBody(BaseModel):
dibang_direction: Optional[str] = Field(
default=None, alias="dibangDirection", description="地磅方向 outbound-出库 inbound-入库 none-无"
)
same_city: int = Field(default=0, alias="sameCity", description="是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证")
products: list[PlanStopProductBody] = Field(default_factory=list, description="装货段产品明细")
contracts: Optional[list["PlanContractLink"]] = Field(default=None, description="装货段合同列表")
remark: Optional[str] = Field(default=None, max_length=512, description="备注")
@@ -203,6 +204,7 @@ class PlanDeliveryBody(BaseModel):
dibang_direction: Optional[str] = Field(
default=None, alias="dibangDirection", description="地磅方向 outbound-出库 inbound-入库 none-无"
)
same_city: int = Field(default=0, alias="sameCity", description="是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证")
products: list[PlanStopProductBody] = Field(default_factory=list, description="送货段产品明细")
contracts: Optional[list["PlanContractLink"]] = Field(default=None, description="送货段合同列表")
remark: Optional[str] = Field(default=None, max_length=512, description="备注")
+1
View File
@@ -20,6 +20,7 @@ class UsageSource(InternalModel):
related_stop_products: List[str] = Field(default_factory=list)
fingerprint: str
snapshot: dict = Field(default_factory=dict)
requires_transport: bool = True
@property
def source_id(self):
@@ -1152,6 +1152,7 @@ def _resolve_or_create_plan_load(
existing.notes = load.notes
existing.generate_dibang = generate_dibang
existing.dibang_direction = dibang_direction
existing.same_city = 1 if int(load.same_city or 0) == 1 else 0
existing.remark = load.remark
existing.del_flag = 0
existing.update_by = user_id
@@ -1173,6 +1174,7 @@ def _resolve_or_create_plan_load(
notes=load.notes,
generate_dibang=generate_dibang,
dibang_direction=dibang_direction,
same_city=1 if int(load.same_city or 0) == 1 else 0,
tenant_no=tenant_no,
remark=load.remark,
create_by=user_id,
@@ -1439,6 +1441,7 @@ def replace_plan_stops(
stop.seq = body.seq or 1
stop.generate_dibang, stop.dibang_direction = normalize_stop_dibang_fields(
stop.customer_name, kind, body.generate_dibang, body.dibang_direction)
stop.same_city = 1 if int(getattr(body, "same_city", 0) or 0) == 1 else 0
stop.del_flag, stop.update_by, stop.update_time = 0, user_id, now
model = PlPlanLoadProduct if kind == 'load' else PlPlanDeliveryProduct
for idx, (row, product, linked) in enumerate(product_jobs or []):
+25 -7
View File
@@ -37,6 +37,7 @@ class SourceDemand:
product_id: str
quantity: Decimal
unit: str
requires_transport: bool = True
@dataclass(frozen=True)
@@ -231,6 +232,8 @@ def allocate_plan(demands: Sequence[SourceDemand], purchases: Sequence[PermitCan
key = (row.permit_kind, row.source_id, row.permit_id)
s = sources.get(row.source_id)
p = groups.get(row.permit_kind, {}).get(row.permit_id)
if s is not None and not s.requires_transport and row.permit_kind == 'transport':
raise AllocationInputError('同市来源不接受运输证分配')
sq, pq = _quantity(row.source_quantity), _quantity(row.permit_quantity)
if key in manual or s is None or p is None or s.product_id not in p.product_ids:
raise AllocationInputError('人工分配标识重复或不适用')
@@ -249,12 +252,19 @@ def allocate_plan(demands: Sequence[SourceDemand], purchases: Sequence[PermitCan
routes = []
for sid, s in sorted(sources.items()):
for gid, g in sorted(groups['purchase'].items()):
for tid, t in sorted(groups['transport'].items()):
if gid in t.purchase_permit_ids and eligible('purchase', s, g) and eligible('transport', s, t):
routes.append((sid, gid, tid))
if len(routes) > limits.max_variables:
raise AllocationIncomplete('分配计算未完成:匹配变量超限')
if s.requires_transport:
pairs = (
(gid, tid)
for gid, g in sorted(groups['purchase'].items())
for tid, t in sorted(groups['transport'].items())
if gid in t.purchase_permit_ids and eligible('purchase', s, g) and eligible('transport', s, t)
)
else:
pairs = ((gid, None) for gid, g in sorted(groups['purchase'].items()) if eligible('purchase', s, g))
for gid, tid in pairs:
routes.append((sid, gid, tid))
if len(routes) > limits.max_variables:
raise AllocationIncomplete('分配计算未完成:匹配变量超限')
a, b = [], []
def constrain(coefficients, bound, equal=False):
@@ -405,6 +415,7 @@ def validate_allocation_pairing(
if len(demands) > limits.max_sources or len(transports) > limits.max_permits:
raise AllocationIncomplete('匹配校验未完成:输入规模超限')
sources = _unique(demands, 'source_id')
purchase_only = {sid for sid, row in sources.items() if not row.requires_transport}
transport_map = _unique(transports, 'permit_id')
for s in sources.values():
_quantity(s.quantity)
@@ -434,13 +445,16 @@ def validate_allocation_pairing(
groups[kind][key] = row
for sid, s in sources.items():
total = sum(F(r.source_quantity) for (source_id, _), r in groups[kind].items() if source_id == sid)
if total != F(s.quantity):
expected = F(0) if kind == 'transport' and sid in purchase_only else F(s.quantity)
if total != expected:
raise AllocationPairingError('分配未覆盖实际来源量,需要人工调整;保留原占用')
purchase_ids = {pid for _, pid in groups['purchase']}
if len(purchase_ids) + len(transport_map) > limits.max_permits:
raise AllocationIncomplete('匹配校验未完成:证件数量超限')
routes = []
for sid, gid in sorted(groups['purchase']):
if sid in purchase_only:
continue
for source_id, tid in sorted(groups['transport']):
if sid == source_id and gid in transport_map[tid].purchase_permit_ids:
routes.append((sid, gid, tid))
@@ -450,9 +464,13 @@ def validate_allocation_pairing(
for kind, rows in groups.items():
position = 1 if kind == 'purchase' else 2
for (sid, pid), row in sorted(rows.items()):
if sid in purchase_only:
continue
v = [int(r[0] == sid and r[position] == pid) for r in routes]
a.extend([v, [-x for x in v]])
b.extend([F(row.source_quantity), -F(row.source_quantity)])
if not a:
return
try:
_solve(a, b, [0] * len(routes), budget)
except InsufficientQuota as exc:
+12 -3
View File
@@ -21,7 +21,13 @@ def allocation_diagnostics(demands, purchases, transports):
grouped.append((code, source.unit))
for kind, permits in [('purchase', purchases), ('transport', transports)]:
for code, unit in grouped:
sources = [s for s in demands if canonical_quantity_unit(s.unit) == code]
sources = [
s for s in demands
if canonical_quantity_unit(s.unit) == code
and (kind == 'purchase' or getattr(s, 'requires_transport', True))
]
if not sources:
continue
products = {s.product_id for s in sources}
eligible = [p for p in permits if p.product_ids & products]
demand = sum((s.quantity for s in sources), Decimal(0))
@@ -67,6 +73,8 @@ def missing_purchase_link_message(demands, purchases, transports, cert_nos):
notices = []
seen = set()
for source in demands:
if not getattr(source, 'requires_transport', True):
continue
buys = [p for p in purchases if source.product_id in p.product_ids and same_quantity_unit(p.unit, source.unit)]
moves = [t for t in transports if source.product_id in t.product_ids and same_quantity_unit(t.unit, source.unit)]
if not buys or not moves:
@@ -93,16 +101,17 @@ def foreign_unit_message(demands, purchases, transports, cert_nos):
for source in demands:
buys = [p for p in purchases if source.product_id in p.product_ids]
moves = [t for t in transports if source.product_id in t.product_ids]
needs_transport = getattr(source, 'requires_transport', True)
same_buys = [p for p in buys if _directly_usable(p, source.unit)]
same_moves = [t for t in moves if _directly_usable(t, source.unit)]
if same_buys and same_moves:
if same_buys and (same_moves or not needs_transport):
continue
parts = []
if not same_buys:
text = _cert_unit_text('purchase', [p for p in buys if not _directly_usable(p, source.unit)], cert_nos)
if text:
parts.append(f'购买证 {text}')
if not same_moves:
if needs_transport and not same_moves:
text = _cert_unit_text('transport', [t for t in moves if not _directly_usable(t, source.unit)], cert_nos)
if text:
parts.append(f'运输证 {text}')
+19 -8
View File
@@ -35,8 +35,9 @@ def plan_fingerprint(sources):
def classify_contract_cert_bindings(sources, bindings_by_contract, contract_labels):
"""按合同产品的可用绑定选出购买证、运输证。缺任一证时拒绝安排。"""
purchase_ids, transport_ids, missing = [], [], []
"""按合同产品的可用绑定选出购买证、运输证。同市段只要求购买证。"""
purchase_ids, transport_ids = [], []
missing_both, missing_purchase = [], []
for source in sources:
found = {'purchase': [], 'transport': []}
for binding in bindings_by_contract.get(source.contract_id) or []:
@@ -52,13 +53,22 @@ def classify_contract_cert_bindings(sources, bindings_by_contract, contract_labe
if source.contract_product_id not in product_ids or kind not in found or not cert_id:
continue
found[kind].append(cert_id)
if not found['purchase'] or not found['transport']:
missing.append(contract_labels.get(source.contract_id) or '未编号合同')
needs_transport = getattr(source, 'requires_transport', True)
label = contract_labels.get(source.contract_id) or '未编号合同'
if not found['purchase'] or (needs_transport and not found['transport']):
(missing_both if needs_transport else missing_purchase).append(label)
purchase_ids.extend(found['purchase'])
transport_ids.extend(found['transport'])
if missing:
names = '、'.join(dict.fromkeys(missing))
raise HTTPException(409, f'合同{names}尚未绑定购买证和运输证,请先到合同中绑定后再安排运输')
if needs_transport:
transport_ids.extend(found['transport'])
messages = []
if missing_both:
names = '、'.join(dict.fromkeys(missing_both))
messages.append(f'合同{names}尚未绑定购买证和运输证,请先到合同中绑定后再安排运输')
if missing_purchase:
names = '、'.join(dict.fromkeys(missing_purchase))
messages.append(f'合同{names}的同市段尚未绑定购买证,请先到合同中绑定后再安排运输')
if messages:
raise HTTPException(409, ';'.join(messages))
def unique(values):
return list(dict.fromkeys(values))
@@ -279,6 +289,7 @@ def get_plan_quota(db, user, plan_id):
product = products[row.plan_product_id]
stop = stops[side][getattr(row, 'load_id' if side == 'load' else 'delivery_id')]
item = {'sourceStopId': stop.id, 'stopId': stop.id,
'sameCity': int(getattr(stop, 'same_city', 0) or 0) == 1,
'stopOrder': getattr(stop, 'seq', None) or stop.order_num or 1,
'productId': row.product_id, 'productNo': row.product_no,
'sourceStopType': side, 'sourceStopProductId': row.id,
+4 -2
View File
@@ -156,8 +156,10 @@ def extract_sources(db, user, plan_id, mapping=None, *, actual=False, lock=False
for stop_side, value in [(side, row)] + [(other_side, r) for r in related]:
parent = stops[stop_side][getattr(value, SIDE[stop_side][2])]
stop_facts.append({key: getattr(parent, key, None) for key in ('id', 'supplier_id', 'supplier_name', 'customer_id', 'customer_name', 'address')})
same_city = int(getattr(stop, 'same_city', 0) or 0) == 1
snapshot = {'source': _facts(row), 'related': [_facts(r) for r in related], 'stops': stop_facts,
'vehicle': plan.license_plate, 'trailer': plan.trailer_no, 'planType': plan.plan_type}
'vehicle': plan.license_plate, 'trailer': plan.trailer_no, 'planType': plan.plan_type,
'sameCity': 1 if same_city else 0}
if measurement_version == 'per_stop_v2':
snapshot['measurementVersion'] = measurement_version
if preview_vehicle is not None:
@@ -168,7 +170,7 @@ def extract_sources(db, user, plan_id, mapping=None, *, actual=False, lock=False
plan_product_id=row.plan_product_id, contract_id=row.contract_id,
contract_product_id=row.contract_product_id, product_id=row.product_id,
quantity=row.plan_qty, unit=row.unit, related_stop_products=[r.id for r in related],
fingerprint=fingerprint, snapshot=snapshot)
fingerprint=fingerprint, snapshot=snapshot, requires_transport=not same_city)
sources.append(source)
if actual:
quantity = getattr(row, actual_field)
+19 -3
View File
@@ -197,6 +197,7 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
product=query(CrmContractProduct,id=s.contract_product_id,contract_id=s.contract_id,del_flag=0).first()
if product is None or product.product_id!=s.product_id: raise ValueError('合同产品归属不符')
scopes={}; candidates={'purchase':[],'transport':[]}
skipped_for_vehicle=False
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('证件尚未确认期初')
@@ -206,7 +207,10 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
if not c.valid_from <= r.on_date <= c.valid_to: raise ValueError('证件不在有效日期内')
policy=usage_policy(c.valid_use_text)
if policy=='needs_confirmation': raise ValueError('有效次数待确认')
if kind=='transport' and (not r.vehicle_no or r.vehicle_no not in (c.vehicle_numbers_json or [])): raise ValueError('车号不在运输证范围')
# 合同上常有多张运输证。车号对不上的证不参与本次分配,只要还有一张覆盖该主车号即可安排。
if kind=='transport' and (not r.vehicle_no or r.vehicle_no not in (c.vehicle_numbers_json or [])):
skipped_for_vehicle=True
continue
if policy=='once':
previous=query(m.PlPlanCertUsage,cert_type=kind,cert_id=pid,del_flag=0).all()
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):
@@ -220,11 +224,13 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
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))
if skipped_for_vehicle and not candidates['transport']:
raise ValueError('车号不在运输证范围')
return sources,scopes,candidates
def _reserve(db,user,r,plan,certs,usages,items,selected,preview=False,lock_preview=False):
sources,scopes,candidates=_candidate_context(db,user,r,plan,certs,usages,items,selected,preview,lock_preview)
demands=[SourceDemand(s.source_id,s.source_id,s.quantity,s.unit) for s in sources.values()]
demands=[SourceDemand(s.source_id,s.source_id,s.quantity,s.unit,s.requires_transport) for s in sources.values()]
try:
allocation=allocate_plan(demands,candidates['purchase'],candidates['transport'],manual_allocations=_manual(r.manual_allocations))
except InsufficientQuota as error:
@@ -244,6 +250,15 @@ def _reserve(db,user,r,plan,certs,usages,items,selected,preview=False,lock_previ
# 零量也占用一次资格;纯计算器省略零流,账本补存一对真实适用证件。
for sid,s in sorted(sources.items()):
if s.quantity != 0: continue
if not s.requires_transport:
buys=[p for p in candidates['purchase'] if sid in p.product_ids and (
same_quantity_unit(p.unit, s.unit) or standard_conversion_ratio(s.unit, p.unit) is not None
or any(a.permit_kind=='purchase' and a.source_id==sid and a.permit_id==p.permit_id for a in r.manual_allocations))]
if not buys:
raise ValueError('零量同市来源仍须有适用购买证')
chosen=min(buys, key=lambda item: (item.expires_on, item.permit_id))
rows.append(Allocation('purchase', sid, chosen.permit_id, ZERO, ZERO, s.unit, chosen.unit, 1))
continue
pairs=[(p,t) for p in candidates['purchase'] for t in candidates['transport']
if sid in p.product_ids and sid in t.product_ids and p.permit_id in t.purchase_permit_ids
and (same_quantity_unit(p.unit, s.unit) or standard_conversion_ratio(s.unit, p.unit) is not None or any(a.permit_kind=='purchase' and a.source_id==sid and a.permit_id==p.permit_id for a in r.manual_allocations))
@@ -430,12 +445,13 @@ def _settle(db,user,r,certs,usages,items,plan):
if any(u.usage_status!='settled' if adjusting else u.usage_status not in ('reserved','in_transit','exception') for k,u in usages.items() if k in active):
raise ValueError('用证状态不允许当前结转动作')
groups={'purchase':[],'transport':[]}; item_map={}; demands={}
transport_required={s.source_id: s.requires_transport for s in r.sources}
for k,rows in active.items():
for i in rows:
sid=_key(i)
a=Allocation(k[0],sid,k[1],i.allocated_source_qty,i.planned_cert_qty,i.source_unit,usages[k].quantity_unit,i.allocation_seq)
groups[k[0]].append(a); item_map[(k[0],sid,k[1])]=i
demands[sid]=SourceDemand(sid,sid,r.actual_by_source.get(sid),i.source_unit)
demands[sid]=SourceDemand(sid,sid,r.actual_by_source.get(sid),i.source_unit,transport_required.get(sid, True))
if not r.manual_actuals:
groups, generated, conversion_error = _prepare_saved_conversion(groups, r.actual_by_source, certs)
if conversion_error:
+43 -2
View File
@@ -3,8 +3,8 @@ from decimal import Decimal as D
import pytest
from app.services.pl_quota_allocation import (SourceDemand, PermitCandidate, ManualAllocation, AllocationLimits, AllocationInputError, AllocationIncomplete, InsufficientQuota, SupplementalAllocationRequired, allocate_plan, settle_allocations)
def source(id='s', product='A', quantity='30', unit='t'):
return SourceDemand(id, product, D(quantity), unit)
def source(id='s', product='A', quantity='30', unit='t', requires_transport=True):
return SourceDemand(id, product, D(quantity), unit, requires_transport)
def permit(id, quantity='30', products=('A',), refs=(), unit='t', expiry=1):
return PermitCandidate(id, D(quantity), unit, date(2027, 1, expiry), frozenset(products), frozenset(refs))
@@ -153,6 +153,40 @@ def test_zero_demand_and_empty_plan():
assert allocate_plan([source(quantity='0')], [], []).transport_allocations == ()
def test_same_city_demand_uses_purchase_certificate_only():
result = allocate_plan([source('city', quantity='10', requires_transport=False)], [permit('g', '10')], [])
assert quantities(result.purchase_allocations) == [('g', D(10), D(10))]
assert result.transport_allocations == ()
def test_same_city_shares_purchase_balance_and_skips_transport():
city = source('city', 'A', '10', requires_transport=False)
cross = source('cross', 'B', '10')
result = allocate_plan(
[city, cross],
[permit('g', '30', ('A', 'B'))],
[permit('t', '10', ('A', 'B'), ('g',))],
)
assert sum(row.source_quantity for row in result.purchase_allocations) == D(20)
assert {(row.source_id, row.permit_id) for row in result.transport_allocations} == {('cross', 't')}
with pytest.raises(InsufficientQuota):
allocate_plan(
[source('city', 'A', '10', requires_transport=False), source('cross', 'A', '10')],
[permit('g', '15')],
[permit('t', '10', refs=('g',))],
)
def test_same_city_rejects_a_transport_manual_row():
with pytest.raises(AllocationInputError, match='同市'):
allocate_plan(
[source(requires_transport=False)],
[permit('g', '30')],
[permit('t', '30', refs=('g',))],
manual_allocations=[ManualAllocation('transport', 's', 't', D(30), D(30))],
)
def test_one_purchase_and_two_transport_certificates():
result = allocate_plan(
[source()], [permit('g', '30')],
@@ -208,6 +242,13 @@ def test_settlement_pairing_29_consistent_passes():
assert validate_allocation_pairing([source(quantity='29')], actual_rows(plan.purchase_allocations), actual_rows(plan.transport_allocations), transports) is None
def test_same_city_settlement_does_not_require_transport_pairing():
from app.services.pl_quota_allocation import Allocation, validate_allocation_pairing
demand = source('city', quantity='10', requires_transport=False)
purchase = Allocation('purchase', 'city', 'g', D(10), D(10), 't', 't', 1)
assert validate_allocation_pairing([demand], [purchase], [], []) is None
@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
@@ -35,6 +35,17 @@ def test_sufficient_totals_do_not_claim_joint_feasibility():
assert all('引用' in row['reason'] for row in rows)
def test_same_city_shortfall_does_not_demand_a_transport_certificate():
from app.services.pl_quota_diagnostics import allocation_diagnostics, insufficient_quantity_message
rows = allocation_diagnostics(
[SourceDemand('a', 'a', D('30'), '吨', False)],
[permit('p', '10', ['a'])],
[],
)
assert [row['certType'] for row in rows] == ['purchase']
assert insufficient_quantity_message(rows) == '购买证剩余10.000吨,本次需要30.000吨。证件余量不足,不能安排运输,请先调整到有余量后再安排'
def test_missing_purchase_link_names_both_certificates():
from app.services.pl_quota_diagnostics import missing_purchase_link_message
source = SourceDemand('a', 'a', D('30'), '吨')
+15
View File
@@ -69,6 +69,21 @@ def test_arrange_uses_active_contract_cert_bindings_and_names_the_gap():
)
def test_same_city_stop_binds_purchase_certificate_only():
from app.services.pl_quota_planning import classify_contract_cert_bindings
source = SimpleNamespace(contract_id='c1', contract_product_id='line-1', requires_transport=False)
purchase = {
'certType': 'purchase', 'certId': 'buy-1', 'linkStatus': 'active',
'products': [{'contractProductId': 'line-1', 'scopeStatus': 'active'}],
}
labels = {'c1': 'HT-1'}
assert classify_contract_cert_bindings([source], {'c1': [purchase]}, labels) == (['buy-1'], [])
with pytest.raises(HTTPException) as error:
classify_contract_cert_bindings([source], {'c1': []}, labels)
assert error.value.status_code == 409
assert error.value.detail == '合同HT-1的同市段尚未绑定购买证,请先到合同中绑定后再安排运输'
def test_supplement_is_explicit_and_archive_public_schema_preserves_intent():
from app.schemas.pl.plan import PlanArchiveBody
from app.schemas.pl.quota_planning import PlanQuotaOperation
+19 -1
View File
@@ -245,7 +245,25 @@ def test_opening_consumed_and_expiry_vehicle_block(case):
p.opening_single_use_consumed=0; p.valid_to=date(2000,1,1); c.db.flush()
with pytest.raises(ValueError): run(c)
p.valid_to=date(2099,12,31); c.db.flush()
with pytest.raises(ValueError): run(c,vehicle_no='unknown')
with pytest.raises(ValueError, match='车号不在运输证范围'): run(c,vehicle_no='unknown')
def test_reserve_uses_transport_cert_that_lists_the_vehicle(case):
"""合同上多张运输证时,只用登记了本次主车号的证,不因其他证没有该车号而拒绝安排。"""
c = case
_second_pair(c)
c.db.get(c.m.PlPurchaseCert, 'p').total_qty = 100
c.db.get(c.m.PlTransportCert, 't').total_qty = 100
c.db.get(c.m.PlTransportCert, 't2').vehicle_numbers_json = ['OTHER']
c.db.flush()
run(c, purchase_cert_ids=['p', 'p2'], transport_cert_ids=['t', 't2'])
assert c.db.get(c.m.PlTransportCert, 't').reserved_qty == 29
assert c.db.get(c.m.PlTransportCert, 't2').reserved_qty == 0
assert c.db.get(c.m.PlPurchaseCert, 'p2').reserved_qty == 0
transport_usages = c.db.query(c.m.PlPlanCertUsage).filter_by(cert_type='transport', del_flag=0).all()
assert [u.cert_id for u in transport_usages if u.usage_status == 'reserved'] == ['t']
with pytest.raises(ValueError, match='车号不在运输证范围'):
run(c, operation_id='none', vehicle_no='NONE', purchase_cert_ids=['p', 'p2'], transport_cert_ids=['t', 't2'])
def test_no_cert_operation_is_still_idempotent(case):
c=case
@@ -0,0 +1,31 @@
-- 装货段、送货段增加「是否同市」。同市的易制毒段只校验、扣减购买证。
-- MySQL 5.7。可重复执行。历史数据默认 0,仍按购买证和运输证同时校验。
SET NAMES utf8mb4;
SET @schema_name = DATABASE();
SET @load_exists = (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'pl_plan_load' AND COLUMN_NAME = 'same_city'
);
SET @load_sql = IF(
@load_exists = 0,
'ALTER TABLE pl_plan_load ADD COLUMN same_city tinyint NOT NULL DEFAULT 0 COMMENT ''是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证'' AFTER dibang_direction',
'SELECT 1'
);
PREPARE load_stmt FROM @load_sql;
EXECUTE load_stmt;
DEALLOCATE PREPARE load_stmt;
SET @delivery_exists = (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'pl_plan_delivery' AND COLUMN_NAME = 'same_city'
);
SET @delivery_sql = IF(
@delivery_exists = 0,
'ALTER TABLE pl_plan_delivery ADD COLUMN same_city tinyint NOT NULL DEFAULT 0 COMMENT ''是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证'' AFTER dibang_direction',
'SELECT 1'
);
PREPARE delivery_stmt FROM @delivery_sql;
EXECUTE delivery_stmt;
DEALLOCATE PREPARE delivery_stmt;
+2
View File
@@ -26711,6 +26711,7 @@ CREATE TABLE `pl_plan_delivery` (
`notes` varchar(500) DEFAULT NULL COMMENT '注意事项',
`generate_dibang` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否生成地磅单 0-否 1-是',
`dibang_direction` varchar(20) DEFAULT NULL COMMENT '地磅方向 outbound-出库 inbound-入库 none-无',
`same_city` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证',
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
@@ -26794,6 +26795,7 @@ CREATE TABLE `pl_plan_load` (
`notes` varchar(500) DEFAULT NULL COMMENT '注意事项',
`generate_dibang` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否生成地磅单 0-否 1-是',
`dibang_direction` varchar(20) DEFAULT NULL COMMENT '地磅方向 outbound-出库 inbound-入库 none-无',
`same_city` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证',
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
+19
View File
@@ -3,6 +3,9 @@ import test from 'node:test'
import {
buyPartyName,
certFaceFields,
certKindLabel,
certKindTagType,
compareCertUploadTime,
expiryWarning,
salePartyName
} from '../src/views/pl/quota-cert/cert-list-face'
@@ -49,3 +52,19 @@ test('only certificates expiring within seven days show a red warning label', ()
assert.equal(expiryWarning('2026-11-12', today), null)
assert.equal(expiryWarning('', today), null)
})
test('merged certificate lists follow upload time, then id, descending', () => {
assert.equal(certKindLabel('transport'), '运输证')
assert.equal(certKindLabel('purchase'), '购买证')
assert.equal(certKindTagType('transport'), 'warning')
assert.equal(certKindTagType('purchase'), 'success')
const rows = [
{ id: 'b', createTime: '2026-11-01 10:00:00' },
{ id: 'a', createTime: '2026-11-05 10:00:00' },
{ id: 'c', createTime: '2026-11-05 10:00:00' }
].sort(compareCertUploadTime)
assert.deepEqual(
rows.map((row) => row.id),
['c', 'a', 'b']
)
})
+4
View File
@@ -480,6 +480,8 @@ export interface PlanLoad {
notes?: string
generateDibang?: number
dibangDirection?: string
/** 1 时本段易制毒只校验购买证 */
sameCity?: number
remark?: string
products?: PlanStopProduct[]
contracts?: PlanContractLink[]
@@ -502,6 +504,8 @@ export interface PlanDelivery {
notes?: string
generateDibang?: number
dibangDirection?: string
/** 1 时本段易制毒只校验购买证 */
sameCity?: number
remark?: string
deliveryMethod?: string
products?: PlanStopProduct[]
+2
View File
@@ -285,6 +285,8 @@ export interface QuotaSourceOption {
contractId: string
contractProductId: string
partyName?: string
/** 本段勾选同市时只要求购买证 */
sameCity?: boolean
}
export interface QuotaSourcePair {
sourceStopType: 'load' | 'delivery'
@@ -162,6 +162,15 @@
@update:model-value="patchDelivery(index, { notes: $event })"
/>
</el-form-item>
<el-form-item label="是否同市" class="form-col-span-2">
<el-checkbox
:model-value="delivery.sameCity === 1"
:disabled="readonly"
@update:model-value="(checked) => patchDelivery(index, { sameCity: checked ? 1 : 0 })"
>
同市只需购买证
</el-checkbox>
</el-form-item>
<el-form-item label="地磅单" class="form-col-span-2">
<div class="pl-plan-dibang-row">
<el-checkbox
@@ -714,6 +714,7 @@
mergePlanProductsFromLoadMirror,
shouldRetainPlanProductsWhileLoadEmpty,
resolveStopDibangDefaults,
toxicStopCertBindingMessage,
sumStopProductsPlanQty,
type PlanDeliveryForm,
type PlanLoadForm,
@@ -2619,19 +2620,7 @@
}
function toxicContractCertBindingMessage(rows: QuotaSourceOption[]) {
const missing = new Map<string, string>()
for (const row of rows) {
if (isPlInternalCustomerName(row.partyName)) continue
const certificates = row.certificates || []
const ready =
certificates.some((cert) => cert.certType === 'purchase') &&
certificates.some((cert) => cert.certType === 'transport')
if (ready) continue
const key = row.contractId || row.contractNo || row.productName || row.sourceStopProductId
missing.set(key, row.contractNo || '未编号合同')
}
if (!missing.size) return ''
return `合同${[...missing.values()].join('、')}尚未绑定购买证和运输证,请先到合同中绑定后再安排运输`
return toxicStopCertBindingMessage(rows)
}
async function performArrangeAfterSave(
@@ -110,6 +110,15 @@
@update:model-value="patch({ notes: $event })"
/>
</el-form-item>
<el-form-item label="是否同市" class="form-col-span-2">
<el-checkbox
:model-value="modelValue.sameCity === 1"
:disabled="readonly"
@update:model-value="patch({ sameCity: $event ? 1 : 0 })"
>
同市只需购买证
</el-checkbox>
</el-form-item>
<el-form-item label="地磅单" class="form-col-span-2">
<div class="pl-plan-dibang-row">
<el-checkbox
@@ -78,7 +78,15 @@
<section v-if="faceTabs.length" class="quota-face">
<h3 class="dialog-section-title">关联证件证面</h3>
<el-tabs v-model="faceTab" class="quota-face-tabs">
<el-tab-pane v-for="tab in faceTabs" :key="tab.key" :label="tab.label" :name="tab.key">
<el-tab-pane v-for="tab in faceTabs" :key="tab.key" :name="tab.key">
<template #label>
<span class="quota-kind-line">
<el-tag size="small" :type="certKindTagType(tab.kind)">{{
certKindLabel(tab.kind)
}}</el-tag>
<span>{{ tab.certNo }}</span>
</span>
</template>
<p v-if="!tab.cert" class="quota-note">没有读到这张证的证面档案。</p>
<template v-else>
<section
@@ -209,12 +217,9 @@
<div class="conversion-grid">
<section v-for="row in group.rows" :key="conversionKey(row)" class="conversion-card">
<div class="conversion-card-head">
<el-tag
size="small"
effect="plain"
:type="row.cert.certType === 'purchase' ? 'success' : 'primary'"
>{{ row.cert.certType === 'purchase' ? '购买证' : '运输证' }}</el-tag
>
<el-tag size="small" :type="certKindTagType(row.cert.certType)">{{
certKindLabel(row.cert.certType)
}}</el-tag>
<strong>{{ row.cert.certNo }}</strong>
</div>
<label>换算系数</label>
@@ -286,7 +291,12 @@
>
</div>
<div v-for="item in selected" :key="item.kind + item.cert.id" class="view-section">
<h4>{{ item.kind === 'purchase' ? '购买证' : '运输证' }} · {{ item.cert.certNo }}</h4>
<h4 class="quota-kind-line">
<el-tag size="small" :type="certKindTagType(item.kind)">{{
certKindLabel(item.kind)
}}</el-tag>
<span>{{ item.cert.certNo }}</span>
</h4>
<p
>{{ item.cert.buyerName || item.cert.shipperName }} →
{{ item.cert.sellerName || item.cert.consigneeName }};有效期
@@ -393,10 +403,12 @@
:key="row.certType + row.unit"
class="view-section"
>
<strong
>{{ row.certType === 'purchase' ? '购买证' : '运输证' }} · 需求
{{ qtyText(row.demand) }} {{ unitText(row.unit) }}</strong
>
<div class="quota-kind-line">
<el-tag size="small" :type="certKindTagType(row.certType)">{{
certKindLabel(row.certType)
}}</el-tag>
<strong>需求 {{ qtyText(row.demand) }} {{ unitText(row.unit) }}</strong>
</div>
<p v-if="row.availableUpperBound !== null">
适用余额至多 {{ qtyText(row.availableUpperBound) }}
{{ unitText(row.unit) }};缺口至少 {{ qtyText(row.gapLowerBound) }}
@@ -408,7 +420,13 @@
}}</p>
</div>
<el-table :data="allocations" stripe
><el-table-column prop="kind" label="证种" min-width="90" /><el-table-column
><el-table-column label="证种" min-width="110"
><template #default="{ row }"
><el-tag size="small" :type="row.kind === '运输证' ? 'warning' : 'success'">{{
row.kind
}}</el-tag></template
></el-table-column
><el-table-column
prop="certNo"
label="证号"
min-width="160" /><el-table-column
@@ -493,6 +511,7 @@
import { useUserStore } from '@/store/modules/user'
import { useDictStore } from '@/store/modules/dict'
import { getProductUnitLabel } from '@/utils/crm-product-display'
import { certKindLabel, certKindTagType } from '@/views/pl/quota-cert/cert-list-face'
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
@@ -631,7 +650,7 @@
kind,
cert,
certNo: number,
label: `${kind === 'purchase' ? '购买证' : '运输证'} ${number}`
label: `${certKindLabel(kind)} ${number}`
})
}
for (const row of plan.value?.stopProducts || []) {
@@ -1059,6 +1078,14 @@
})
</script>
<style scoped>
.quota-kind-line {
display: inline-flex;
gap: 8px;
align-items: center;
justify-content: flex-start;
max-width: 100%;
}
.quota-dialog :deep(.el-select) {
width: 100%;
}
@@ -2,7 +2,8 @@ import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
canManualToggleStopDibang,
resolveStopDibangDefaults
resolveStopDibangDefaults,
toxicStopCertBindingMessage
} from './pl-plan-stop-form'
import { isPlDibangOptionalVehiclePlate } from './pl-internal-customer'
@@ -23,6 +24,28 @@ describe('isPlDibangOptionalVehiclePlate', () => {
})
})
describe('toxicStopCertBindingMessage', () => {
it('same-city stop only requires the purchase certificate', () => {
assert.equal(
toxicStopCertBindingMessage([
{
contractId: 'c1',
contractNo: 'HT-1',
sameCity: 1,
certificates: [{ certType: 'purchase' }]
}
]),
''
)
assert.equal(
toxicStopCertBindingMessage([
{ contractId: 'c1', contractNo: 'HT-1', sameCity: true, certificates: [] }
]),
'合同HT-1的同市段尚未绑定购买证,请先到合同中绑定后再安排运输'
)
})
})
describe('resolveStopDibangDefaults + canManualToggleStopDibang', () => {
it('internal + normal vehicle: auto-check, not manual', () => {
assert.deepEqual(resolveStopDibangDefaults(INTERNAL, 'load', '皖A11111'), {
+55
View File
@@ -102,6 +102,8 @@ export interface PlanLoadForm {
notes: string
generateDibang: number
dibangDirection: string
/** 1 时本段易制毒只校验购买证 */
sameCity: number
contracts: PlanContractLink[]
products: PlanStopProductFormRow[]
}
@@ -119,6 +121,8 @@ export interface PlanDeliveryForm {
notes: string
generateDibang: number
dibangDirection: string
/** 1 时本段易制毒只校验购买证 */
sameCity: number
contracts: PlanContractLink[]
products: PlanStopProductFormRow[]
}
@@ -146,6 +150,49 @@ export function resolveStopDibangDefaults(
return { generateDibang: 1, dibangDirection: 'inbound' }
}
/** 安排前检查易制毒段的合同证件。同市段只要求购买证。 */
export function toxicStopCertBindingMessage(
rows: Array<{
partyName?: string | null
sameCity?: boolean | number | null
certificates?: { certType?: string | null }[] | null
contractId?: string | null
contractNo?: string | null
productName?: string | null
sourceStopProductId?: string | null
}>
): string {
const missingBoth = new Map<string, string>()
const missingPurchase = new Map<string, string>()
for (const row of rows) {
if (isPlInternalCustomerName(row.partyName)) continue
const certificates = row.certificates || []
const hasPurchase = certificates.some((cert) => cert.certType === 'purchase')
const hasTransport = certificates.some((cert) => cert.certType === 'transport')
const sameCity = row.sameCity === true || Number(row.sameCity) === 1
const key = row.contractId || row.contractNo || row.productName || row.sourceStopProductId || ''
const label = row.contractNo || '未编号合同'
if (sameCity) {
if (!hasPurchase) missingPurchase.set(key, label)
continue
}
if (hasPurchase && hasTransport) continue
missingBoth.set(key, label)
}
const parts: string[] = []
if (missingBoth.size) {
parts.push(
`合同${[...missingBoth.values()].join('、')}尚未绑定购买证和运输证,请先到合同中绑定后再安排运输`
)
}
if (missingPurchase.size) {
parts.push(
`合同${[...missingPurchase.values()].join('、')}的同市段尚未绑定购买证,请先到合同中绑定后再安排运输`
)
}
return parts.join(';')
}
/** 仅「内部客户 + 地磅可选车」允许用户手动勾选地磅单 */
export function canManualToggleStopDibang(
customerName?: string | null,
@@ -433,6 +480,7 @@ export function createEmptyLoadForm(): PlanLoadForm {
notes: '',
generateDibang: 0,
dibangDirection: 'none',
sameCity: 0,
contracts: [],
products: []
}
@@ -451,6 +499,7 @@ export function createEmptyDeliveryForm(seq = 1): PlanDeliveryForm {
notes: '',
generateDibang: 0,
dibangDirection: 'none',
sameCity: 0,
contracts: [],
products: []
}
@@ -540,6 +589,7 @@ export function mapLoadFromApi(
notes: load.notes || '',
generateDibang: dibang.generateDibang,
dibangDirection: dibang.dibangDirection,
sameCity: Number(load.sameCity) === 1 ? 1 : 0,
contracts: (load.contracts || []).map((item) => ({
contractId: String(item.contractId || ''),
contractNo: item.contractNo
@@ -574,6 +624,7 @@ export function mapDeliveriesFromApi(
notes: row.notes || '',
generateDibang: dibang.generateDibang,
dibangDirection: dibang.dibangDirection,
sameCity: Number(row.sameCity) === 1 ? 1 : 0,
contracts: (row.contracts || []).map((item) => ({
contractId: String(item.contractId || ''),
contractNo: item.contractNo
@@ -617,6 +668,7 @@ export function mapLegacyPlanToStops(plan: Plan): {
notes: plan.pickupNotes || '',
generateDibang: loadDefaults.generateDibang,
dibangDirection: loadDefaults.dibangDirection,
sameCity: 0,
contracts: (plan.supplierContracts || []).map((item) => ({
contractId: String(item.contractId || ''),
contractNo: item.contractNo
@@ -636,6 +688,7 @@ export function mapLegacyPlanToStops(plan: Plan): {
notes: plan.destinationNotes || '',
generateDibang: deliveryDefaults.generateDibang,
dibangDirection: deliveryDefaults.dibangDirection,
sameCity: 0,
contracts: (plan.demanderContracts || []).map((item) => ({
contractId: String(item.contractId || ''),
contractNo: item.contractNo
@@ -900,6 +953,7 @@ export function buildLoadPayload(load: PlanLoadForm) {
notes: load.notes || undefined,
generateDibang: load.generateDibang,
dibangDirection: load.dibangDirection,
sameCity: load.sameCity === 1 ? 1 : 0,
contracts: load.contracts,
products: load.products.map(buildStopProductPayload)
}
@@ -919,6 +973,7 @@ export function buildDeliveriesPayload(deliveries: PlanDeliveryForm[]) {
notes: row.notes || undefined,
generateDibang: row.generateDibang,
dibangDirection: row.dibangDirection,
sameCity: row.sameCity === 1 ? 1 : 0,
contracts: row.contracts,
products: row.products.map(buildStopProductPayload)
}))
@@ -16,8 +16,8 @@
<div class="mp-wv-card__no">{{ row.certNo }}</div>
<span
class="status-tag mp-cert-kind-tag"
:class="row.certType === 'transport' ? 'status-tag--warning' : 'status-tag--primary'"
>{{ row.certType === 'transport' ? '运输证' : '购买证' }}</span
:class="`status-tag--${certKindMobileTone(row.certType)}`"
>{{ certKindLabel(row.certType) }}</span
>
</div>
<span class="status-tag" :class="`status-tag--${statusTag(row.status)}`">{{
@@ -109,7 +109,14 @@
import CertConfirmSheet from './CertConfirmSheet.vue'
import { formatDateTime } from '@/utils/format'
import { formatCertQty } from '@/views/pl/quota-cert/model'
import { buyPartyName, expiryWarning, salePartyName } from '@/views/pl/quota-cert/cert-list-face'
import {
buyPartyName,
certKindLabel,
certKindMobileTone,
compareCertUploadTime,
expiryWarning,
salePartyName
} 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'
@@ -209,9 +216,11 @@
const kinds: CertType[] = props.kind ? [props.kind as CertType] : ['purchase', 'transport']
const lists = await Promise.all(kinds.map((item) => quotaCertApi.list(item, params)))
if (current !== epoch) return
rows.value = kinds.flatMap((item, index) =>
lists[index].map((row) => ({ ...row, certType: row.certType || item }))
)
rows.value = kinds
.flatMap((item, index) =>
lists[index].map((row) => ({ ...row, certType: row.certType || item }))
)
.sort(compareCertUploadTime)
} catch {
if (current === epoch) error.value = '证件加载失败,请重试'
} finally {
@@ -41,7 +41,14 @@
计划数量 {{ qtyText(group.source.quantity) }} {{ unitText(group.source.unit) }}
</p>
<div v-for="row in group.rows" :key="conversionKey(row)" class="mp-cert-factor">
<strong>{{ row.cert.certType === 'purchase' ? '购买证' : '运输证' }} {{ row.cert.certNo }}</strong>
<strong class="mp-cert-kind-heading">
<span
class="status-tag mp-cert-kind-tag"
:class="`status-tag--${certKindMobileTone(row.cert.certType)}`"
>{{ certKindLabel(row.cert.certType) }}</span
>
<span>{{ row.cert.certNo }}</span>
</strong>
<van-field
v-model="coefficients[conversionKey(row)]"
label="换算系数"
@@ -91,7 +98,14 @@
<section v-if="isPerStop && faceCards.length" class="mp-wv-section">
<h3>关联证件证面</h3>
<article v-for="face in faceCards" :id="faceDomId(face.key)" :key="face.key" class="mp-cert-face-card">
<h4>{{ face.label }}</h4>
<h4 class="mp-cert-kind-heading">
<span
class="status-tag mp-cert-kind-tag"
:class="`status-tag--${certKindMobileTone(face.kind)}`"
>{{ certKindLabel(face.kind) }}</span
>
<span>{{ face.certNo }}</span>
</h4>
<p v-if="!face.cert" class="plan-empty-hint">没有读到这张证的证面档案。</p>
<template v-else>
<div v-for="(group, groupIndex) in groupsOf(face.cert, face.kind)" :key="groupIndex">
@@ -158,6 +172,7 @@
sourceLabel,
unitConversions
} from '@/components/business/pl-plan-quota-dialog/stop-model'
import { certKindLabel, certKindMobileTone } from '@/views/pl/quota-cert/cert-list-face'
import { formatCertQty } from '@/views/pl/quota-cert/model'
import { useDictStore } from '@/store/modules/dict'
import { getProductUnitLabel } from '@/utils/crm-product-display'
@@ -190,7 +205,13 @@
const sourceOptions = computed(() => plan.value?.sourceOptions || [])
const faceCards = computed(() => {
const seen = new Set<string>()
const tabs: { key: string; label: string; kind: CertType; cert: QuotaCert | null }[] = []
const tabs: {
key: string
label: string
kind: CertType
certNo: string
cert: QuotaCert | null
}[] = []
const push = (kind: string, id: string, certNo: string) => {
if ((kind !== 'purchase' && kind !== 'transport') || !id || seen.has(`${kind}:${id}`)) return
seen.add(`${kind}:${id}`)
@@ -200,7 +221,8 @@
key: `${kind}:${id}`,
kind,
cert,
label: `${kind === 'purchase' ? '购买证' : '运输证'} ${number}`
certNo: number,
label: `${certKindLabel(kind)} ${number}`
})
}
for (const row of plan.value?.stopProducts || []) {
@@ -60,7 +60,14 @@
</button>
</template>
</template>
<p v-else class="mp-cert-link-note">{{ currentLabel }}</p>
<p v-else class="mp-cert-kind-heading">
<span
class="status-tag mp-cert-kind-tag"
:class="`status-tag--${certKindMobileTone(certType)}`"
>{{ certKindLabel(certType) }}</span
>
<span v-if="certNo">{{ certNo }}</span>
</p>
<template v-if="certId">
<h3>适用产品</h3>
@@ -139,6 +146,7 @@
type QuotaCert,
type ScopeProduct
} from '@/api/pl/quota-cert'
import { certKindLabel, certKindMobileTone } from '@/views/pl/quota-cert/cert-list-face'
import { bindingPayload, replaceCertProducts } from '@/views/pl/quota-cert/model'
import { getProductUnitLabel } from '@/utils/crm-product-display'
import { useDictStore } from '@/store/modules/dict'
@@ -178,9 +186,6 @@
let epoch = 0
let searchTimer = 0
const currentLabel = computed(() =>
[certType.value === 'purchase' ? '购买证' : '运输证', certNo.value].filter(Boolean).join(' ')
)
const pickerRows = computed(() => {
const used = new Set(draft.value.map((product) => product.contractProductId))
return lines.value.filter((row) => row.id && !used.has(row.id))
@@ -15,9 +15,13 @@
@click="open(row)"
>
<div class="mp-wv-card__head">
<div>
<div class="mp-cert-card__main">
<div class="mp-wv-card__no">{{ row.certNo }}</div>
<div class="mp-wv-card__sub">{{ row.certType === 'transport' ? '运输证' : '购买证' }}</div>
<span
class="status-tag mp-cert-kind-tag"
:class="`status-tag--${certKindMobileTone(row.certType)}`"
>{{ certKindLabel(row.certType) }}</span
>
</div>
<span class="status-tag" :class="`status-tag--${row.status === 'active' ? 'success' : 'info'}`">
{{ certListStatus(row.status) }}
@@ -61,7 +65,11 @@
import { useDictStore } from '@/store/modules/dict'
import { getProductUnitLabel } from '@/utils/crm-product-display'
import { certListStatus } from './usage-model'
import { expiryWarning } from '@/views/pl/quota-cert/cert-list-face'
import {
certKindLabel,
certKindMobileTone,
expiryWarning
} from '@/views/pl/quota-cert/cert-list-face'
const props = defineProps<{ keyword: string; kind: string }>()
const router = useRouter()
@@ -37,6 +37,24 @@
min-height: calc(100dvh - 132px);
}
.mp-cert-kind-tag {
display: inline-flex;
flex: 0 0 auto;
align-self: flex-start;
width: fit-content;
max-width: 100%;
white-space: nowrap;
vertical-align: middle;
}
.mp-cert-kind-heading {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: flex-start;
}
.mp-plan-cert-list .mp-wv-flat-card {
.mp-cert-card__main {
display: flex;
@@ -46,10 +64,7 @@
}
.mp-cert-kind-tag {
width: fit-content;
max-width: 100%;
margin-top: 4px;
white-space: nowrap;
}
.mp-wv-card__no {
@@ -48,7 +48,11 @@
</div>
</div>
<div class="mp-cert-tags">
<span class="status-tag status-tag--info">{{ cert.certType === 'purchase' ? '购买证' : '运输证' }}</span>
<span
class="status-tag mp-cert-kind-tag"
:class="`status-tag--${certKindMobileTone(cert.certType)}`"
>{{ certKindLabel(cert.certType) }}</span
>
<span class="status-tag" :class="`status-tag--${cert.linkStatus === 'active' ? 'success' : 'info'}`">
{{ linkLabel(cert.linkStatus) }}
</span>
@@ -145,7 +149,14 @@
<div class="mp-wv-card__kv">
<div class="mp-wv-card__kv-row"><span>规格</span><span>{{ row.model }}</span></div>
<div class="mp-wv-card__kv-row"><span>产品单位</span><span>{{ unit(row.unit) || '—' }}</span></div>
<div class="mp-wv-card__kv-row"><span>证件类型</span><span>{{ row.certKind }}</span></div>
<div class="mp-wv-card__kv-row">
<span>证件类型</span>
<span
class="status-tag mp-cert-kind-tag"
:class="`status-tag--${certKindMobileTone(row.certType)}`"
>{{ row.certKind }}</span
>
</div>
<div class="mp-wv-card__kv-row"><span>证件编号</span><span>{{ row.certNo }}</span></div>
<div class="mp-wv-card__kv-row"><span>证面单位</span><span>{{ unit(row.certUnit) || '—' }}</span></div>
<div class="mp-wv-card__kv-row"><span>产品预占</span><span>{{ qty(row.reservedQty) }}</span></div>
@@ -188,7 +199,11 @@
type StatisticsProduct
} from '@/api/pl/quota-cert'
import { bindingPayload, formatCertQty } from '@/views/pl/quota-cert/model'
import { certFaceFields } from '@/views/pl/quota-cert/cert-list-face'
import {
certFaceFields,
certKindLabel,
certKindMobileTone
} from '@/views/pl/quota-cert/cert-list-face'
import { contractTypeLabel } from '@/views/pl/quota-statistics/labels'
import { showCrmConfirmDialog } from '@/utils/mp-crm-confirm'
import CertLinkSheet from './CertLinkSheet.vue'
@@ -1,5 +1,5 @@
import type { ContractStatistics } from '@/api/pl/quota-cert'
import { certFaceFields, expiryWarning } from '@/views/pl/quota-cert/cert-list-face'
import { certFaceFields, certKindLabel, expiryWarning } from '@/views/pl/quota-cert/cert-list-face'
import { statusLabel } from '@/views/pl/quota-statistics/labels'
export function certDetailStatus(status?: string | null) {
@@ -19,7 +19,8 @@ export function contractProductRows(detail: ContractStatistics) {
productName: product.productName || '—',
model: product.model || '—',
unit: product.unit,
certKind: cert.certType === 'purchase' ? '购买证' : '运输证',
certType: cert.certType,
certKind: certKindLabel(cert.certType),
certNo: cert.certNo || '—',
certUnit: cert.quantityUnit,
reservedQty: product.reservedQty,
@@ -9,7 +9,14 @@
<div v-else-if="loading" class="mp-cert-page-loading"><van-loading /></div>
<template v-else-if="detail">
<section class="mp-wv-section">
<h3>{{ detail.certType === 'transport' ? '运输证' : '购买证' }} {{ detail.certNo }}</h3>
<h3 class="mp-cert-kind-heading">
<span
class="status-tag mp-cert-kind-tag"
:class="`status-tag--${certKindMobileTone(detail.certType)}`"
>{{ certKindLabel(detail.certType) }}</span
>
<span>{{ detail.certNo }}</span>
</h3>
<div class="detail-field-grid">
<div class="mp-wv-kv"><span>证面品名</span><span>{{ detail.productNameOnCert || '—' }}</span></div>
<div class="mp-wv-kv">
@@ -101,6 +108,7 @@
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
import { quotaStatisticsApi, type CertificateStatistics, type CertType } from '@/api/pl/quota-cert'
import { certKindLabel, certKindMobileTone } from '@/views/pl/quota-cert/cert-list-face'
import { formatCertQty } from '@/views/pl/quota-cert/model'
import { useDictStore } from '@/store/modules/dict'
import { getProductUnitLabel } from '@/utils/crm-product-display'
@@ -116,6 +116,13 @@
:readonly="c.props.readonly"
@update:model-value="c.patchDelivery(index, { notes: $event })"
/>
<van-cell class="mp-stop-toggle" title="是否同市" label="同市只需购买证"
><template #right-icon
><van-switch
:model-value="delivery.sameCity === 1"
:disabled="c.props.readonly"
@update:model-value="c.patchDelivery(index, { sameCity: $event ? 1 : 0 })" /></template
></van-cell>
<van-cell
class="mp-stop-toggle"
title="生成地磅单"
@@ -61,6 +61,13 @@
:readonly="c.props.readonly"
@update:model-value="c.patch({ notes: $event })"
/>
<van-cell class="mp-stop-toggle" title="是否同市" label="同市只需购买证"
><template #right-icon
><van-switch
:model-value="c.props.modelValue.sameCity === 1"
:disabled="c.props.readonly"
@update:model-value="c.patch({ sameCity: $event ? 1 : 0 })" /></template
></van-cell>
<van-cell class="mp-stop-toggle" title="生成地磅单" :label="c.dibangDirectionLabel"
><template #right-icon
><van-switch
@@ -34,6 +34,28 @@ export function certFaceFields(row: CertListFace) {
]
}
export function certKindLabel(certType?: string | null) {
return certType === 'transport' ? '运输证' : '购买证'
}
export function certKindTagType(certType?: string | null) {
return certType === 'transport' ? 'warning' : 'success'
}
export function certKindMobileTone(certType?: string | null) {
return certType === 'transport' ? 'warning' : 'primary'
}
export function compareCertUploadTime<T extends { createTime?: string | null; id?: string | null }>(
a: T,
b: T
) {
return (
String(b.createTime || '').localeCompare(String(a.createTime || '')) ||
String(b.id || '').localeCompare(String(a.id || ''))
)
}
export function expiryWarning(validTo?: string | null, today = new Date()) {
const target = calendarDate(validTo)
if (!target) return null
+11 -8
View File
@@ -66,8 +66,8 @@
><el-tag
v-else-if="column.prop === 'certType'"
size="small"
:type="row.certType === 'transport' ? 'warning' : 'success'"
>{{ row.certType === 'transport' ? '运输证' : '购买证' }}</el-tag
:type="certKindTagType(row.certType)"
>{{ certKindLabel(row.certType) }}</el-tag
><el-tag
v-else-if="column.prop === 'status'"
size="small"
@@ -629,7 +629,14 @@
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import CertContractBinder from './CertContractBinder.vue'
import { bindingsFromPurchaseCerts, matchPurchaseCert } from './contract-binder-rules'
import { buyPartyName, expiryWarning, salePartyName } from './cert-list-face'
import {
buyPartyName,
certKindLabel,
certKindTagType,
compareCertUploadTime,
expiryWarning,
salePartyName
} from './cert-list-face'
import ContractScopeDialog from './ContractScopeDialog.vue'
import SystemViewDialog, {
type ViewField,
@@ -1089,11 +1096,7 @@
rows.value = [
...purchases.map((row) => presentCert(row, 'purchase')),
...transports.map((row) => presentCert(row, 'transport'))
].sort(
(a, b) =>
String(b.createTime || '').localeCompare(String(a.createTime || '')) ||
String(b.id).localeCompare(String(a.id))
)
].sort(compareCertUploadTime)
} finally {
if (current === listEpoch && !disposed) loading.value = false
}
@@ -42,7 +42,10 @@
</el-select>
</el-form-item>
</el-form>
<p v-else class="quota-contract-cert-dialog__current">{{ currentCertLabel }}</p>
<p v-else class="quota-contract-cert-dialog__current">
<el-tag size="small" :type="certKindTagType(certType)">{{ certKindLabel(certType) }}</el-tag>
<span v-if="certNo">{{ certNo }}</span>
</p>
<CertContractBinder
v-if="ready && certId && contractId"
:key="binderKey"
@@ -68,6 +71,7 @@
type ScopeProduct
} from '@/api/pl/quota-cert'
import CertContractBinder from '../quota-cert/CertContractBinder.vue'
import { certKindLabel, certKindTagType } from '../quota-cert/cert-list-face'
import { bindingPayload, replaceCertProducts } from '../quota-cert/model'
const emit = defineEmits<{ saved: [] }>()
@@ -91,10 +95,6 @@
() =>
`${mode.value}|${certType.value}|${certId.value}|${savedProducts.value.map((row) => row.contractProductId).join(',')}`
)
const currentCertLabel = computed(() =>
[certType.value === 'purchase' ? '购买证' : '运输证', certNo.value].filter(Boolean).join(' ')
)
function certOptionLabel(item: QuotaCert) {
const name = [item.certNo, item.productNameOnCert].filter(Boolean).join(' · ')
if (item.status === 'void') return `${name}(已作废)`
@@ -209,6 +209,10 @@
<style scoped>
.quota-contract-cert-dialog__current {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
margin: 0 0 12px;
color: #1e293b;
font-weight: 600;
@@ -6,8 +6,15 @@
v-for="cert in contract?.certificates || []"
:key="certKey(cert)"
:name="certKey(cert)"
:label="certTabLabel(cert)"
>
<template #label>
<span class="quota-cert-tab-label">
<el-tag size="small" :type="certKindTagType(cert.certType)">{{
certKindLabel(cert.certType)
}}</el-tag>
<span>{{ cert.certNo }}</span>
</span>
</template>
<section class="quota-cert-summary">
<header class="quota-cert-summary__head">
<strong>{{ cert.certNo }}</strong>
@@ -16,8 +23,8 @@
有效期:{{ cert.validFrom || '—' }} 至 {{ cert.validTo || '—' }}
</span>
<div class="quota-cert-summary__tags">
<el-tag size="small">{{
cert.certType === 'purchase' ? '购买证' : '运输证'
<el-tag size="small" :type="certKindTagType(cert.certType)">{{
certKindLabel(cert.certType)
}}</el-tag>
<el-tag size="small" :type="cert.linkStatus === 'active' ? 'success' : 'info'">{{
linkLabel(cert.linkStatus)
@@ -90,7 +97,13 @@
<el-table-column prop="productName" label="产品" min-width="160" />
<el-table-column prop="model" label="规格" min-width="120" />
<el-table-column prop="unit" label="产品单位" min-width="100" />
<el-table-column prop="certKind" label="证件类型" min-width="100" />
<el-table-column label="证件类型" min-width="110">
<template #default="{ row }">
<el-tag size="small" :type="certKindTagType(row.certType)">{{
row.certKind
}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="certNo" label="证件编号" min-width="160" />
<el-table-column prop="certUnit" label="证面单位" min-width="100" />
<el-table-column label="产品预占" min-width="120"
@@ -230,7 +243,12 @@
import { useDictStore } from '@/store/modules/dict'
import { getProductUnitLabel } from '@/utils/crm-product-display'
import { formatCertQty } from '../quota-cert/model'
import { certFaceFields, expiryWarning } from '../quota-cert/cert-list-face'
import {
certFaceFields,
certKindLabel,
certKindTagType,
expiryWarning
} from '../quota-cert/cert-list-face'
import StatisticsProducts from './StatisticsProducts.vue'
import { statusLabel } from './labels'
const props = withDefaults(
@@ -252,10 +270,6 @@
function certKey(cert: { certType: string; certId: string }) {
return `${cert.certType}:${cert.certId}`
}
function certTabLabel(cert: ContractStatistics['certificates'][number]) {
const kind = cert.certType === 'purchase' ? '购买证' : '运输证'
return `${kind} ${cert.certNo || ''}`.trim()
}
watch(
() =>
props.mode === 'contract'
@@ -282,7 +296,8 @@
productName: product.productName || '—',
model: product.model || '—',
unit: unitLabel(product.unit) || '—',
certKind: cert.certType === 'purchase' ? '购买证' : '运输证',
certType: cert.certType,
certKind: certKindLabel(cert.certType),
certNo: cert.certNo || '—',
certUnit: unitLabel(cert.quantityUnit) || '—',
reservedQty: product.reservedQty,
@@ -475,4 +490,10 @@
}
}
}
.quota-cert-tab-label {
display: inline-flex;
gap: 6px;
align-items: center;
}
</style>
@@ -144,9 +144,11 @@
/> </div
></template>
<template #default="{ row }"
><span v-if="column.prop === 'certType'">{{
row.certType === 'transport' ? '运输证' : '购买证'
}}</span
><el-tag
v-if="column.prop === 'certType'"
size="small"
:type="certKindTagType(row.certType)"
>{{ certKindLabel(row.certType) }}</el-tag
><el-tag
v-else-if="column.prop === 'status'"
size="small"
@@ -205,7 +207,7 @@
import StatisticsDetail from './StatisticsDetail.vue'
import { createStatisticsLoader } from './loader'
import { statusLabel, contractTypeLabel } from './labels'
import { expiryWarning } from '../quota-cert/cert-list-face'
import { certKindLabel, certKindTagType, expiryWarning } from '../quota-cert/cert-list-face'
const props = withDefaults(
defineProps<{
mode: 'contract' | 'certificate'