Compare commits

...
Author SHA1 Message Date
wuzilongandCursor be0ba622a0 fix(pl): 证件额度切点改为按首次安排时间并冻结版本。
切点前已安排的计划归档跳过购买证/运输证校验;创建较早但安排在生效后的计划走客户+品类新规则。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 19:11:02 +08:00
wuzilongandCursor f865d8ed99 fix(pl): 证用品类差量复用软删关联,并修正归档用证结转与证面品类展示。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 18:52:33 +08:00
wuzilong 6f20c0ff8a merge: issue #39 安排运输购买证失败提示补全客户与产品。 2026-11-15 16:57:52 +08:00
30 changed files with 702 additions and 86 deletions

No files matched your search

+3 -6
View File
@@ -2206,8 +2206,7 @@ def create_plan(
create_by=user.id,
create_dept_id=user.dept_id,
)
from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_version
stamp_plan_rule_version(db, user, plan)
# 证件额度规则版本在首次安排时按生效时间打标,创建不打标。
db.add(plan)
# 添加产品明细
@@ -2379,8 +2378,7 @@ def create_plan_full(
create_by=user.id,
create_dept_id=user.dept_id,
)
from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_version
stamp_plan_rule_version(db, user, plan)
# 证件额度规则版本在首次安排时按生效时间打标,创建不打标。
db.add(plan)
db.flush()
@@ -2531,8 +2529,7 @@ def create_from_contract(
create_by=user.id,
create_dept_id=user.dept_id,
)
from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_version
stamp_plan_rule_version(db, user, plan)
# 证件额度规则版本在首次安排时按生效时间打标,创建不打标。
db.add(plan)
# 查询合同产品明细
+5
View File
@@ -888,7 +888,10 @@ def arrange_transport(
)
apply_resource_snapshot_json(plan=plan, transport=transport, snapshot=snapshot)
from app.services.pl_quota_planning import pin_measurement_version
from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_on_arrange
pin_measurement_version(db, user, plan)
# 首次安排按生效时间打标并冻结;改派不重打。
stamp_plan_rule_on_arrange(db, user, plan)
plan.transport_status = "arranged"
plan.follow_status = "arranged"
plan.freight_amount = freight_amount or 0
@@ -1304,11 +1307,13 @@ def process_approval(
transport.carrier_name = vehicle_info['carrier_name']
snapshot = build_transport_resource_snapshot(db, tenant_no, transport.vehicle_id,
selected_driver_id=transport.driver_id, selected_escort_id=transport.escort_id)
from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_on_arrange
for plan in plans:
assert_pl_row_in_scope(db, plan, PlPlan, user, tenant_no)
plan.vehicle_id = transport.vehicle_id
plan.license_plate = transport.license_plate
apply_resource_snapshot_json(plan=plan, transport=transport, snapshot=snapshot)
stamp_plan_rule_on_arrange(db, user, plan)
apply_plan_quota(db, user, plan, "reserve", payload)
plan.transport_status = "arranged"
plan.follow_status = "arranged"
+2 -1
View File
@@ -370,7 +370,8 @@ class PlPlan(Base):
quota_cert_waived_by = Column(String(64), nullable=True, comment="证书齐备确认人ID")
quota_cert_waived_by_name = Column(String(100), nullable=True, comment="证书齐备确认人姓名")
quota_cert_waived_time = Column(DateTime, nullable=True, comment="证书齐备确认时间")
cert_quota_rule_version = Column(String(32), nullable=True, comment="证件额度规则版本 legacy|customer_category;创建时打标")
cert_quota_rule_version = Column(String(32), nullable=True, comment="证件额度规则版本 legacy|customer_category;首次安排时打标并冻结")
cert_quota_arranged_at = Column(DateTime, nullable=True, comment="首次安排时间(证件额度切点用);再次安排不改")
quota_cert_pending_fulfill = Column(SmallInteger, nullable=False, default=0, comment="新规则证书齐备待完善 0-否 1-是")
erp_entered = Column(SmallInteger, nullable=False, default=0, comment="是否已录入ERP 0-否 1-是")
cancel_reason = Column(String(500), nullable=True, comment="作废原因")
+2 -1
View File
@@ -417,7 +417,8 @@ class PlanRead(PlanBase, PlanResourceSnapshotMixin):
quota_cert_waived_by: Optional[str] = Field(default=None, alias="quotaCertWaivedBy", description="证书齐备确认人ID")
quota_cert_waived_by_name: Optional[str] = Field(default=None, alias="quotaCertWaivedByName", description="证书齐备确认人姓名")
quota_cert_waived_time: Optional[datetime] = Field(default=None, alias="quotaCertWaivedTime", description="证书齐备确认时间")
cert_quota_rule_version: Optional[str] = Field(default=None, alias="certQuotaRuleVersion", description="legacy|customer_category")
cert_quota_rule_version: Optional[str] = Field(default=None, alias="certQuotaRuleVersion", description="legacy|customer_category;首次安排打标")
cert_quota_arranged_at: Optional[datetime] = Field(default=None, alias="certQuotaArrangedAt", description="首次安排时间(证件额度切点)")
quota_cert_pending_fulfill: int = Field(default=0, alias="quotaCertPendingFulfill", description="新规则齐备待完善 0否 1是")
erp_entered: int = Field(default=0, alias="erpEntered", description="是否已录入ERP 0-否 1-是")
cancel_reason: Optional[str] = Field(default=None, alias="cancelReason", description="作废原因")
@@ -127,12 +127,16 @@ def save_category(db: Session, user, data: dict, category_id=None):
submitted = data['products']
if submitted is None:
raise HTTPException(400, '产品关联集合不可为 null;清空请提交 []')
existing = {
r.id: r
for r in db.query(PlCertCategoryProduct).filter_by(
tenant_no=user.tenant_no, category_id=row.id, del_flag=0,
).with_for_update().populate_existing().all()
}
# uk_cert_category_product 不含 del_flag,软删行仍占唯一键;无 id 再加须复用。
all_links = (
db.query(PlCertCategoryProduct)
.filter_by(tenant_no=user.tenant_no, category_id=row.id)
.with_for_update()
.populate_existing()
.all()
)
existing = {r.id: r for r in all_links if r.del_flag == 0}
by_product = {r.product_id: r for r in all_links}
seen = set()
product_ids = set()
for item in submitted:
@@ -150,20 +154,46 @@ def save_category(db: Session, user, data: dict, category_id=None):
raise HTTPException(400, '产品关联明细不属于当前品类')
if rid in seen:
raise HTTPException(400, '产品关联明细 ID 重复')
if pid != link.product_id:
conflict = by_product.get(pid)
if conflict is not None and conflict.id != link.id:
if conflict.del_flag == 0:
raise HTTPException(400, '同一品类不能重复关联同一产品')
# 目标 product_id 被软删行占用:恢复该行并软删当前行
conflict.del_flag = 0
conflict.scope_status = scope
conflict.update_by = user.id
link.del_flag = 1
link.update_by = user.id
seen.add(conflict.id)
continue
if by_product.get(link.product_id) is link:
del by_product[link.product_id]
link.product_id = pid
by_product[pid] = link
seen.add(rid)
link.product_id = pid
link.scope_status = scope
link.update_by = user.id
else:
link = PlCertCategoryProduct(
**_audit(user),
category_id=row.id,
product_id=pid,
scope_status=scope,
)
db.add(link)
# 关联表业务键 (category_id, product_id):复用有效或软删行,避免 uk 冲突
link = by_product.get(pid)
if link is not None:
if link.del_flag == 0:
seen.add(link.id)
link.del_flag = 0
link.scope_status = scope
link.update_by = user.id
else:
link = PlCertCategoryProduct(
**_audit(user),
category_id=row.id,
product_id=pid,
scope_status=scope,
)
db.add(link)
by_product[pid] = link
for rid, link in existing.items():
if rid not in seen:
if rid not in seen and link.del_flag == 0:
link.del_flag = 1
link.update_by = user.id
db.flush()
@@ -1,4 +1,4 @@
"""客户品类额度规则切点:系统信息配置;未配置则拦住安排。"""
"""客户品类额度规则切点:按首次安排时间打标;切点前已安排归档跳过校证。"""
from datetime import datetime
from typing import Optional
from uuid import uuid4
@@ -58,20 +58,77 @@ def assert_cert_quota_rule_configured(db: Session, tenant_no: str) -> datetime:
return effective_at
def resolve_rule_version_for_create(db: Session, tenant_no: str, create_time: Optional[datetime] = None) -> str:
"""计划创建时打标;未配置生效时间时仍标 legacy(安排会被拦住)。"""
def resolve_rule_version_for_arrange(
db: Session, tenant_no: str, arrange_time: Optional[datetime] = None
) -> str:
"""首次安排时打标:安排时刻 >= 生效时间 → 新规则,否则祖父 legacy。"""
effective_at = get_effective_at(db, tenant_no)
when = create_time or datetime.now()
when = arrange_time or datetime.now()
if effective_at is not None and when >= effective_at:
return VERSION_CUSTOMER_CATEGORY
return VERSION_LEGACY
def stamp_plan_rule_version(db: Session, user, plan) -> str:
version = resolve_rule_version_for_create(db, user.tenant_no, getattr(plan, 'create_time', None) or datetime.now())
# 兼容旧测试/调用名;语义已改为安排时刻。
resolve_rule_version_for_create = resolve_rule_version_for_arrange
def stamp_plan_rule_on_arrange(
db: Session, user, plan, *, arrange_time: Optional[datetime] = None
) -> str:
"""首次安排写入版本与安排时刻;再次安排冻结,不改版本。"""
if getattr(plan, 'cert_quota_arranged_at', None) is not None:
return getattr(plan, 'cert_quota_rule_version', None) or VERSION_LEGACY
when = arrange_time or datetime.now()
version = resolve_rule_version_for_arrange(db, user.tenant_no, when)
plan.cert_quota_arranged_at = when
plan.cert_quota_rule_version = version
return version
def stamp_plan_rule_version(db: Session, user, plan) -> str:
"""兼容旧名:等价于首次安排打标(创建路径不应再调用)。"""
return stamp_plan_rule_on_arrange(db, user, plan)
def plan_uses_customer_category(plan) -> bool:
return (getattr(plan, 'cert_quota_rule_version', None) or VERSION_LEGACY) == VERSION_CUSTOMER_CATEGORY
def infer_arranged_at(db: Session, plan) -> Optional[datetime]:
"""历史缺字段时用关联运输单创建时间推断首次安排。"""
stamped = getattr(plan, 'cert_quota_arranged_at', None)
if stamped is not None:
return stamped
transport_id = getattr(plan, 'transport_id', None)
tenant_no = getattr(plan, 'tenant_no', None)
if not transport_id or not tenant_no or db is None:
return None
from app.models.pl_models import PlTransport
transport = (
db.query(PlTransport)
.filter_by(id=transport_id, tenant_no=tenant_no, del_flag=0)
.first()
)
return getattr(transport, 'create_time', None) if transport else None
def plan_skips_archive_cert_quota(plan, db: Session = None, tenant_no: str = None) -> bool:
"""首次安排早于生效时间则归档跳过校证。
优先比较安排时刻与生效时间;若无生效配置或无法得到安排时刻,则不因空版本自动跳过
(避免误伤仍走合同绑证结转的历史路径;迁移/安排打标会补齐 arranged_at)。
"""
tn = tenant_no or getattr(plan, 'tenant_no', None)
arranged_at = getattr(plan, 'cert_quota_arranged_at', None)
if arranged_at is None and db is not None:
arranged_at = infer_arranged_at(db, plan)
if arranged_at is None:
return False
if db is not None and tn:
effective_at = get_effective_at(db, tn)
if effective_at is not None:
return arranged_at < effective_at
# 已记录首次安排且版本为 legacy(安排时未达生效点)→ 跳过
return not plan_uses_customer_category(plan)
+2
View File
@@ -1166,7 +1166,9 @@ def create_transport_for_plan(
escort_id=escort_id,
)
from app.services.pl_quota_planning import pin_measurement_version
from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_on_arrange
pin_measurement_version(db, user, plan)
stamp_plan_rule_on_arrange(db, user, plan)
plan.transport_status = "arranged"
plan.follow_status = "arranged"
plan.freight_amount = freight_amount or 0
@@ -337,7 +337,11 @@ def settle_allocations(reserved: Sequence[Allocation], actual_by_source: Mapping
异单位来源须逐行重新确认全部实际量(含零量行),不按预占比例推算。
超来源量或超任一证件预占量均要求先补充分配。
同市等仅购买证场景下,调用方仍可能对本函数传入空的运输证分组与全量实运量;
无预占行时直接空结转,不把「缺实际量」套到另一类证件上。
"""
if not reserved:
return ()
keys, seqs, grouped = set(), set(), {}
kinds = {r.permit_kind for r in reserved}
if len(kinds) > 1 or kinds - {'purchase', 'transport'}:
+11 -2
View File
@@ -120,12 +120,21 @@ def apply_plan_quota(db, user, plan, action, payload=None):
evidenceUrls=getattr(payload, 'quota_evidence_urls', None),
sourceNote=getattr(payload, 'quota_source_note', None))
from app.services.pl_quota_waiver import assert_waived_sources, quota_cert_waived
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
from app.services.pl_cert_quota_rule_config_service import (
plan_skips_archive_cert_quota,
plan_uses_customer_category,
)
# 新规则:齐备仅放行安排/改派预占;归档与实际调整必须真证结转。
# 旧规则(legacy):齐备仍跳过安排与归档(祖父兼容)。
# 切点前首次安排:归档/实调不校证件(无需齐备);齐备仍可跳过安排预占。
skip_actions = ('reserve', 'resize')
if not plan_uses_customer_category(plan):
skip_actions = ('reserve', 'resize', 'settle', 'actual_adjust')
grandfather_archive_skip = (
action in ('settle', 'actual_adjust')
and plan_skips_archive_cert_quota(plan, db, getattr(user, 'tenant_no', None))
)
if grandfather_archive_skip:
return {'operationId': ledger_id, 'usageIds': [], 'waived': True, 'skippedCertQuota': True}
if quota_cert_waived(plan) and action in skip_actions:
if action in ('reserve', 'resize'):
assert_waived_sources(db, user, plan, lock=True)
+29 -2
View File
@@ -136,6 +136,26 @@ def _customer_display_name(db, user, customer_id):
return (row[0] if row else None) or None
def _category_display_names(db, user, category_ids):
"""按证用品类 ID 顺序返回启用中的品类名称;未知 ID 跳过。"""
from app.models.pl_quota_models import PlCertCategory
ids = [str(item or '').strip() for item in (category_ids or []) if str(item or '').strip()]
if not ids:
return []
rows = (
db.query(PlCertCategory.id, PlCertCategory.category_name)
.filter(
PlCertCategory.tenant_no == user.tenant_no,
PlCertCategory.del_flag == 0,
PlCertCategory.id.in_(ids),
)
.all()
)
names = {row.id: (row.category_name or '').strip() for row in rows}
return [names[item] for item in ids if names.get(item)]
def get_cert(db, user, cert_type, cert_id, current=False):
row = _get_row(db, user, cert_type, cert_id, lock=current)
# 只读列表/详情不得在未提交事务里改状态,否则会出现「页面已启用、库仍 draft」。
@@ -151,12 +171,17 @@ def get_cert(db, user, cert_type, cert_id, current=False):
result['availableQty'] = row.total_qty - row.used_qty - row.reserved_qty
result['singleUseConsumed'] = is_single_use_consumed(db, user, cert_type, row)
result['customerName'] = _customer_display_name(db, user, getattr(row, 'customer_id', None))
if cert_type == 'purchase':
names = _category_display_names(db, user, [getattr(row, 'category_id', None)])
result['categoryName'] = names[0] if names else None
if cert_type == 'transport':
result['purchaseRefs'] = [_row_to_dict(r) for r in _refs(db, user, cert_id, lock=current)]
result['referencesEditable'] = not _has_usage_beyond_zero_opening(
db, user, cert_type, cert_id, lock=current
)
result['derivedCategoryIds'] = _transport_derived_categories(db, user, cert_id)
derived_ids = _transport_derived_categories(db, user, cert_id)
result['derivedCategoryIds'] = derived_ids
result['derivedCategoryNames'] = _category_display_names(db, user, derived_ids)
return result
@@ -182,7 +207,9 @@ def list_certs(db, user, cert_type, keyword=None, status=None, customer_id=None)
result = [get_cert(db, user, cert_type, r.id) for r in query.order_by(model.create_time.desc()).all()]
if cert_type == 'transport':
for item in result:
item['derivedCategoryIds'] = _transport_derived_categories(db, user, item['id'])
derived_ids = _transport_derived_categories(db, user, item['id'])
item['derivedCategoryIds'] = derived_ids
item['derivedCategoryNames'] = _category_display_names(db, user, derived_ids)
return result
+11 -1
View File
@@ -675,7 +675,17 @@ def _settle(db,user,r,certs,usages,items,plan):
for kind,rows in settled.items():
for row in rows: base_totals[(kind,row.allocation.permit_id)]+=row.actual_permit_quantity
credits={k:SimpleNamespace(reserved_qty=u.reserved_qty+u.used_qty-base_totals[k]) for k,u in usages.items()}
extra_request=r.model_copy(update={'sources':[source.model_copy(update={'quantity':excess[source.source_id]}) for source in r.sources if source.source_id in excess]})
# 超额补扣需求仅为超出预占的部分。安排/完善并关闭写入的 manual_allocations
# 是整笔计划量,若原样带入会与超额需求冲突(证件明明够却报额度不足)。
extra_update = {
'sources': [
source.model_copy(update={'quantity': excess[source.source_id]})
for source in r.sources if source.source_id in excess
],
}
if auto_cover:
extra_update['manual_allocations'] = []
extra_request = r.model_copy(update=extra_update)
try:
result=_reserve(db,user,extra_request,plan,certs,credits,{},selected,preview=True,lock_preview=True)
except InsufficientQuota as exc:
@@ -0,0 +1,117 @@
"""证用品类产品关联差量保存:软删后再加回须复用唯一键行。"""
from datetime import datetime
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.models.pl_quota_models import PlCertCategory, PlCertCategoryProduct
from app.services import pl_cert_category_service as svc
@pytest.fixture
def db(monkeypatch):
engine = create_engine('sqlite://')
PlCertCategory.__table__.create(engine)
PlCertCategoryProduct.__table__.create(engine)
monkeypatch.setattr(svc, '_product_labels', lambda *a, **k: {})
with Session(engine) as session:
yield session
engine.dispose()
def user(tenant='t', uid='editor'):
return SimpleNamespace(id=uid, tenant_no=tenant, dept_id='dept')
def seed_category(db, id='cat', **extra):
values = dict(
id=id, category_name='硫酸', category_code='H2SO4', status='1',
tenant_no='t', create_by='creator', create_dept_id='dept',
create_time=datetime(2020, 1, 1), del_flag=0,
)
values.update(extra)
row = PlCertCategory(**values)
db.add(row)
db.flush()
return row
def seed_link(db, id='link', product_id='339E64E02E7C41F3', del_flag=0, **extra):
values = dict(
id=id, category_id='cat', product_id=product_id, scope_status='active',
tenant_no='t', create_by='creator', create_dept_id='dept',
create_time=datetime(2020, 1, 1), del_flag=del_flag,
)
values.update(extra)
row = PlCertCategoryProduct(**values)
db.add(row)
db.flush()
return row
def test_readd_soft_deleted_product_reuses_unique_key_row(db):
seed_category(db)
link = seed_link(db, del_flag=1)
result = svc.save_category(
db, user(),
{'categoryName': '硫酸', 'products': [{'productId': link.product_id}]},
category_id='cat',
)
db.flush()
assert db.query(PlCertCategoryProduct).count() == 1
assert link.del_flag == 0
assert link.create_by == 'creator'
assert link.create_time == datetime(2020, 1, 1)
assert link.update_by == 'editor'
assert result['products'][0]['id'] == link.id
assert result['products'][0]['productId'] == link.product_id
def test_remove_then_readd_without_id_keeps_one_row(db):
seed_category(db)
link = seed_link(db)
svc.save_category(
db, user(),
{'categoryName': '硫酸', 'products': []},
category_id='cat',
)
assert link.del_flag == 1
svc.save_category(
db, user(),
{'categoryName': '硫酸', 'products': [{'productId': link.product_id, 'scopeStatus': 'active'}]},
category_id='cat',
)
db.flush()
assert db.query(PlCertCategoryProduct).count() == 1
assert link.del_flag == 0
assert link.scope_status == 'active'
def test_no_id_reuses_active_row_by_product_id(db):
seed_category(db)
link = seed_link(db)
svc.save_category(
db, user(),
{'categoryName': '硫酸', 'products': [{'productId': link.product_id}]},
category_id='cat',
)
db.flush()
assert db.query(PlCertCategoryProduct).filter_by(del_flag=0).count() == 1
assert link.del_flag == 0
assert link.id == 'link'
def test_deleted_explicit_id_is_rejected(db):
seed_category(db)
link = seed_link(db, del_flag=1)
with pytest.raises(HTTPException) as exc:
svc.save_category(
db, user(),
{'categoryName': '硫酸', 'products': [{'id': link.id, 'productId': link.product_id}]},
category_id='cat',
)
assert exc.value.status_code == 400
assert link.del_flag == 1
+176 -4
View File
@@ -19,16 +19,94 @@ def test_assert_rule_not_configured_blocks_arrange():
assert '系统信息' in str(exc.value.detail)
def test_resolve_rule_version_by_effective_at():
def test_resolve_rule_version_by_arrange_effective_at():
from app.services import pl_cert_quota_rule_config_service as cfg
db = MagicMock()
row = SimpleNamespace(effective_at=datetime(2026, 11, 14, 0, 0, 0))
db.query.return_value.filter_by.return_value.first.return_value = row
assert cfg.resolve_rule_version_for_create(db, 't', datetime(2026, 11, 13)) == 'legacy'
assert cfg.resolve_rule_version_for_create(db, 't', datetime(2026, 11, 14)) == 'customer_category'
assert cfg.resolve_rule_version_for_arrange(db, 't', datetime(2026, 11, 13)) == 'legacy'
assert cfg.resolve_rule_version_for_arrange(db, 't', datetime(2026, 11, 14)) == 'customer_category'
db.query.return_value.filter_by.return_value.first.return_value = None
assert cfg.resolve_rule_version_for_create(db, 't', datetime(2026, 11, 20)) == 'legacy'
assert cfg.resolve_rule_version_for_arrange(db, 't', datetime(2026, 11, 20)) == 'legacy'
def test_stamp_plan_rule_version_freezes_first_arrange():
from app.services import pl_cert_quota_rule_config_service as cfg
db = MagicMock()
row = SimpleNamespace(effective_at=datetime(2026, 11, 14, 0, 0, 0))
db.query.return_value.filter_by.return_value.first.return_value = row
user = SimpleNamespace(tenant_no='t', id='u')
plan = SimpleNamespace(cert_quota_rule_version=None, cert_quota_arranged_at=None)
first = cfg.stamp_plan_rule_on_arrange(db, user, plan, arrange_time=datetime(2026, 11, 13, 10, 0, 0))
assert first == 'legacy'
assert plan.cert_quota_rule_version == 'legacy'
assert plan.cert_quota_arranged_at == datetime(2026, 11, 13, 10, 0, 0)
second = cfg.stamp_plan_rule_on_arrange(db, user, plan, arrange_time=datetime(2026, 11, 20, 10, 0, 0))
assert second == 'legacy'
assert plan.cert_quota_rule_version == 'legacy'
assert plan.cert_quota_arranged_at == datetime(2026, 11, 13, 10, 0, 0)
def test_stamp_after_cutoff_uses_customer_category_even_if_created_earlier():
from app.services import pl_cert_quota_rule_config_service as cfg
db = MagicMock()
row = SimpleNamespace(effective_at=datetime(2026, 11, 14, 0, 0, 0))
db.query.return_value.filter_by.return_value.first.return_value = row
user = SimpleNamespace(tenant_no='t', id='u')
plan = SimpleNamespace(
create_time=datetime(2026, 11, 1),
cert_quota_rule_version=None,
cert_quota_arranged_at=None,
)
version = cfg.stamp_plan_rule_on_arrange(db, user, plan, arrange_time=datetime(2026, 11, 15))
assert version == 'customer_category'
assert plan.cert_quota_rule_version == 'customer_category'
def test_plan_skips_archive_when_arranged_before_effective_at():
from app.services import pl_cert_quota_rule_config_service as cfg
db = MagicMock()
db.query.return_value.filter_by.return_value.first.return_value = SimpleNamespace(
effective_at=datetime(2026, 11, 14, 0, 0, 0)
)
before = SimpleNamespace(
tenant_no='t',
cert_quota_arranged_at=datetime(2026, 11, 10),
cert_quota_rule_version='legacy',
transport_id=None,
)
after = SimpleNamespace(
tenant_no='t',
cert_quota_arranged_at=datetime(2026, 11, 15),
cert_quota_rule_version='customer_category',
transport_id=None,
)
assert cfg.plan_skips_archive_cert_quota(before, db, 't') is True
assert cfg.plan_skips_archive_cert_quota(after, db, 't') is False
def test_legacy_arranged_before_cutoff_skips_archive_cert_without_waive(monkeypatch):
"""切点前首次安排的计划:归档不校证件,无需证书齐备。"""
from app.services import pl_quota_business as biz
plan = SimpleNamespace(
id='p1',
quota_cert_waived=0,
quota_cert_pending_fulfill=0,
cert_quota_rule_version='legacy',
cert_quota_arranged_at=datetime(2026, 11, 10),
cert_configuration={},
)
monkeypatch.setattr(
'app.services.pl_cert_quota_rule_config_service.plan_skips_archive_cert_quota',
lambda p, db=None, tenant_no=None: True,
)
result = biz.apply_plan_quota(MagicMock(), SimpleNamespace(id='u', tenant_no='t'), plan, 'settle', None)
assert result.get('waived') is True or result.get('skippedCertQuota') is True
def test_apply_plan_quota_new_rule_waive_skips_reserve_not_settle(monkeypatch):
@@ -754,6 +832,100 @@ def test_get_cert_customer_name_from_crm_not_consignee(monkeypatch):
assert result['customerId'] == 'cust-kanghua'
def test_get_cert_purchase_category_name(monkeypatch):
"""购买证详情回填证用品类名称,便于 H5/Web 只读展示。"""
from app.services import pl_quota_cert_service as svc
class Col:
def __init__(self, name):
self.name = name
row = SimpleNamespace(
id='p1',
cert_no='G1',
customer_id='cust1',
category_id='cat-hcl',
buyer_name='安庆市山峡化工有限公司',
valid_use_text='多次有效',
total_qty=Decimal('100'),
used_qty=Decimal('0'),
reserved_qty=Decimal('0'),
__table__=SimpleNamespace(
columns=[
Col('id'),
Col('cert_no'),
Col('customer_id'),
Col('category_id'),
Col('buyer_name'),
Col('valid_use_text'),
Col('total_qty'),
Col('used_qty'),
Col('reserved_qty'),
]
),
)
monkeypatch.setattr(svc, '_get_row', lambda *a, **k: row)
monkeypatch.setattr(svc, 'is_single_use_consumed', lambda *a, **k: False)
monkeypatch.setattr(svc, '_customer_display_name', lambda *a, **k: '甲客户')
monkeypatch.setattr(svc, '_category_display_names', lambda *a, **k: ['盐酸'])
opening = MagicMock()
opening.filter_by.return_value.first.return_value = None
db = MagicMock()
db.query.return_value = opening
result = svc.get_cert(db, SimpleNamespace(tenant_no='t'), 'purchase', 'p1')
assert result['customerName'] == '甲客户'
assert result['categoryName'] == '盐酸'
assert result['categoryId'] == 'cat-hcl'
def test_get_cert_transport_derived_category_names(monkeypatch):
"""运输证详情回填引用购买证品类名称并集。"""
from app.services import pl_quota_cert_service as svc
class Col:
def __init__(self, name):
self.name = name
row = SimpleNamespace(
id='t1',
cert_no='Y1',
customer_id='cust1',
valid_use_text='多次有效',
total_qty=Decimal('10'),
used_qty=Decimal('0'),
reserved_qty=Decimal('0'),
__table__=SimpleNamespace(
columns=[
Col('id'),
Col('cert_no'),
Col('customer_id'),
Col('valid_use_text'),
Col('total_qty'),
Col('used_qty'),
Col('reserved_qty'),
]
),
)
monkeypatch.setattr(svc, '_get_row', lambda *a, **k: row)
monkeypatch.setattr(svc, 'is_single_use_consumed', lambda *a, **k: False)
monkeypatch.setattr(svc, '_customer_display_name', lambda *a, **k: '甲客户')
monkeypatch.setattr(svc, '_refs', lambda *a, **k: [])
monkeypatch.setattr(svc, '_has_usage_beyond_zero_opening', lambda *a, **k: False)
monkeypatch.setattr(svc, '_transport_derived_categories', lambda *a, **k: ['c1', 'c2'])
monkeypatch.setattr(svc, '_category_display_names', lambda *a, **k: ['硫酸', '盐酸'])
opening = MagicMock()
opening.filter_by.return_value.first.return_value = None
db = MagicMock()
db.query.return_value = opening
result = svc.get_cert(db, SimpleNamespace(tenant_no='t'), 'transport', 't1')
assert result['derivedCategoryIds'] == ['c1', 'c2']
assert result['derivedCategoryNames'] == ['硫酸', '盐酸']
def _remote_source(**kw):
from app.schemas.pl.quota_usage import UsageSource
@@ -249,6 +249,11 @@ def test_same_city_settlement_does_not_require_transport_pairing():
assert validate_allocation_pairing([demand], [purchase], [], []) is None
def test_empty_transport_kind_settle_ignores_source_actuals():
"""同市只结转购买证时,空的运输证分组对实运量应直接空结果,不报缺实际量。"""
assert settle_allocations([], {'city': D(10)}) == ()
@pytest.mark.parametrize('manual_values', [(8,10,0),(18,0,0)])
def test_zero_capacity_foreign_row_cannot_unlock_manual_reordering(manual_values):
from app.services.pl_quota_allocation import Allocation
+46
View File
@@ -98,6 +98,31 @@ def test_zero_reservation_consumes_once_slot(case):
assert c.db.query(c.m.PlPlanCertUsage).count()==2
with pytest.raises(ValueError): run(c,operation_id='other',plan_id='other',sources=sources)
def test_same_city_purchase_only_settle_without_transport(case):
"""同市段只预占购买证时,归档结转不得因空运输证分组误报缺实际量。"""
c = case
sources = [dict(
source_stop_type='delivery', source_stop_id='stop', source_stop_product_id='sp',
plan_product_id='pp', contract_id='c', contract_product_id='cp', product_id='product',
quantity='1', unit='吨', fingerprint='fp', requires_transport=False,
)]
run(c, purchase_cert_ids=['p'], transport_cert_ids=[], sources=sources)
assert c.db.query(c.m.PlPlanCertUsage).count() == 1
assert c.db.get(c.m.PlPurchaseCert, 'p').reserved_qty == 1
run(
c,
operation_id='settle-same-city',
action='settle',
purchase_cert_ids=['p'],
transport_cert_ids=[],
sources=sources,
actual_by_source={'delivery:sp': '1'},
)
assert c.db.get(c.m.PlPurchaseCert, 'p').used_qty == 1
assert c.db.get(c.m.PlPurchaseCert, 'p').reserved_qty == 0
assert c.db.query(c.m.PlTransportCert).filter_by(id='t').one().used_qty == 0
def test_same_cert_balance_covers_actual_above_reservation(case):
c = case
run(c)
@@ -108,6 +133,27 @@ def test_same_cert_balance_covers_actual_above_reservation(case):
assert c.db.query(c.m.PlPlanCertUsage).count() == 2
def test_auto_cover_ignores_arrangement_manuals_when_actual_exceeds_reservation(case):
"""安排/完善并关闭写入的整笔 manual_allocations 不得挡住归档超额补扣。"""
c = case
manuals = [
dict(permit_kind='purchase', source_id='load:sp', permit_id='p', source_quantity='29', permit_quantity='29'),
dict(permit_kind='transport', source_id='load:sp', permit_id='t', source_quantity='29', permit_quantity='29'),
]
run(c, manual_allocations=manuals)
# 归档请求仍带着计划量人工行(与完善并关闭后 cert_configuration 回灌一致)
run(
c,
operation_id='settle-with-stale-manuals',
action='settle',
actual_by_source={'load:sp': '35'},
manual_allocations=manuals,
)
assert c.db.get(c.m.PlPurchaseCert, 'p').used_qty == 35
assert c.db.get(c.m.PlPurchaseCert, 'p').reserved_qty == 0
assert c.db.get(c.m.PlTransportCert, 't').used_qty == 35
def test_same_cert_without_enough_balance_asks_for_another_certificate(case):
c = case
c.db.get(c.m.PlPurchaseCert, 'p').total_qty = 30
@@ -0,0 +1,28 @@
-- 证件额度切点改为首次安排时间:补 arranged_at 列,并按运输单创建时间回填已安排计划。
-- MySQL 5.7。可重复执行。
SET NAMES utf8mb4;
SET @schema_name = DATABASE();
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_plan' AND COLUMN_NAME='cert_quota_arranged_at');
SET @sql = IF(@col=0,
'ALTER TABLE pl_plan ADD COLUMN cert_quota_arranged_at datetime DEFAULT NULL COMMENT ''首次安排时间(证件额度切点用);再次安排不改'' AFTER cert_quota_rule_version',
'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
-- 已安排:回填首次安排时刻,并按「安排时刻 vs 生效时间」校正规则版本(修正历史按创建打标)。
UPDATE pl_plan p
INNER JOIN pl_transport t
ON t.id = p.transport_id AND t.tenant_no = p.tenant_no AND t.del_flag = 0
LEFT JOIN pl_cert_quota_rule_config cfg
ON cfg.tenant_no = p.tenant_no AND cfg.del_flag = 0
SET
p.cert_quota_arranged_at = COALESCE(p.cert_quota_arranged_at, t.create_time),
p.cert_quota_rule_version = CASE
WHEN cfg.effective_at IS NOT NULL
AND COALESCE(p.cert_quota_arranged_at, t.create_time) >= cfg.effective_at
THEN 'customer_category'
ELSE 'legacy'
END
WHERE p.del_flag = 0
AND p.transport_status = 'arranged';
@@ -0,0 +1,28 @@
-- 证件额度切点改为首次安排时间:补 arranged_at 列,并按运输单创建时间回填已安排计划。
-- MySQL 5.7。可重复执行。
SET NAMES utf8mb4;
SET @schema_name = DATABASE();
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_plan' AND COLUMN_NAME='cert_quota_arranged_at');
SET @sql = IF(@col=0,
'ALTER TABLE pl_plan ADD COLUMN cert_quota_arranged_at datetime DEFAULT NULL COMMENT ''首次安排时间(证件额度切点用);再次安排不改'' AFTER cert_quota_rule_version',
'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
-- 已安排:回填首次安排时刻,并按「安排时刻 vs 生效时间」校正规则版本(修正历史按创建打标)。
UPDATE pl_plan p
INNER JOIN pl_transport t
ON t.id = p.transport_id AND t.tenant_no = p.tenant_no AND t.del_flag = 0
LEFT JOIN pl_cert_quota_rule_config cfg
ON cfg.tenant_no = p.tenant_no AND cfg.del_flag = 0
SET
p.cert_quota_arranged_at = COALESCE(p.cert_quota_arranged_at, t.create_time),
p.cert_quota_rule_version = CASE
WHEN cfg.effective_at IS NOT NULL
AND COALESCE(p.cert_quota_arranged_at, t.create_time) >= cfg.effective_at
THEN 'customer_category'
ELSE 'legacy'
END
WHERE p.del_flag = 0
AND p.transport_status = 'arranged';
@@ -0,0 +1,30 @@
# 证件额度切点:按首次安排时间(设计)
工单:[#40](https://gitea.aichengyan.com/cykj/shanxia2.0/issues/40)
## 背景
原设计按计划 `create_time` 与 `effective_at` 比较打 `cert_quota_rule_version`。业务要求改为按**首次安排运输**时刻切点;切点前已安排的计划归档不再校购买证/运输证。
## 定稿规则
1. **未配置 `effective_at`**:安排仍 409 拦住(不变)。
2. **首次安排**时写入:
- `cert_quota_arranged_at = 安排时刻`
- `cert_quota_rule_version = customer_category` 若 `安排时刻 >= effective_at`,否则 `legacy`
3. **再次调整运输**:不重打 `cert_quota_arranged_at` / `cert_quota_rule_version`(冻结首次安排)。
4. **创建计划**:不再按创建时间打标(可空,待安排时写入)。
5. **归档**:
- `legacy`(切点前首次安排):不校验、不扣购买证/运输证,额度结转按跳过处理,允许直接归档。
- `customer_category`:保持现有最新业务逻辑(选证、齐备仅放行安排、归档须真证等)。
6. **历史已安排且缺 `cert_quota_arranged_at`**:归档/读规则时用关联 `pl_transport.create_time` 推断;若推断时刻 `< effective_at` 视为 `legacy` 祖父。
## 范围
- backend:切点服务、安排打标、归档跳过、迁移列
- web-admin:系统信息「运输证件规则」说明文案
## 非目标
- 不批量改历史 `quota_cert_waived`
- 不改选证算法本身
+1 -1
View File
@@ -9,5 +9,5 @@ allowBuilds:
core-js: false
es5-ext: false
esbuild: true
protobufjs: false
protobufjs: true
vue-demi: true
@@ -31,17 +31,24 @@ test('pending keywords still force alert mode without plan flags', () => {
)
})
test('unmarked legacy quota block still offers certificate ready', () => {
test('unmarked quota block only alerts, never offers certificate ready', () => {
assert.equal(
resolveArchiveQuotaBlockMode(
'实际数量已保存,证件没有扣减。请把实际数量改小,或先补充购买证和运输证,再重新归档。',
'用证没有完成',
{ quotaCertPendingFulfill: 0, quotaCertWaived: 0 }
),
'confirm-ready'
'alert-plain'
)
})
test('non quota-title blocks stay plain alert', () => {
assert.equal(resolveArchiveQuotaBlockMode('不能归档:缺证', '不能归档', {}), 'alert-plain')
})
test('missing actual-quantity settle error does not offer certificate ready', () => {
assert.equal(
resolveArchiveQuotaBlockMode('必须明确提交每个来源的实际量', '用证没有完成', {}),
'alert-plain'
)
})
@@ -39,6 +39,34 @@ test('H5 客户用证按客户列表展示证件', () => {
assert.match(contractTab, /\/m\/pl\/plan-cert\/customer\//)
assert.match(contractTab, /暂无客户用证数据/)
assert.match(planCertIndex, /title: '客户用证'/)
assert.match(planCertIndex, /客户名称 \/ 编号/)
assert.doesNotMatch(planCertIndex, /合同编号 \/ 名称 \/ 销售或购买单位/)
})
test('H5 客户用证详情显式导入 WebView 壳,避免空白页', () => {
const customerDetail = readFileSync(
new URL('../src/views/m/pl/plan-cert/customer-detail.vue', import.meta.url),
'utf8'
)
assert.match(customerDetail, /import MpWebviewShell from '@\/layouts\/MpWebviewShell\.vue'/)
assert.match(customerDetail, /import MpBusinessHeader from '@\/components\/mp\/MpBusinessHeader\.vue'/)
assert.match(customerDetail, /@ready="onShellReady"/)
})
test('H5 证件详情与建档列表展示系统客户和证用品类', () => {
const faceModel = readFileSync(
new URL('../src/views/m/pl/plan-cert/face-model.ts', import.meta.url),
'utf8'
)
const archiveTab = readFileSync(
new URL('../src/views/m/pl/plan-cert/ArchiveTab.vue', import.meta.url),
'utf8'
)
assert.match(faceModel, /label: '证用品类'/)
assert.match(faceModel, /certCategoryLabel/)
assert.match(archiveTab, /系统客户/)
assert.match(archiveTab, /证用品类/)
assert.match(archiveTab, /certCategoryLabel/)
})
test('H5 建档页使用关联购买证且无证面引用残留', () => {
+2
View File
@@ -65,7 +65,9 @@ export interface QuotaCert extends Omit<CertBody, 'status'> {
createTime?: string
certType?: CertType
derivedCategoryIds?: string[]
derivedCategoryNames?: string[]
customerName?: string | null
categoryName?: string | null
}
export interface ScopeProduct {
id?: string
@@ -1604,36 +1604,17 @@
async function showArchiveQuotaBlockDialog(message: string, title = '不能归档') {
const html = `<p class="pl-arrange-block-text">${escapeArchiveBlockText(message)}</p>`
const mode = resolveArchiveQuotaBlockMode(message, title, plan.value)
if (mode !== 'confirm-ready') {
const guide =
mode === 'alert-pending'
? `${html}<p class="pl-arrange-block-text">该运输计划已走证书齐备,归档前须完善证件。请到「证书齐备 → 已标记待完善证书」上传购买证/运输证并执行完善并关闭后再归档。</p>`
: html
await ElMessageBox.alert(guide, title, {
type: 'warning',
confirmButtonText: '知道了',
dangerouslyUseHTMLString: true,
customClass: 'pl-arrange-block-msgbox',
closeOnClickModal: false
}).catch(() => undefined)
return undefined
}
// 仅旧规则(legacy)允许在归档时标记证书齐备以跳过额度
const readyHtml = `${html}<p class="pl-arrange-block-text">如果购买证、运输证还没上传,可以标记证书齐备。仅旧规则计划标记后这次归档不再校验、不扣这两证额度;新规则计划请走「证书齐备 → 已标记待完善证书」。</p>`
try {
await ElMessageBox.confirm(readyHtml, title, {
type: 'warning',
confirmButtonText: '证书齐备',
cancelButtonText: '知道了',
dangerouslyUseHTMLString: true,
customClass: 'pl-arrange-block-msgbox',
closeOnClickModal: false,
distinguishCancelAndClose: true
})
return 'ready' as const
} catch {
return undefined
}
const guide =
mode === 'alert-pending'
? `${html}<p class="pl-arrange-block-text">该运输计划已走证书齐备,归档前须完善证件。请到「证书齐备 → 已标记待完善证书」上传购买证/运输证并执行完善并关闭后再归档。</p>`
: html
await ElMessageBox.alert(guide, title, {
type: 'warning',
confirmButtonText: '知道了',
dangerouslyUseHTMLString: true,
customClass: 'pl-arrange-block-msgbox',
closeOnClickModal: false
}).catch(() => undefined)
}
function applyArchiveOverageHighlight(items: Array<Record<string, unknown>>) {
@@ -1668,13 +1649,7 @@
// 业务异常已作为一次请求提交;补齐条件后允许新尝试,不回放旧异常。
quotaArchiveAttempt = { signature: '', id: '' }
const message = saved.quotaSettlementError
const choice = await showArchiveQuotaBlockDialog(message, '用证没有完成')
if (choice === 'ready' && current()) {
await planApi.markQuotaCertReady(targetId)
if (!current()) return
await submitArchiveWithOverageConfirm(targetId, frozenBody, current, confirmOverage)
return
}
await showArchiveQuotaBlockDialog(message, '用证没有完成')
const error = new Error(message) as Error & { cancelled?: boolean }
error.cancelled = true
throw error
@@ -1,6 +1,6 @@
/** 归档用证拦截弹窗:已齐备/待完善只提醒,未齐备旧规则才可再点证书齐备。 */
/** 归档用证拦截弹窗:只提醒,不在归档界面提供「证书齐备」操作。 */
export type ArchiveQuotaBlockMode = 'alert-pending' | 'alert-plain' | 'confirm-ready'
export type ArchiveQuotaBlockMode = 'alert-pending' | 'alert-plain'
export type ArchiveQuotaBlockPlanFlags = {
quotaCertPendingFulfill?: number | boolean | null
@@ -23,5 +23,5 @@ export function resolveArchiveQuotaBlockMode(
const pendingByPlan = flagOn(plan?.quotaCertPendingFulfill) || flagOn(plan?.quotaCertWaived)
if (title !== '用证没有完成') return 'alert-plain'
if (pendingByPlan || pendingByMessage) return 'alert-pending'
return 'confirm-ready'
return 'alert-plain'
}
@@ -25,6 +25,12 @@
}}</span>
</div>
<div class="mp-wv-card__kv">
<div class="mp-wv-card__kv-row"
><span>系统客户</span><span>{{ row.customerName || '—' }}</span></div
>
<div class="mp-wv-card__kv-row"
><span>证用品类</span><span>{{ certCategoryLabel(row) }}</span></div
>
<div class="mp-wv-card__kv-row"
><span>销售单位</span><span>{{ salePartyName(row) || '—' }}</span></div
>
@@ -119,7 +125,7 @@
} from '@/views/pl/quota-cert/cert-list-face'
import { useDictStore } from '@/store/modules/dict'
import { getProductUnitLabel } from '@/utils/crm-product-display'
import { certStatusLabels } from './face-model'
import { certCategoryLabel, certStatusLabels } from './face-model'
const props = defineProps<{
keyword: string
@@ -1,5 +1,5 @@
<template>
<MpWebviewShell @ready="ready = true">
<MpWebviewShell @ready="onShellReady">
<MpBusinessHeader title="客户用证" back-label="返回运输证件管理" @back="router.back()" />
<main v-if="ready" class="mp-h5-list-content mp-h5-detail-page mp-plan-cert-list">
<p v-if="error" class="mp-wv-empty" role="alert">
@@ -34,11 +34,19 @@
import { useRoute, useRouter } from 'vue-router'
import { fetchGetMenuCodes } from '@/api/auth'
import { quotaStatisticsApi, type CustomerStatistics } from '@/api/pl/quota-cert'
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
import CustomerCertQuotaTab from '@/views/m/crm/contract/CustomerCertQuotaTab.vue'
import '@/styles/mp-webview.scss'
import './cert-mobile.scss'
const route = useRoute()
const router = useRouter()
const ready = ref(false)
function onShellReady() {
ready.value = true
}
const authReady = ref(false)
const allowed = ref(false)
const loading = ref(true)
@@ -30,6 +30,22 @@ const longFaceKeys = new Set([
export type FaceField = { label: string; value: string; wide?: boolean }
export type FaceGroup = { title: string; fields: FaceField[] }
export function certCategoryLabel(
cert: Pick<QuotaCert, 'certType' | 'categoryName' | 'derivedCategoryNames'> & {
certType?: CertType | null
},
kind?: CertType
) {
const certKind = kind || cert.certType
if (certKind === 'transport') {
const names = (cert.derivedCategoryNames || [])
.map((item) => String(item || '').trim())
.filter(Boolean)
return names.join('、') || '—'
}
return String(cert.categoryName || '').trim() || '—'
}
export function certFaceGroups(
cert: QuotaCert,
kind: CertType,
@@ -51,6 +67,11 @@ export function certFaceGroups(
label: '系统客户',
value: String(cert.customerName || '').trim() || '—',
wide: true
},
{
label: '证用品类',
value: certCategoryLabel(cert, kind),
wide: true
}
)
}
+1 -1
View File
@@ -361,7 +361,7 @@
]
const placeholder = computed(() =>
tab.value === 'contract'
? '合同编号 / 名称 / 销售或购买单位'
? '客户名称 / 编号'
: tab.value === 'plan' || tab.value === 'ready'
? '计划编号 / 供方 / 需方'
: '证件编号 / 品名 / 销售或购买单位'
@@ -1656,7 +1656,7 @@ async function handleArchive(row: Plan) {
ElMessage.warning('无运输计划归档操作权限')
return
}
// 打开归档弹窗:已归档走调整;未归档/已暂存可直接打开(证书齐备仅在「确认归档」时校验,以便支持暂存)
// 打开归档弹窗:已归档走调整;未归档/已暂存可直接打开(用证校验在「确认归档」时执行,以便支持暂存)
archivePlanId.value = row.id
archiveDialogVisible.value = true
}
@@ -7,7 +7,7 @@
</div>
<div class="p-6 max-w-xl">
<p class="text-sm text-slate-500 mb-4">
配置「客户+品类」额度生效时间。未配置时无法安排运输。计划创建时按该时间打规则版本;修改后只影响之后新建的计划。
配置「客户+品类」额度生效时间。未配置时无法安排运输。以计划首次安排时刻与该时间比较:之前已安排的归档不再校验购买证/运输证;之后安排的走客户+品类最新规则。首次安排后版本冻结,改派不重打;修改生效时间只影响尚未安排的计划。
</p>
<el-form label-position="top" class="dialog-form-layout">
<el-form-item label="客户品类额度生效时间">