Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
697075462a | ||
|
|
d5f3a79ea5 | ||
|
|
c5ae2d957c | ||
|
|
9f855423d8 | ||
|
|
a6fc0aa0f3 |
No files matched your search
@@ -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)
|
||||
# 用最终证面状态校验作废证不可扩大,不修改持久对象。
|
||||
|
||||
@@ -87,6 +87,9 @@ def _transport_derived_categories(db, user, transport_cert_id):
|
||||
|
||||
def get_cert(db, user, cert_type, cert_id, current=False):
|
||||
row = _get_row(db, user, cert_type, cert_id, lock=current)
|
||||
# 存量未期初确认的证件在读取时自动补期初0并启用(void/closed 除外)。
|
||||
if not current:
|
||||
row = ensure_zero_opening(db, user, cert_type, cert_id, row=row)
|
||||
result = _row_to_dict(row)
|
||||
result['usagePolicy'] = usage_policy(row.valid_use_text)
|
||||
opening_query = db.query(PlCertQtyLog.id).filter_by(
|
||||
@@ -98,7 +101,9 @@ def get_cert(db, user, cert_type, cert_id, current=False):
|
||||
result['availableQty'] = row.total_qty - row.used_qty - row.reserved_qty
|
||||
if cert_type == 'transport':
|
||||
result['purchaseRefs'] = [_row_to_dict(r) for r in _refs(db, user, cert_id, lock=current)]
|
||||
result['referencesEditable'] = not _has_history(db, user, cert_type, cert_id, lock=current)
|
||||
result['referencesEditable'] = not _has_usage_beyond_zero_opening(
|
||||
db, user, cert_type, cert_id, lock=current
|
||||
)
|
||||
result['derivedCategoryIds'] = _transport_derived_categories(db, user, cert_id)
|
||||
return result
|
||||
|
||||
@@ -169,8 +174,11 @@ def _refs_keep_verified(pending, removed):
|
||||
|
||||
|
||||
def _has_history(db, user, cert_type, cert_id, lock=True):
|
||||
"""任意额度流水或计划占用均视为已有历史(含自动期初),用于锁证号/核定量等身份。"""
|
||||
for model in (PlCertQtyLog, PlPlanCertUsage):
|
||||
query = db.query(model.id).filter_by(tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id)
|
||||
query = db.query(model.id).filter_by(
|
||||
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, del_flag=0,
|
||||
)
|
||||
if lock:
|
||||
query = query.with_for_update()
|
||||
if query.first() is not None:
|
||||
@@ -178,6 +186,35 @@ def _has_history(db, user, cert_type, cert_id, lock=True):
|
||||
return False
|
||||
|
||||
|
||||
def _has_usage_beyond_zero_opening(db, user, cert_type, cert_id, lock=True):
|
||||
"""真实用量/占用才锁运输证引用;期初已用=0 的 opening 仍可补全关联购买证。"""
|
||||
usage_q = db.query(PlPlanCertUsage.id).filter_by(
|
||||
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, del_flag=0,
|
||||
)
|
||||
if lock:
|
||||
usage_q = usage_q.with_for_update()
|
||||
if usage_q.first() is not None:
|
||||
return True
|
||||
log_q = db.query(PlCertQtyLog).filter_by(
|
||||
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, del_flag=0,
|
||||
)
|
||||
if lock:
|
||||
log_q = log_q.with_for_update()
|
||||
for log in log_q.all():
|
||||
snap = log.event_snapshot or {}
|
||||
# 仅建档自动期初(已用0)不锁引用;手工期初或其它流水仍锁。
|
||||
if (
|
||||
log.event_type == 'opening'
|
||||
and snap.get('auto')
|
||||
and (log.used_delta or 0) == 0
|
||||
and (log.reserved_delta or 0) == 0
|
||||
and (log.total_delta or 0) == 0
|
||||
):
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _validate_refs(db, user, cert_id, submitted, historical, purchases, current=False):
|
||||
if submitted is None:
|
||||
raise HTTPException(400, '引用集合不可为null;清空请提交[]')
|
||||
@@ -211,13 +248,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 +278,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))
|
||||
@@ -282,7 +319,8 @@ def prepare_cert(db, user, cert_type, data, cert_id=None, *, locked=None, effect
|
||||
if duplicate and duplicate.id != cert_id:
|
||||
raise HTTPException(400, '证号已存在,删除后也不得重建额度池')
|
||||
effective_id = cert_id or effective_id or str(uuid4())
|
||||
pending, removed = _validate_refs(db,user,effective_id,refs,historical,purchases,current=bool(cert_id)) if refs_present else ([],[])
|
||||
refs_locked = bool(row and _has_usage_beyond_zero_opening(db, user, cert_type, cert_id))
|
||||
pending, removed = _validate_refs(db,user,effective_id,refs,refs_locked,purchases,current=bool(cert_id)) if refs_present else ([],[])
|
||||
changed = {key for key, value in values.items() if row is None or getattr(row, key) != value}
|
||||
refs_changed = bool(removed) or any(previous is None or previous.del_flag == 1 or any(getattr(previous, key) != value for key, value in item.items() if key != 'id') for previous, item in pending)
|
||||
return dict(model=model, row=row, values=values, cert_id=cert_id, effective_id=effective_id,
|
||||
@@ -329,19 +367,98 @@ def apply_cert(db, user, prepared):
|
||||
return row
|
||||
|
||||
|
||||
def ensure_zero_opening(db, user, cert_type, cert_id, *, row=None):
|
||||
"""建档后无需手工期初确认:缺 opening 流水时写入期初已用=0;draft 直接启用。"""
|
||||
if row is None:
|
||||
row = _get_row(db, user, cert_type, cert_id)
|
||||
if row.status in ('void', 'closed'):
|
||||
return row
|
||||
existing = db.query(PlCertQtyLog.id).filter_by(
|
||||
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, event_type='opening', del_flag=0,
|
||||
).first()
|
||||
if existing:
|
||||
return row
|
||||
row = _get_row(db, user, cert_type, cert_id, lock=True)
|
||||
if row.status in ('void', 'closed'):
|
||||
return row
|
||||
if db.query(PlCertQtyLog.id).filter_by(
|
||||
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, event_type='opening', del_flag=0,
|
||||
).with_for_update().first():
|
||||
return row
|
||||
# 有效次数原文未识别时保留 draft,不自动启用;明确一次/多次后保存或读取再启用。
|
||||
if usage_policy(row.valid_use_text) == 'needs_confirmation':
|
||||
return row
|
||||
used = Decimal(row.used_qty or 0)
|
||||
op_id = f'auto-opening:{cert_type}:{cert_id}'
|
||||
digest = hashlib.sha256(op_id.encode()).hexdigest()
|
||||
operation, replay = _claim_operation(db, user, 'opening', op_id, digest)
|
||||
if replay:
|
||||
return row
|
||||
db.add(PlCertQtyLog(
|
||||
**_audit(user),
|
||||
operation_id=op_id,
|
||||
request_hash=digest,
|
||||
cert_type=cert_type,
|
||||
cert_id=cert_id,
|
||||
event_type='opening',
|
||||
quantity_unit=row.quantity_unit,
|
||||
total_delta=Decimal(0),
|
||||
reserved_delta=Decimal(0),
|
||||
used_delta=used,
|
||||
before_total_qty=row.total_qty,
|
||||
after_total_qty=row.total_qty,
|
||||
before_reserved_qty=row.reserved_qty,
|
||||
after_reserved_qty=row.reserved_qty,
|
||||
before_used_qty=used,
|
||||
after_used_qty=used,
|
||||
reason='建档自动启用',
|
||||
evidence_urls=None,
|
||||
event_snapshot={'singleUseConsumed': False, 'sourceNote': 'auto', 'auto': True},
|
||||
))
|
||||
if row.status == 'draft':
|
||||
row.status = 'active'
|
||||
row.verified_by = user.id
|
||||
row.verified_time = datetime.now()
|
||||
row.update_by = user.id
|
||||
operation.result_json = {'certType': cert_type, 'certId': cert_id, 'auto': True}
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def save_cert(db, user, cert_type, data, cert_id=None):
|
||||
row = apply_cert(db, user, prepare_cert(db, user, cert_type, data, cert_id))
|
||||
ensure_zero_opening(db, user, cert_type, row.id, row=row)
|
||||
return get_cert(db, user, cert_type, row.id)
|
||||
|
||||
|
||||
def delete_cert(db, user, cert_type, cert_id):
|
||||
row = _get_row(db,user,cert_type,cert_id,lock=True)
|
||||
if _has_history(db,user,cert_type,cert_id) or row.reserved_qty or row.used_qty:
|
||||
if row.reserved_qty or row.used_qty:
|
||||
raise HTTPException(400, '已有用量或流水的证件只能停用、核销或作废')
|
||||
if db.query(PlPlanCertUsage.id).filter_by(
|
||||
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, del_flag=0,
|
||||
).with_for_update().first():
|
||||
raise HTTPException(400, '已有用量或流水的证件只能停用、核销或作废')
|
||||
logs = (
|
||||
db.query(PlCertQtyLog)
|
||||
.filter_by(tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, del_flag=0)
|
||||
.with_for_update()
|
||||
.all()
|
||||
)
|
||||
# 仅建档自动期初(或无流水)仍允许删除;手工期初或其它额度流水则禁止。
|
||||
def _auto_opening(log):
|
||||
snap = log.event_snapshot or {}
|
||||
return log.event_type == 'opening' and bool(snap.get('auto')) and (log.used_delta or 0) == 0
|
||||
|
||||
if any(not _auto_opening(log) for log in logs):
|
||||
raise HTTPException(400, '已有用量或流水的证件只能停用、核销或作废')
|
||||
if db.query(PlContractCertLink.id).filter_by(tenant_no=user.tenant_no,cert_type=cert_type,cert_id=cert_id,del_flag=0).with_for_update().first():
|
||||
raise HTTPException(400, '证件已绑定合同')
|
||||
if cert_type == 'purchase' and db.query(PlTransportCertPurchaseRef.id).filter_by(tenant_no=user.tenant_no,purchase_cert_id=cert_id,del_flag=0).with_for_update().first():
|
||||
raise HTTPException(400, '证件被运输证引用')
|
||||
for log in logs:
|
||||
log.del_flag = 1
|
||||
log.update_by = user.id
|
||||
row.del_flag = 1
|
||||
row.update_by = user.id
|
||||
if cert_type == 'transport':
|
||||
@@ -388,7 +505,24 @@ def _confirm_cert(db, user, cert_type, cert_id, data):
|
||||
_assert_known_usage_policy(row)
|
||||
if row.status == 'void': raise HTTPException(400, '作废证件不能启用')
|
||||
if row.status == 'closed': raise HTTPException(400, '已核销证件不能启用')
|
||||
if row.verified_time or _has_history(db,user,cert_type,cert_id):
|
||||
# 建档自动期初(used=0)可被手工/接口期初覆盖;已有真实用量或非自动期初则拒绝。
|
||||
openings = (
|
||||
db.query(PlCertQtyLog)
|
||||
.filter_by(
|
||||
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id,
|
||||
event_type='opening', del_flag=0,
|
||||
)
|
||||
.with_for_update()
|
||||
.all()
|
||||
)
|
||||
for log in openings:
|
||||
snap = log.event_snapshot or {}
|
||||
if snap.get('auto') and (log.used_delta or 0) == 0:
|
||||
log.del_flag = 1
|
||||
log.update_by = user.id
|
||||
else:
|
||||
raise HTTPException(400, '期初已确认,不能重复初始化额度')
|
||||
if _has_history(db, user, cert_type, cert_id):
|
||||
raise HTTPException(400, '期初已确认,不能重复初始化额度')
|
||||
if body.used_qty > row.total_qty:
|
||||
raise HTTPException(400, '期初已用量不能超过核定量')
|
||||
|
||||
@@ -42,6 +42,16 @@ def _has_opening(db, user, cert_type, cert_id) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _ensure_opening_quiet(db, user, cert_type, cert_id, row):
|
||||
"""选证前尽量自动补期初;单元测试 mock 库失败时静默跳过。"""
|
||||
try:
|
||||
from app.services.pl_quota_cert_service import ensure_zero_opening
|
||||
|
||||
ensure_zero_opening(db, user, cert_type, cert_id, row=row)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def _vehicle_ok(cert, vehicle_no: str) -> bool:
|
||||
plates = cert.vehicle_numbers_json or []
|
||||
if not isinstance(plates, list):
|
||||
@@ -116,6 +126,7 @@ def select_whole_cover_certs(db, user, sources, *, vehicle_no: str, on_date: dat
|
||||
for purchase in candidates:
|
||||
if not purchase.verified_by or not purchase.verified_time:
|
||||
continue
|
||||
_ensure_opening_quiet(db, user, 'purchase', purchase.id, purchase)
|
||||
if not _has_opening(db, user, 'purchase', purchase.id):
|
||||
continue
|
||||
if not _in_force(purchase, on_date):
|
||||
@@ -165,6 +176,7 @@ def select_whole_cover_certs(db, user, sources, *, vehicle_no: str, on_date: dat
|
||||
for transport in transports:
|
||||
if not transport.verified_by or not transport.verified_time:
|
||||
continue
|
||||
_ensure_opening_quiet(db, user, 'transport', transport.id, transport)
|
||||
if not _has_opening(db, user, 'transport', transport.id):
|
||||
continue
|
||||
if not _in_force(transport, on_date):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -198,8 +198,12 @@ 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)
|
||||
from app.services.pl_quota_cert_service import ensure_zero_opening
|
||||
for k in sorted(selected):
|
||||
kind,pid=k; c=certs[k]
|
||||
c = certs[k] = ensure_zero_opening(db, user, kind, pid, row=c)
|
||||
if not query(m.PlCertQtyLog,cert_type=kind,cert_id=pid,event_type='opening',del_flag=0).first(): raise ValueError('证件尚未确认期初')
|
||||
if c.status=='closed': raise ValueError('已核销证件不能再安排用证')
|
||||
if c.status!='active' or not c.verified_time or not c.verified_by: raise ValueError('证件尚未启用复核')
|
||||
@@ -216,11 +220,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 +289,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 +321,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)
|
||||
|
||||
|
||||
@@ -340,6 +372,8 @@ ARCHIVE_QUOTA_BLOCK = '合同绑定的购买证和运输证剩余额度不够覆
|
||||
|
||||
def _settlement_cover_cert(db, user, r, plan, kind, cert):
|
||||
"""合同上另有证件时,不合格的证跳过,不因此挡住其余还能扣的证。"""
|
||||
from app.services.pl_quota_cert_service import ensure_zero_opening
|
||||
cert = ensure_zero_opening(db, user, kind, cert.id, row=cert)
|
||||
if not _query(db, user, m.PlCertQtyLog, cert_type=kind, cert_id=cert.id, event_type='opening', del_flag=0).first():
|
||||
return False
|
||||
if cert.status != 'active' or not cert.verified_time or not cert.verified_by:
|
||||
|
||||
@@ -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'})
|
||||
@@ -57,7 +57,7 @@ def test_permission_guards_validation_and_atomic_creation(api):
|
||||
saved = client.post(path, json=payload())
|
||||
assert saved.status_code == 200
|
||||
row = saved.json()
|
||||
assert row['tenantNo'] == 't' and row['status'] == 'draft' and row['openingConfirmed'] is False
|
||||
assert row['tenantNo'] == 't' and row['status'] == 'active' and row['openingConfirmed'] is True
|
||||
assert client.put(path + '/' + row['id'], json={'totalQty': '-1'}).status_code == 422
|
||||
assert client.get(path + '/' + row['id']).json()['totalQty'] == '30.000000'
|
||||
app.dependency_overrides[auth] = lambda: {'role': 'admin', 'user_id': 'u', 'tenant_no': 'other'}
|
||||
@@ -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 == []
|
||||
|
||||
@@ -130,7 +130,8 @@ def test_total_adjust_requires_provenance_and_replays_without_duplicate_log(api)
|
||||
assert response.status_code == 200
|
||||
assert client.post(path, json=body).status_code == 200
|
||||
logs = client.get(base + '/' + row['id'] + '/logs').json()
|
||||
assert len(logs) == 1 and logs[0]['totalDelta'] == '5.000000'
|
||||
adjust_logs = [item for item in logs if item.get('eventType') == 'total_adjust']
|
||||
assert len(adjust_logs) == 1 and adjust_logs[0]['totalDelta'] == '5.000000'
|
||||
assert client.post(path, json={**body,'totalQty':'40'}).status_code == 409
|
||||
assert client.post(path, json={**body,'operationId':'adjust-2','sourceNote':''}).status_code == 409
|
||||
assert client.get(base + '/' + row['id']).json()['totalQty'] == '35.000000'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -41,14 +41,31 @@ def transport(**kw):
|
||||
return data
|
||||
|
||||
|
||||
def test_save_auto_enables_with_zero_opening(ctx):
|
||||
"""建档即启用:期初已用默认 0,无需手工期初确认。"""
|
||||
db, u, s, m = ctx
|
||||
row = s.save_cert(db, u, 'purchase', purchase())
|
||||
assert row['status'] == 'active'
|
||||
assert row['openingConfirmed'] is True
|
||||
assert row['usedQty'] == Decimal('0')
|
||||
assert row['verifiedBy'] == 'u'
|
||||
assert row['verifiedTime'] is not None
|
||||
log = db.query(m.PlCertQtyLog).filter_by(del_flag=0, event_type='opening').one()
|
||||
assert (log.event_snapshot or {}).get('auto') is True
|
||||
transport_row = s.save_cert(db, u, 'transport', transport())
|
||||
assert transport_row['status'] == 'active' and transport_row['openingConfirmed'] is True
|
||||
|
||||
|
||||
def test_manual_optional_image_complete_fields(ctx):
|
||||
db,u,s,m = ctx
|
||||
data = purchase(buyerAddress='地址', buyerPhone='电话', buyerLegalRepresentative='甲', sellerAddress='乙址', sellerPhone='乙电话', sellerLegalRepresentative='乙', purchasePurpose='用途', issueDate='2026-01-01', validFrom='2026-01-01', validTo='2027-01-01')
|
||||
row = s.save_cert(db,u,'purchase',data)
|
||||
assert row['buyerAddress'] == '地址' and row['attachmentUrls'] is None
|
||||
assert row['totalQty'] == Decimal('100.123456')
|
||||
assert row['status'] == 'draft'
|
||||
assert db.query(m.PlCertQtyLog).count() == 0
|
||||
assert row['status'] == 'active'
|
||||
assert row['openingConfirmed'] is True
|
||||
assert row['usedQty'] == Decimal('0')
|
||||
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0, event_type='opening').count() == 1
|
||||
db.rollback()
|
||||
assert db.query(m.PlPurchaseCert).count() == 0
|
||||
|
||||
@@ -56,9 +73,8 @@ def test_manual_optional_image_complete_fields(ctx):
|
||||
def test_opening_confirmation_remains_visible_after_reverification_required(ctx):
|
||||
db, user, svc, models = ctx
|
||||
row = svc.save_cert(db, user, 'purchase', purchase())
|
||||
assert row['openingConfirmed'] is False
|
||||
row = svc.confirm_cert(db, user, 'purchase', row['id'], {
|
||||
'operationId': 'op-ui', 'usedQty': '0', 'singleUseConsumed': False, 'reason': '全新证'})
|
||||
assert row['openingConfirmed'] is True
|
||||
assert row['status'] == 'active'
|
||||
changed = svc.save_cert(db, user, 'purchase', {'buyerAddress': '更正地址'}, row['id'])
|
||||
assert changed['verifiedTime'] is None
|
||||
assert changed['openingConfirmed'] is True
|
||||
@@ -89,10 +105,12 @@ def test_reference_delta_omitted_empty_and_invalid_prewrite(ctx):
|
||||
def test_confirmation_and_history_protection(ctx):
|
||||
db,u,s,m=ctx
|
||||
row=s.save_cert(db,u,'purchase',purchase())
|
||||
assert row['status']=='active' and row['openingConfirmed'] is True
|
||||
with pytest.raises((HTTPException,ValidationError)): s.confirm_cert(db,u,'purchase',row['id'],{'usedQty':'0','singleUseConsumed':True,'reason':'核对','operationId':'op'})
|
||||
row=s.confirm_cert(db,u,'purchase',row['id'],{'usedQty':'0','singleUseConsumed':True,'reason':'核对','evidenceUrls':'https://minio/evidence','operationId':'op'})
|
||||
assert row['openingSingleUseConsumed']==1 and row['status']=='active'
|
||||
assert db.query(m.PlCertQtyLog).one().event_type=='opening'
|
||||
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).one().event_type=='opening'
|
||||
# 建档自动期初后身份已锁定;手工覆盖期初后同样不可改核定量/证号。
|
||||
for data in ({'quantityUnit':'千克'},{'certNo':'new'},{'totalQty':'90'}):
|
||||
with pytest.raises(HTTPException): s.save_cert(db,u,'purchase',data,row['id'])
|
||||
with pytest.raises(HTTPException): s.delete_cert(db,u,'purchase',row['id'])
|
||||
@@ -151,8 +169,9 @@ def test_opening_retry_preserves_balance_and_operation_identity(ctx):
|
||||
db.query(m.PlPurchaseCert).one().used_qty=Decimal('5')
|
||||
retry=s.confirm_cert(db,u,'purchase',row['id'],opening())
|
||||
assert retry['usedQty']==Decimal('5')
|
||||
assert db.query(m.PlCertQtyLog).count()==1
|
||||
assert db.query(m.PlCertOperation).count()==1
|
||||
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).count()==1
|
||||
# 自动期初操作号 + 手工期初操作号;重放不新增手工操作
|
||||
assert db.query(m.PlCertOperation).filter_by(operation_id='opening').count()==1
|
||||
with pytest.raises(HTTPException): s.confirm_cert(db,u,'purchase',row['id'],opening(usedQty='7'))
|
||||
with pytest.raises(HTTPException): s.confirm_cert(db,u,'purchase',row['id'],opening(operationId='second'))
|
||||
|
||||
@@ -209,10 +228,11 @@ def test_reverify_after_correction_keeps_opening_and_balances(ctx):
|
||||
assert corrected['status']=='suspended' and corrected['verifiedBy'] is None
|
||||
result=s.verify_cert(db,u,'purchase',row['id'],{'operationId':'verify-1','reason':'地址更正核对','evidenceUrls':'https://minio/evidence'})
|
||||
assert result['status']=='active' and result['usedQty']==Decimal('5')
|
||||
assert db.query(m.PlCertQtyLog).count()==1
|
||||
assert db.query(m.PlCertOperation).count()==2
|
||||
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).count()==1
|
||||
# 自动期初 + 手工期初 + 复核
|
||||
assert db.query(m.PlCertOperation).count()==3
|
||||
result=s.verify_cert(db,u,'purchase',row['id'],{'operationId':'verify-1','reason':'地址更正核对','evidenceUrls':'https://minio/evidence'})
|
||||
assert db.query(m.PlCertOperation).count()==2
|
||||
assert db.query(m.PlCertOperation).count()==3
|
||||
|
||||
|
||||
def test_actual_scope_denies_user_without_roles(ctx):
|
||||
@@ -261,7 +281,7 @@ def test_historical_opening_accepts_written_source_without_image(ctx):
|
||||
row=s.save_cert(db,u,'purchase',purchase())
|
||||
result=s.confirm_cert(db,u,'purchase',row['id'],{'operationId':'written-source','usedQty':'5','singleUseConsumed':True,'reason':'历史核对','sourceNote':'2026年10月纸质台账第3页,经张三核实'})
|
||||
assert result['usedQty']==Decimal('5') and result['status']=='active'
|
||||
assert db.query(m.PlCertQtyLog).one().event_snapshot['sourceNote'].startswith('2026')
|
||||
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).one().event_snapshot['sourceNote'].startswith('2026')
|
||||
|
||||
|
||||
def test_unknown_use_text_is_preserved_and_requires_confirmation(ctx):
|
||||
@@ -314,11 +334,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'},
|
||||
@@ -423,7 +443,7 @@ def test_opening_verify_stale_operation_snapshot_uses_unique_insert_arbitration(
|
||||
result=fn(db,u,'purchase',p['id'],body)
|
||||
assert missed and result['id']==p['id']
|
||||
assert db.query(m.PlCertOperation).count()==count
|
||||
assert db.query(m.PlCertQtyLog).count()==1
|
||||
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).count()==1
|
||||
|
||||
|
||||
def test_opening_and_verify_allow_blank_reason(ctx):
|
||||
@@ -431,7 +451,7 @@ def test_opening_and_verify_allow_blank_reason(ctx):
|
||||
p=s.save_cert(db,u,'purchase',purchase())
|
||||
row=s.confirm_cert(db,u,'purchase',p['id'],{**opening(),'reason':' '})
|
||||
assert row['status']=='active'
|
||||
assert db.query(m.PlCertQtyLog).one().reason==''
|
||||
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).one().reason==''
|
||||
s.save_cert(db,u,'purchase',{'buyerAddress':'更正地址'},p['id'])
|
||||
verified=s.verify_cert(db,u,'purchase',p['id'],{'operationId':'verify-blank','reason':''})
|
||||
assert verified['status']=='active' and verified['verifiedTime']
|
||||
@@ -440,10 +460,12 @@ def test_opening_and_verify_allow_blank_reason(ctx):
|
||||
def test_rejected_opening_rolls_back_claimed_operation(ctx):
|
||||
db,u,s,m=ctx
|
||||
p=s.save_cert(db,u,'purchase',purchase())
|
||||
auto_ops = db.query(m.PlCertOperation).count()
|
||||
auto_logs = db.query(m.PlCertQtyLog).filter_by(del_flag=0).count()
|
||||
with pytest.raises(HTTPException):
|
||||
s.confirm_cert(db,u,'purchase',p['id'],{**opening(),'usedQty':'999'})
|
||||
assert db.query(m.PlCertOperation).count()==0
|
||||
assert db.query(m.PlCertQtyLog).count()==0
|
||||
assert db.query(m.PlCertOperation).count()==auto_ops
|
||||
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).count()==auto_logs
|
||||
|
||||
|
||||
def test_replay_reads_current_certificate_and_opening_not_old_snapshot(ctx,monkeypatch):
|
||||
@@ -472,7 +494,7 @@ def test_closed_certificate_keeps_history_and_cannot_be_reopened(ctx):
|
||||
used = confirmed['usedQty']
|
||||
closed = s.save_cert(db, u, 'purchase', {'status': 'closed'}, row['id'])
|
||||
assert closed['status'] == 'closed' and closed['usedQty'] == used
|
||||
assert db.query(m.PlCertQtyLog).count() == 1
|
||||
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).count() == 1
|
||||
for action, payload in (
|
||||
('confirm', {**opening(), 'operationId': 'reopen-opening'}),
|
||||
('verify', {'operationId': 'reopen-verify', 'reason': '重新启用'}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -60,12 +60,12 @@
|
||||
>编辑</button
|
||||
>
|
||||
<button
|
||||
v-if="canEdit && row.status !== 'void' && row.status !== 'closed'"
|
||||
v-if="canEdit && row.openingConfirmed && row.status !== 'void' && row.status !== 'closed'"
|
||||
class="mp-wv-btn"
|
||||
type="button"
|
||||
@click="confirmRow = row"
|
||||
>
|
||||
{{ row.openingConfirmed ? '复核' : '期初确认' }}
|
||||
复核
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -8,37 +8,21 @@
|
||||
:close-on-click-overlay="!saving"
|
||||
@update:show="onShow"
|
||||
>
|
||||
<van-nav-bar
|
||||
:title="row?.openingConfirmed ? '复核证件' : '期初核对'"
|
||||
left-text="关闭"
|
||||
@click-left="!saving && emit('close')"
|
||||
/>
|
||||
<van-nav-bar title="复核证件" left-text="关闭" @click-left="!saving && emit('close')" />
|
||||
<div v-if="row" class="mp-cert-confirm-sheet__body mp-cert-form">
|
||||
<p class="plan-empty-hint">{{ row.certNo }} · {{ row.openingConfirmed ? '复核已启用的证件' : '核对历史用量后启用' }}</p>
|
||||
<template v-if="!row.openingConfirmed">
|
||||
<van-field v-model="opening.usedQty" label="历史已用量" label-align="top" type="number" :disabled="saving" />
|
||||
<van-cell title="一次有效证已有使用记录" center>
|
||||
<template #right-icon>
|
||||
<van-switch v-model="opening.singleUseConsumed" :disabled="saving" size="20px" />
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-field v-model="opening.sourceNote" label="历史来源说明" label-align="top" type="textarea" rows="2" :disabled="saving" />
|
||||
<p class="plan-empty-hint">核对凭据</p>
|
||||
<AttachmentUploadField v-model="opening.evidenceUrls" folder="pl/quota-cert" :disabled="saving" hint="" />
|
||||
</template>
|
||||
<p class="plan-empty-hint">{{ row.certNo }} · 复核已启用的证件</p>
|
||||
<van-field v-model="opening.reason" label="核对说明" label-align="top" type="textarea" rows="2" :disabled="saving" />
|
||||
</div>
|
||||
<footer>
|
||||
<button class="mp-wv-btn" type="button" :disabled="saving" @click="emit('close')">取消</button>
|
||||
<button class="mp-wv-btn mp-wv-btn--primary" type="button" :disabled="saving" @click="submit">
|
||||
{{ saving ? '正在提交…' : '确认启用' }}
|
||||
{{ saving ? '正在提交…' : '确认复核' }}
|
||||
</button>
|
||||
</footer>
|
||||
</van-popup>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
|
||||
import { quotaCertApi, type CertType, type QuotaCert } from '@/api/pl/quota-cert'
|
||||
import { randomOperationId } from '@/utils/random-id'
|
||||
import './cert-mobile.scss'
|
||||
@@ -76,19 +60,15 @@
|
||||
opening.reason = opening.reason.trim()
|
||||
saving.value = true
|
||||
try {
|
||||
if (props.row.openingConfirmed) {
|
||||
await quotaCertApi.verify(kind, props.row.id, {
|
||||
operationId: opening.operationId,
|
||||
reason: opening.reason
|
||||
})
|
||||
} else {
|
||||
await quotaCertApi.confirm(kind, props.row.id, { ...opening })
|
||||
}
|
||||
ElMessage.success(props.row.openingConfirmed ? '证件已复核' : '期初已确认')
|
||||
await quotaCertApi.verify(kind, props.row.id, {
|
||||
operationId: opening.operationId,
|
||||
reason: opening.reason
|
||||
})
|
||||
ElMessage.success('证件已复核')
|
||||
emit('saved')
|
||||
emit('close')
|
||||
} catch {
|
||||
ElMessage.error('核对没有提交,请重试')
|
||||
ElMessage.error('复核没有提交,请重试')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -93,11 +93,10 @@
|
||||
><el-icon><Edit /></el-icon>编辑</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="canEdit && row.status !== 'void' && row.status !== 'closed'"
|
||||
v-if="canEdit && row.openingConfirmed && row.status !== 'void' && row.status !== 'closed'"
|
||||
size="small"
|
||||
@click="openConfirm(row)"
|
||||
><el-icon><Check /></el-icon
|
||||
>{{ row.openingConfirmed ? '复核' : '期初确认' }}</el-button
|
||||
><el-icon><Check /></el-icon>复核</el-button
|
||||
>
|
||||
<el-button size="small" @click="view(row)"
|
||||
><el-icon><View /></el-icon>查看</el-button
|
||||
@@ -172,7 +171,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 +420,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"
|
||||
@@ -591,35 +590,12 @@
|
||||
</SystemViewDialog>
|
||||
<el-dialog
|
||||
v-model="confirmVisible"
|
||||
:title="confirmRow?.openingConfirmed ? '复核证件' : '期初核对'"
|
||||
title="复核证件"
|
||||
width="640px"
|
||||
align-center
|
||||
class="dialog-form-layout quota-cert-confirm-dialog"
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<template v-if="!confirmRow?.openingConfirmed">
|
||||
<div class="form-grid-2">
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
历史已用量(证面单位)<span v-if="confirmUnit" class="form-label-tip">{{
|
||||
confirmUnit
|
||||
}}</span>
|
||||
</template>
|
||||
<el-input v-model="opening.usedQty">
|
||||
<template v-if="confirmUnit" #append>{{ confirmUnit }}</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="一次有效证是否已有使用记录">
|
||||
<el-switch v-model="opening.singleUseConsumed" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="历史来源说明">
|
||||
<el-input v-model="opening.sourceNote" type="textarea" />
|
||||
</el-form-item>
|
||||
<el-form-item label="核对凭据">
|
||||
<AttachmentUploadField v-model="opening.evidenceUrls" folder="pl/quota-cert" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item label="核对说明"
|
||||
><el-input v-model="opening.reason" type="textarea"
|
||||
/></el-form-item>
|
||||
@@ -628,7 +604,7 @@
|
||||
><el-button @click="confirmVisible = false"
|
||||
><el-icon><Close /></el-icon>取消</el-button
|
||||
><el-button type="primary" :loading="saving" @click="confirm"
|
||||
><el-icon><Check /></el-icon>确认启用</el-button
|
||||
><el-icon><Check /></el-icon>确认复核</el-button
|
||||
></template
|
||||
>
|
||||
</el-dialog>
|
||||
@@ -808,23 +784,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 +1039,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 +1606,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 +1729,7 @@
|
||||
}
|
||||
function referencesLocked() {
|
||||
if (form.value.referencesEditable !== false) return false
|
||||
ElMessage.warning('已有额度流水,不能新增或移除证面引用')
|
||||
ElMessage.warning('已有额度流水,不能新增或移除关联购买证')
|
||||
return true
|
||||
}
|
||||
function addReference() {
|
||||
@@ -1798,12 +1781,10 @@
|
||||
saving.value = true
|
||||
try {
|
||||
const targetKind = rowCertType(confirmRow.value)
|
||||
if (confirmRow.value.openingConfirmed)
|
||||
await quotaCertApi.verify(targetKind, confirmRow.value.id, {
|
||||
operationId: opening.operationId,
|
||||
reason: opening.reason
|
||||
})
|
||||
else await quotaCertApi.confirm(targetKind, confirmRow.value.id, opening)
|
||||
await quotaCertApi.verify(targetKind, confirmRow.value.id, {
|
||||
operationId: opening.operationId,
|
||||
reason: opening.reason
|
||||
})
|
||||
confirmVisible.value = false
|
||||
await load()
|
||||
} finally {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user