Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be0ba622a0 | ||
|
|
f865d8ed99 | ||
|
|
6f20c0ff8a | ||
|
|
14516dd2d0 | ||
|
|
5a8a9048d4 | ||
|
|
ba62ec7b36 | ||
|
|
579285a7bf | ||
|
|
f41e47601d | ||
|
|
e0598ed07b | ||
|
|
02d576abfe | ||
|
|
95ecb7236e | ||
|
|
ea46761f40 |
No files matched your search
@@ -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)
|
||||
|
||||
# 查询合同产品明细
|
||||
@@ -3807,7 +3804,7 @@ def archive_plan(
|
||||
db.flush()
|
||||
result = get_plan(id, db, user)
|
||||
if not quota_settled:
|
||||
result.quota_settlement_error = read_quota_settlement_error(db, tenant_no, plan.id)
|
||||
result.quota_settlement_error = read_quota_settlement_error(db, tenant_no, plan.id, plan=plan)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -320,6 +320,23 @@ def contract_statistics_list(keyword: str = Query('', max_length=200),
|
||||
cert_count=cert_count, cert_count_min=cert_count_min, cert_count_max=cert_count_max))
|
||||
|
||||
|
||||
@router.get('/statistics/customers')
|
||||
def customer_statistics_list(keyword: str = Query('', max_length=200),
|
||||
customer_no: str = Query('', alias='customerNo', max_length=200),
|
||||
customer_name: str = Query('', alias='customerName', max_length=200),
|
||||
cert_count_op: str = Query('', alias='certCountOp', max_length=20),
|
||||
cert_count: str = Query('', alias='certCount', max_length=40),
|
||||
cert_count_min: str = Query('', alias='certCountMin', max_length=40),
|
||||
cert_count_max: str = Query('', alias='certCountMax', max_length=40),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, alias='pageSize', ge=1, le=100),
|
||||
db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
|
||||
return _wire(statistics.list_customers(
|
||||
db, user, keyword=keyword, page=page, page_size=page_size, customer_no=customer_no,
|
||||
customer_name=customer_name, cert_count_op=cert_count_op, cert_count=cert_count,
|
||||
cert_count_min=cert_count_min, cert_count_max=cert_count_max))
|
||||
|
||||
|
||||
@router.get('/statistics/certificates')
|
||||
def certificate_statistics_list(keyword: str = Query('', max_length=200),
|
||||
cert_no: str = Query('', alias='certNo', max_length=200),
|
||||
@@ -378,6 +395,12 @@ def contract_statistics(contract_id: str, db: Session = Depends(get_db),
|
||||
return _wire(statistics.contract_statistics(db, user, contract_id))
|
||||
|
||||
|
||||
@router.get('/customers/{customer_id}/statistics')
|
||||
def customer_statistics(customer_id: str, db: Session = Depends(get_db),
|
||||
user: DbUserContext = Depends(current_user)):
|
||||
return _wire(statistics.customer_statistics(db, user, customer_id))
|
||||
|
||||
|
||||
@router.get('/{cert_type}/{cert_id}/statistics')
|
||||
def certificate_statistics(cert_type: CertType, cert_id: str, db: Session = Depends(get_db),
|
||||
user: DbUserContext = Depends(current_user)):
|
||||
@@ -386,8 +409,9 @@ def certificate_statistics(cert_type: CertType, cert_id: str, db: Session = Depe
|
||||
|
||||
@router.get('/{cert_type}')
|
||||
def list_certs(cert_type: CertType, keyword: Optional[str] = None, status: Optional[str] = None,
|
||||
customer_id: Optional[str] = Query(None, alias='customerId', max_length=64),
|
||||
db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
|
||||
return _wire(certs.list_certs(db, user, cert_type, keyword, status))
|
||||
return _wire(certs.list_certs(db, user, cert_type, keyword, status, customer_id=customer_id))
|
||||
|
||||
|
||||
@router.post('/{cert_type}/recognize', dependencies=[Depends(require_menu_code('pl:quota-cert:edit'))])
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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="作废原因")
|
||||
|
||||
@@ -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)
|
||||
@@ -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'}:
|
||||
|
||||
@@ -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)
|
||||
@@ -171,26 +180,40 @@ def settle_plan_quota(db, user, plan, payload, *, adjustment=False):
|
||||
except ArchiveQuotaBlocked as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
except (HTTPException, ValueError, AllocationIncomplete) as exc:
|
||||
message = str(getattr(exc, 'detail', exc))
|
||||
# 无用量行(如证书齐备仅放行安排)时仍保留原因,供同请求读回。
|
||||
setattr(plan, '_quota_settlement_error', message)
|
||||
rows = db.query(PlPlanCertUsage).filter_by(tenant_no=user.tenant_no, plan_id=plan.id, del_flag=0).all()
|
||||
for row in rows:
|
||||
if not adjustment:
|
||||
row.usage_status = 'exception'
|
||||
row.confirmation_snapshot = {**(row.confirmation_snapshot or {}), 'executed': True,
|
||||
'settlementError': str(getattr(exc, 'detail', exc))}
|
||||
'settlementError': message}
|
||||
row.update_by = user.id
|
||||
if not adjustment:
|
||||
plan.archive_status = 'exception'
|
||||
return False
|
||||
|
||||
|
||||
def read_quota_settlement_error(db, tenant_no, plan_id):
|
||||
def read_quota_settlement_error(db, tenant_no, plan_id, plan=None):
|
||||
"""结转失败时返回已经写在用量快照上的原因。"""
|
||||
from app.models.pl_models import PlPlan
|
||||
from app.models.pl_quota_models import PlPlanCertUsage
|
||||
|
||||
if plan is not None:
|
||||
cached = getattr(plan, '_quota_settlement_error', None)
|
||||
if isinstance(cached, str) and cached.strip():
|
||||
return cached.strip()
|
||||
rows = db.query(PlPlanCertUsage).filter_by(tenant_no=tenant_no, plan_id=plan_id, del_flag=0).all()
|
||||
for row in rows:
|
||||
message = (row.confirmation_snapshot or {}).get('settlementError')
|
||||
if isinstance(message, str) and message.strip():
|
||||
return message.strip()
|
||||
target = plan
|
||||
if target is None:
|
||||
target = db.query(PlPlan).filter_by(id=plan_id, tenant_no=tenant_no, del_flag=0).first()
|
||||
if target is not None and int(getattr(target, 'quota_cert_pending_fulfill', 0) or 0) == 1:
|
||||
return '该计划仍处于证书齐备待完善,请先在证书齐备中完善并关闭后再归档'
|
||||
return '实际数量已保存,证件没有扣减。请把实际数量改小,或先补充购买证和运输证,再重新归档。'
|
||||
|
||||
|
||||
|
||||
@@ -155,4 +155,6 @@ def save_cert(db, user, cert_type, data, cert_id=None):
|
||||
for scope in prepared:
|
||||
scopes.apply_contract_bindings(db, user, scope)
|
||||
db.flush()
|
||||
# 与无合同集合的 save_cert 一致:建档/保存后自动期初并启用;current=True 的读回不会再跑启用。
|
||||
row = certs.ensure_zero_opening(db, user, cert_type, row.id, row=row)
|
||||
return get_cert(db, user, cert_type, row.id, current=True)
|
||||
@@ -9,6 +9,7 @@ from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from app.models.crm_models import CrmCustomer
|
||||
from app.models.pl_quota_models import (
|
||||
PlPurchaseCert, PlTransportCert, PlTransportCertPurchaseRef,
|
||||
PlCertQtyLog, PlPlanCertUsage, PlContractCertLink, PlCertOperation,
|
||||
@@ -47,6 +48,39 @@ def usage_policy(valid_use_text):
|
||||
return {'一次有效': 'once', '多次有效': 'multiple'}.get((valid_use_text or '').strip(), 'needs_confirmation')
|
||||
|
||||
|
||||
_ONCE_USAGE_STATUSES = ('reserved', 'in_transit', 'settled', 'exception')
|
||||
|
||||
|
||||
def is_single_use_consumed(db, user, cert_type, cert, *, exclude_plan_id=None):
|
||||
"""一次有效证是否已不可再用:期初已使用,或其它计划已占用/已执行。
|
||||
|
||||
exclude_plan_id:当前计划自身的用量不计入占用(安排/结转同一计划可续用)。
|
||||
多次有效或待确认原文返回 False,由调用方另行校验 policy。
|
||||
"""
|
||||
if usage_policy(getattr(cert, 'valid_use_text', None)) != 'once':
|
||||
return False
|
||||
if getattr(cert, 'opening_single_use_consumed', 0):
|
||||
return True
|
||||
previous = (
|
||||
db.query(PlPlanCertUsage)
|
||||
.filter_by(
|
||||
tenant_no=user.tenant_no,
|
||||
cert_type=cert_type,
|
||||
cert_id=cert.id,
|
||||
del_flag=0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for usage in previous:
|
||||
if exclude_plan_id is not None and usage.plan_id == exclude_plan_id:
|
||||
continue
|
||||
if usage.usage_status in _ONCE_USAGE_STATUSES:
|
||||
return True
|
||||
if (usage.confirmation_snapshot or {}).get('executed'):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _assert_known_usage_policy(row):
|
||||
if usage_policy(row.valid_use_text) == 'needs_confirmation':
|
||||
raise HTTPException(400, '有效次数原文待确认,明确一次或多次有效后才能启用')
|
||||
@@ -85,8 +119,47 @@ def _transport_derived_categories(db, user, transport_cert_id):
|
||||
return list(dict.fromkeys(r.category_id for r in rows if r.category_id))
|
||||
|
||||
|
||||
def _customer_display_name(db, user, customer_id):
|
||||
"""按证件绑定的系统客户 ID 取 CRM 名称;与证面收货/购买单位无关。"""
|
||||
cid = str(customer_id or '').strip()
|
||||
if not cid:
|
||||
return None
|
||||
row = (
|
||||
db.query(CrmCustomer.customer_name)
|
||||
.filter(
|
||||
CrmCustomer.id == cid,
|
||||
CrmCustomer.tenant_no == user.tenant_no,
|
||||
CrmCustomer.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
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」。
|
||||
# 自动期初与启用仅在 save_cert / confirm 等写入路径执行。
|
||||
result = _row_to_dict(row)
|
||||
result['usagePolicy'] = usage_policy(row.valid_use_text)
|
||||
opening_query = db.query(PlCertQtyLog.id).filter_by(
|
||||
@@ -96,17 +169,28 @@ def get_cert(db, user, cert_type, cert_id, current=False):
|
||||
opening_query = opening_query.with_for_update()
|
||||
result['openingConfirmed'] = opening_query.first() is not None
|
||||
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_history(db, user, cert_type, cert_id, lock=current)
|
||||
result['derivedCategoryIds'] = _transport_derived_categories(db, user, cert_id)
|
||||
result['referencesEditable'] = not _has_usage_beyond_zero_opening(
|
||||
db, user, cert_type, cert_id, lock=current
|
||||
)
|
||||
derived_ids = _transport_derived_categories(db, user, cert_id)
|
||||
result['derivedCategoryIds'] = derived_ids
|
||||
result['derivedCategoryNames'] = _category_display_names(db, user, derived_ids)
|
||||
return result
|
||||
|
||||
|
||||
def list_certs(db, user, cert_type, keyword=None, status=None):
|
||||
def list_certs(db, user, cert_type, keyword=None, status=None, customer_id=None):
|
||||
model = _model(cert_type)
|
||||
query = db.query(model).filter(model.tenant_no == user.tenant_no, model.del_flag == 0)
|
||||
query = apply_pl_scope(query, model, user, db, user.tenant_no)
|
||||
if customer_id:
|
||||
query = query.filter(model.customer_id == customer_id)
|
||||
if keyword:
|
||||
sale_name = model.seller_name if cert_type == 'purchase' else model.shipper_name
|
||||
buy_name = model.buyer_name if cert_type == 'purchase' else model.consignee_name
|
||||
@@ -123,7 +207,9 @@ def list_certs(db, user, cert_type, keyword=None, status=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
|
||||
|
||||
|
||||
@@ -169,8 +255,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 +267,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;清空请提交[]')
|
||||
@@ -282,7 +400,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 +448,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 +586,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, '期初已用量不能超过核定量')
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.models.pl_quota_models import (
|
||||
)
|
||||
from app.schemas.pl.quota_planning import ManualQuantity
|
||||
from app.services.pl_cert_category_service import category_ids_for_product
|
||||
from app.services.pl_quota_cert_service import is_single_use_consumed
|
||||
from app.services.pl_quota_units import same_quantity_unit, standard_conversion_ratio
|
||||
|
||||
|
||||
@@ -42,6 +43,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):
|
||||
@@ -52,6 +63,14 @@ def _vehicle_ok(cert, vehicle_no: str) -> bool:
|
||||
return plate in {str(p).strip() for p in plates if p}
|
||||
|
||||
|
||||
def _cert_no(cert) -> str:
|
||||
return str(getattr(cert, 'cert_no', None) or getattr(cert, 'id', '') or '').strip()
|
||||
|
||||
|
||||
def _join_cert_nos(certs) -> str:
|
||||
return '、'.join(dict.fromkeys(_cert_no(c) for c in certs if _cert_no(c)))
|
||||
|
||||
|
||||
def _permit_qty_for(source_qty: Decimal, source_unit: str, cert_unit: str) -> Decimal:
|
||||
if same_quantity_unit(source_unit, cert_unit):
|
||||
return source_qty
|
||||
@@ -69,6 +88,292 @@ def _customer_id_from_source(source) -> str:
|
||||
raise HTTPException(409, '用证段次缺少客户,无法按客户选证')
|
||||
|
||||
|
||||
def _snap_text(source, *keys) -> str:
|
||||
snap = source.snapshot or {}
|
||||
for key in keys:
|
||||
value = snap.get(key)
|
||||
if value is not None and str(value).strip():
|
||||
return str(value).strip()
|
||||
return ''
|
||||
|
||||
|
||||
def _source_customer_label(source) -> str:
|
||||
return _snap_text(
|
||||
source, 'customerName', 'customer_name', 'customerNo', 'customer_no'
|
||||
) or _customer_id_from_source(source)
|
||||
|
||||
|
||||
def _source_product_label(db, user, source) -> str:
|
||||
name = _snap_text(source, 'productName', 'product_name')
|
||||
if name:
|
||||
return name
|
||||
product_id = getattr(source, 'product_id', None)
|
||||
if not product_id:
|
||||
return ''
|
||||
try:
|
||||
from app.models.crm_models import CrmProduct
|
||||
|
||||
row = (
|
||||
db.query(CrmProduct)
|
||||
.filter(
|
||||
CrmProduct.id == product_id,
|
||||
CrmProduct.tenant_no == user.tenant_no,
|
||||
CrmProduct.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
except Exception:
|
||||
return ''
|
||||
product_name = getattr(row, 'product_name', None) if row is not None else None
|
||||
if isinstance(product_name, str) and product_name.strip():
|
||||
return product_name.strip()
|
||||
return str(product_id)
|
||||
|
||||
|
||||
def _purchase_candidates(db, user, customer_id, categories):
|
||||
# MySQL ASC 会把 NULL 排最前;无到期证应排在末尾,优先消耗更早到期的证(D5)
|
||||
return (
|
||||
db.query(PlPurchaseCert)
|
||||
.filter(
|
||||
PlPurchaseCert.tenant_no == user.tenant_no,
|
||||
PlPurchaseCert.del_flag == 0,
|
||||
PlPurchaseCert.status == 'active',
|
||||
PlPurchaseCert.customer_id == customer_id,
|
||||
PlPurchaseCert.category_id.in_(categories),
|
||||
)
|
||||
.order_by(
|
||||
PlPurchaseCert.valid_to.is_(None).asc(),
|
||||
PlPurchaseCert.valid_to.asc(),
|
||||
PlPurchaseCert.id.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _linked_transports(db, user, customer_id, purchase_id):
|
||||
refs = (
|
||||
db.query(PlTransportCertPurchaseRef)
|
||||
.filter_by(
|
||||
tenant_no=user.tenant_no,
|
||||
purchase_cert_id=purchase_id,
|
||||
del_flag=0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
transport_cert_ids = [r.transport_cert_id for r in refs if r.transport_cert_id]
|
||||
if not transport_cert_ids:
|
||||
return []
|
||||
return (
|
||||
db.query(PlTransportCert)
|
||||
.filter(
|
||||
PlTransportCert.tenant_no == user.tenant_no,
|
||||
PlTransportCert.del_flag == 0,
|
||||
PlTransportCert.status == 'active',
|
||||
PlTransportCert.customer_id == customer_id,
|
||||
PlTransportCert.id.in_(transport_cert_ids),
|
||||
)
|
||||
.order_by(
|
||||
PlTransportCert.valid_to.is_(None).asc(),
|
||||
PlTransportCert.valid_to.asc(),
|
||||
PlTransportCert.id.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _diagnose_select_failure(db, user, source, *, vehicle_no: str, on_date: date, candidates):
|
||||
"""选证失败时按证据优先给出单一可执行原因,避免笼统兜底句。"""
|
||||
place = '同市' if not source.requires_transport else '异地'
|
||||
plate = (vehicle_no or '').strip()
|
||||
need_qty = Decimal(source.quantity)
|
||||
customer_label = _source_customer_label(source)
|
||||
product_label = _source_product_label(db, user, source)
|
||||
|
||||
if not candidates:
|
||||
product_part = f'产品「{product_label}」' if product_label else '该产品'
|
||||
return (
|
||||
f'{place}段客户「{customer_label}」下没有{product_part}匹配品类的有效购买证,'
|
||||
'请先建档购买证或核对客户/证用品类后再安排'
|
||||
)
|
||||
|
||||
unverified_buy = []
|
||||
no_opening_buy = []
|
||||
expired_buy = []
|
||||
consumed_buy = []
|
||||
unit_buy = []
|
||||
short_buy = []
|
||||
ready_buy = []
|
||||
no_transport_link = []
|
||||
unverified_move = []
|
||||
no_opening_move = []
|
||||
expired_move = []
|
||||
consumed_move = []
|
||||
vehicle_miss = []
|
||||
unit_move = []
|
||||
short_move = []
|
||||
|
||||
for purchase in candidates:
|
||||
if not purchase.verified_by or not purchase.verified_time:
|
||||
unverified_buy.append(purchase)
|
||||
continue
|
||||
_ensure_opening_quiet(db, user, 'purchase', purchase.id, purchase)
|
||||
if not _has_opening(db, user, 'purchase', purchase.id):
|
||||
no_opening_buy.append(purchase)
|
||||
continue
|
||||
if not _in_force(purchase, on_date):
|
||||
expired_buy.append(purchase)
|
||||
continue
|
||||
if is_single_use_consumed(db, user, 'purchase', purchase):
|
||||
consumed_buy.append(purchase)
|
||||
continue
|
||||
try:
|
||||
need = _permit_qty_for(need_qty, source.unit, purchase.quantity_unit)
|
||||
except HTTPException:
|
||||
unit_buy.append(purchase)
|
||||
continue
|
||||
if _available(purchase) < need:
|
||||
short_buy.append((purchase, need))
|
||||
continue
|
||||
|
||||
if not source.requires_transport:
|
||||
ready_buy.append(purchase)
|
||||
continue
|
||||
|
||||
transports = _linked_transports(db, user, purchase.customer_id, purchase.id)
|
||||
if not transports:
|
||||
no_transport_link.append(purchase)
|
||||
continue
|
||||
|
||||
purchase_had_ready_transport = False
|
||||
for transport in transports:
|
||||
if not transport.verified_by or not transport.verified_time:
|
||||
unverified_move.append(transport)
|
||||
continue
|
||||
_ensure_opening_quiet(db, user, 'transport', transport.id, transport)
|
||||
if not _has_opening(db, user, 'transport', transport.id):
|
||||
no_opening_move.append(transport)
|
||||
continue
|
||||
if not _in_force(transport, on_date):
|
||||
expired_move.append(transport)
|
||||
continue
|
||||
if is_single_use_consumed(db, user, 'transport', transport):
|
||||
consumed_move.append(transport)
|
||||
continue
|
||||
if not _vehicle_ok(transport, vehicle_no):
|
||||
vehicle_miss.append(transport)
|
||||
continue
|
||||
try:
|
||||
t_need = _permit_qty_for(need_qty, source.unit, transport.quantity_unit)
|
||||
except HTTPException:
|
||||
unit_move.append(transport)
|
||||
continue
|
||||
if _available(transport) < t_need:
|
||||
short_move.append((transport, t_need))
|
||||
continue
|
||||
purchase_had_ready_transport = True
|
||||
break
|
||||
if purchase_had_ready_transport:
|
||||
ready_buy.append(purchase)
|
||||
|
||||
if source.requires_transport and not plate:
|
||||
return (
|
||||
f'{place}段安排运输须填写车牌号;当前计划未填写车牌,'
|
||||
'无法匹配运输证车号,请先填写车牌后再安排'
|
||||
)
|
||||
|
||||
if vehicle_miss and not short_move and not unit_move:
|
||||
nos = _join_cert_nos(vehicle_miss)
|
||||
listed = '、'.join(
|
||||
dict.fromkeys(
|
||||
str(p).strip()
|
||||
for t in vehicle_miss
|
||||
for p in (t.vehicle_numbers_json or [])
|
||||
if str(p).strip()
|
||||
)
|
||||
)
|
||||
suffix = f'(证上现有车号:{listed})' if listed else ''
|
||||
return (
|
||||
f'{place}段运输证 {nos} 未包含车牌号「{plate}」{suffix}。'
|
||||
'请到运输证中补充该车号,或更换已登记该车号的车辆后再安排'
|
||||
)
|
||||
|
||||
if short_move:
|
||||
parts = []
|
||||
for cert, need in short_move[:3]:
|
||||
left = _available(cert)
|
||||
parts.append(
|
||||
f'{_cert_no(cert)}剩余{left}{cert.quantity_unit},本次需要{need}{cert.quantity_unit}'
|
||||
)
|
||||
return (
|
||||
f'{place}段运输证{";".join(parts)}。'
|
||||
'运输证余量不足,请先补充额度或调整计划量后再安排'
|
||||
)
|
||||
|
||||
if short_buy and not ready_buy:
|
||||
parts = []
|
||||
for cert, need in short_buy[:3]:
|
||||
left = _available(cert)
|
||||
parts.append(
|
||||
f'{_cert_no(cert)}剩余{left}{cert.quantity_unit},本次需要{need}{cert.quantity_unit}'
|
||||
)
|
||||
return (
|
||||
f'{place}段购买证{";".join(parts)}。'
|
||||
'购买证余量不足,请先补充额度或调整计划量后再安排'
|
||||
)
|
||||
|
||||
if no_transport_link and not vehicle_miss and not short_move:
|
||||
nos = _join_cert_nos(no_transport_link)
|
||||
return (
|
||||
f'{place}段购买证 {nos} 尚未关联运输证。'
|
||||
'请打开运输证,在「关联购买证」中选择该购买证后再安排'
|
||||
)
|
||||
|
||||
if expired_move and not vehicle_miss:
|
||||
nos = _join_cert_nos(expired_move)
|
||||
return (
|
||||
f'{place}段运输证 {nos} 不在计划日期 {on_date.isoformat()} 的有效期内,'
|
||||
'请更换有效运输证后再安排'
|
||||
)
|
||||
|
||||
if expired_buy and not ready_buy:
|
||||
nos = _join_cert_nos(expired_buy)
|
||||
return (
|
||||
f'{place}段购买证 {nos} 不在计划日期 {on_date.isoformat()} 的有效期内,'
|
||||
'请更换有效购买证后再安排'
|
||||
)
|
||||
|
||||
if unverified_move and not vehicle_miss:
|
||||
nos = _join_cert_nos(unverified_move)
|
||||
return f'{place}段运输证 {nos} 尚未复核启用,请先复核后再安排'
|
||||
|
||||
if unverified_buy and not ready_buy:
|
||||
nos = _join_cert_nos(unverified_buy)
|
||||
return f'{place}段购买证 {nos} 尚未复核启用,请先复核后再安排'
|
||||
|
||||
if consumed_move:
|
||||
nos = _join_cert_nos(consumed_move)
|
||||
return f'{place}段运输证 {nos} 为一次有效且已使用,请更换证件后再安排'
|
||||
|
||||
if consumed_buy and not ready_buy:
|
||||
nos = _join_cert_nos(consumed_buy)
|
||||
return f'{place}段购买证 {nos} 为一次有效且已使用,请更换证件后再安排'
|
||||
|
||||
if unit_buy or unit_move:
|
||||
return (
|
||||
f'{place}段证件单位与计划单位无法自动换算,'
|
||||
'请调整单位或到「计划段次用证」填写换算后再安排'
|
||||
)
|
||||
|
||||
if no_opening_buy or no_opening_move:
|
||||
return f'{place}段证件尚未确认期初用量,请先确认期初后再安排'
|
||||
|
||||
return (
|
||||
f'{place}段找不到可用的购买证'
|
||||
f'{"和运输证" if source.requires_transport else ""},'
|
||||
'请补充证件或标记证书齐备后再安排'
|
||||
)
|
||||
|
||||
|
||||
def select_whole_cover_certs(db, user, sources, *, vehicle_no: str, on_date: date):
|
||||
"""返回 purchase_cert_ids, transport_cert_ids, manual_allocations。
|
||||
|
||||
@@ -85,28 +390,13 @@ def select_whole_cover_certs(db, user, sources, *, vehicle_no: str, on_date: dat
|
||||
customer_id = _customer_id_from_source(source)
|
||||
categories = category_ids_for_product(db, user.tenant_no, source.product_id)
|
||||
if not categories:
|
||||
product_label = _source_product_label(db, user, source) or '该产品'
|
||||
raise HTTPException(
|
||||
409,
|
||||
'产品未配置证用品类,请先在证用品类中关联系统产品后再安排运输',
|
||||
f'产品「{product_label}」未配置证用品类,请先在证用品类中关联系统产品后再安排运输',
|
||||
)
|
||||
|
||||
# MySQL ASC 会把 NULL 排最前;无到期证应排在末尾,优先消耗更早到期的证(D5)
|
||||
candidates = (
|
||||
db.query(PlPurchaseCert)
|
||||
.filter(
|
||||
PlPurchaseCert.tenant_no == user.tenant_no,
|
||||
PlPurchaseCert.del_flag == 0,
|
||||
PlPurchaseCert.status == 'active',
|
||||
PlPurchaseCert.customer_id == customer_id,
|
||||
PlPurchaseCert.category_id.in_(categories),
|
||||
)
|
||||
.order_by(
|
||||
PlPurchaseCert.valid_to.is_(None).asc(),
|
||||
PlPurchaseCert.valid_to.asc(),
|
||||
PlPurchaseCert.id.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
candidates = _purchase_candidates(db, user, customer_id, categories)
|
||||
|
||||
chosen_purchase = None
|
||||
chosen_transport = None
|
||||
@@ -116,11 +406,12 @@ 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):
|
||||
continue
|
||||
if purchase.opening_single_use_consumed:
|
||||
if is_single_use_consumed(db, user, 'purchase', purchase):
|
||||
continue
|
||||
try:
|
||||
need = _permit_qty_for(Decimal(source.quantity), source.unit, purchase.quantity_unit)
|
||||
@@ -134,42 +425,18 @@ def select_whole_cover_certs(db, user, sources, *, vehicle_no: str, on_date: dat
|
||||
purchase_permit_qty = need
|
||||
break
|
||||
|
||||
refs = (
|
||||
db.query(PlTransportCertPurchaseRef)
|
||||
.filter_by(
|
||||
tenant_no=user.tenant_no,
|
||||
purchase_cert_id=purchase.id,
|
||||
del_flag=0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
transport_cert_ids = [r.transport_cert_id for r in refs if r.transport_cert_id]
|
||||
if not transport_cert_ids:
|
||||
transports = _linked_transports(db, user, customer_id, purchase.id)
|
||||
if not transports:
|
||||
continue
|
||||
transports = (
|
||||
db.query(PlTransportCert)
|
||||
.filter(
|
||||
PlTransportCert.tenant_no == user.tenant_no,
|
||||
PlTransportCert.del_flag == 0,
|
||||
PlTransportCert.status == 'active',
|
||||
PlTransportCert.customer_id == customer_id,
|
||||
PlTransportCert.id.in_(transport_cert_ids),
|
||||
)
|
||||
.order_by(
|
||||
PlTransportCert.valid_to.is_(None).asc(),
|
||||
PlTransportCert.valid_to.asc(),
|
||||
PlTransportCert.id.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
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):
|
||||
continue
|
||||
if transport.opening_single_use_consumed:
|
||||
if is_single_use_consumed(db, user, 'transport', transport):
|
||||
continue
|
||||
if not _vehicle_ok(transport, vehicle_no):
|
||||
continue
|
||||
@@ -188,11 +455,11 @@ def select_whole_cover_certs(db, user, sources, *, vehicle_no: str, on_date: dat
|
||||
break
|
||||
|
||||
if chosen_purchase is None:
|
||||
place = '同市' if not source.requires_transport else '异地'
|
||||
raise HTTPException(
|
||||
409,
|
||||
f'{place}段找不到余量足够、有效期内且匹配品类/车号的购买证'
|
||||
f'{"和运输证" if source.requires_transport else ""},请补充证件或标记证书齐备后再安排',
|
||||
_diagnose_select_failure(
|
||||
db, user, source, vehicle_no=vehicle_no, on_date=on_date, candidates=candidates,
|
||||
),
|
||||
)
|
||||
|
||||
purchase_ids.append(chosen_purchase.id)
|
||||
@@ -221,3 +488,76 @@ def select_whole_cover_certs(db, user, sources, *, vehicle_no: str, on_date: dat
|
||||
return list(dict.fromkeys(values))
|
||||
|
||||
return unique(purchase_ids), unique(transport_ids), manuals
|
||||
|
||||
|
||||
def resolve_customer_category_cover_certs(db, user, sources):
|
||||
"""归档超额补扣:按客户+品类收集证件池,不查合同绑证。
|
||||
|
||||
与 select_whole_cover_certs 不同,这里不按数量筛证,把客户下匹配品类的
|
||||
有效购买证及其关联运输证全部纳入候选,由结转阶段结合本计划返还额度分配。
|
||||
"""
|
||||
if not sources:
|
||||
return [], []
|
||||
|
||||
purchase_ids = []
|
||||
transport_ids = []
|
||||
missing_both = []
|
||||
missing_purchase = []
|
||||
missing_transport = []
|
||||
|
||||
for source in sources:
|
||||
customer_id = _customer_id_from_source(source)
|
||||
snap = source.snapshot or {}
|
||||
customer_label = (
|
||||
snap.get('customerName')
|
||||
or snap.get('customer_name')
|
||||
or snap.get('customerNo')
|
||||
or snap.get('customer_no')
|
||||
or customer_id
|
||||
)
|
||||
categories = category_ids_for_product(db, user.tenant_no, source.product_id)
|
||||
if not categories:
|
||||
product_label = _source_product_label(db, user, source) or '该产品'
|
||||
raise HTTPException(
|
||||
409,
|
||||
f'产品「{product_label}」未配置证用品类,请先在证用品类中关联系统产品后再归档',
|
||||
)
|
||||
purchases = _purchase_candidates(db, user, customer_id, categories)
|
||||
needs_transport = getattr(source, 'requires_transport', True)
|
||||
if not purchases:
|
||||
(missing_both if needs_transport else missing_purchase).append(str(customer_label))
|
||||
continue
|
||||
purchase_ids.extend(p.id for p in purchases)
|
||||
if not needs_transport:
|
||||
continue
|
||||
linked = []
|
||||
for purchase in purchases:
|
||||
linked.extend(_linked_transports(db, user, customer_id, purchase.id))
|
||||
if not linked:
|
||||
missing_transport.append(str(customer_label))
|
||||
continue
|
||||
transport_ids.extend(t.id for t in linked)
|
||||
|
||||
messages = []
|
||||
if missing_both:
|
||||
names = '、'.join(dict.fromkeys(missing_both))
|
||||
messages.append(
|
||||
f'客户{names}下尚未建档匹配品类的购买证和运输证,请先建档证件后再归档'
|
||||
)
|
||||
if missing_purchase:
|
||||
names = '、'.join(dict.fromkeys(missing_purchase))
|
||||
messages.append(
|
||||
f'客户{names}下尚未建档匹配品类的购买证,请先建档证件后再归档'
|
||||
)
|
||||
if missing_transport:
|
||||
names = '、'.join(dict.fromkeys(missing_transport))
|
||||
messages.append(
|
||||
f'客户{names}下购买证尚未关联可用运输证,请先关联运输证后再归档'
|
||||
)
|
||||
if messages:
|
||||
raise HTTPException(409, ';'.join(messages))
|
||||
|
||||
def unique(values):
|
||||
return list(dict.fromkeys(values))
|
||||
|
||||
return unique(purchase_ids), unique(transport_ids)
|
||||
@@ -137,7 +137,9 @@ def build_usage_request(db, user, plan_id, operation, action, *, lock=False, che
|
||||
if customer_rule:
|
||||
# 新规则:禁止手工选证;按客户+品类整笔自动选证
|
||||
from app.services.pl_quota_customer_select import select_whole_cover_certs
|
||||
vehicle = (preview_vehicle or {}).get('licensePlate', plan.license_plate) or ''
|
||||
vehicle = str(
|
||||
(preview_vehicle or {}).get('licensePlate', plan.license_plate) or ''
|
||||
).strip()
|
||||
on_date = max(date.today(), plan.plan_date or date.today())
|
||||
purchase_ids, transport_ids, manuals = select_whole_cover_certs(
|
||||
db, user, sources, vehicle_no=vehicle, on_date=on_date,
|
||||
@@ -171,7 +173,7 @@ def build_usage_request(db, user, plan_id, operation, action, *, lock=False, che
|
||||
certs = {key: _get_row(db, user, *key, lock=False) for key in sorted(keys)}
|
||||
if sources and not keys:
|
||||
msg = (
|
||||
'找不到适用的购买证和运输证,请补充证件或标记证书齐备后再安排运输'
|
||||
'未选到可用的购买证和运输证。请核对证件余量、有效期、品类与车牌号后补充证件,或标记证书齐备后再安排运输'
|
||||
if customer_rule else
|
||||
'请先在合同中绑定购买证和运输证后再安排运输'
|
||||
)
|
||||
@@ -183,7 +185,7 @@ def build_usage_request(db, user, plan_id, operation, action, *, lock=False, che
|
||||
operation_id=op.operation_id, action=action, plan_id=plan_id, sources=sources,
|
||||
allow_supplement=op.allow_supplement,
|
||||
purchase_cert_ids=config.purchase_cert_ids, transport_cert_ids=config.transport_cert_ids,
|
||||
vehicle_no=(preview_vehicle or {}).get('licensePlate', plan.license_plate) or '', on_date=max(date.today(), plan.plan_date or date.today()),
|
||||
vehicle_no=str((preview_vehicle or {}).get('licensePlate', plan.license_plate) or '').strip(), on_date=max(date.today(), plan.plan_date or date.today()),
|
||||
executed=executed, manual_allocations=[UsageManual(**r.model_dump()) for r in config.manual_allocations],
|
||||
actual_by_source=actuals, manual_actuals=[UsageManual(**r.model_dump()) for r in op.manual_actuals],
|
||||
reason=op.reason, evidence_urls=op.evidence_urls or '', source_note=op.source_note or '',
|
||||
@@ -351,7 +353,11 @@ def get_plan_quota(db, user, plan_id):
|
||||
),
|
||||
'usages': []}
|
||||
for usage in usages:
|
||||
_get_row(db, user, usage.cert_type, usage.cert_id)
|
||||
cert = _get_row(db, user, usage.cert_type, usage.cert_id)
|
||||
items = db.query(PlPlanCertUsageItem).filter_by(tenant_no=user.tenant_no, usage_id=usage.id, del_flag=0).order_by(PlPlanCertUsageItem.allocation_seq).all()
|
||||
result['usages'].append({**_row_to_dict(usage), 'items': [_row_to_dict(row) for row in items]})
|
||||
payload = {**_row_to_dict(usage), 'items': [_row_to_dict(row) for row in items]}
|
||||
# 详情展示证面附件:用量行本身无附件字段,从证件档案回填
|
||||
payload['attachmentUrls'] = getattr(cert, 'attachment_urls', None) or ''
|
||||
payload['certNo'] = payload.get('certNo') or getattr(cert, 'cert_no', None) or usage.cert_no_snapshot
|
||||
result['usages'].append(payload)
|
||||
return result
|
||||
@@ -168,6 +168,10 @@ def extract_sources(db, user, plan_id, mapping=None, *, actual=False, lock=False
|
||||
snapshot['vehicle'] = preview_vehicle.get('licensePlate', plan.license_plate)
|
||||
snapshot['trailer'] = preview_vehicle.get('trailerNo', plan.trailer_no)
|
||||
fingerprint = hashlib.sha256(json.dumps(snapshot, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
||||
# 展示字段不参与 fingerprint,避免仅补产品名就触发来源指纹变化
|
||||
product_name = getattr(row, 'product_name', None)
|
||||
if product_name is not None and str(product_name).strip():
|
||||
snapshot = {**snapshot, 'productName': str(product_name).strip()}
|
||||
source = UsageSource(source_stop_type=side, source_stop_id=stop.id, source_stop_product_id=row.id,
|
||||
plan_product_id=row.plan_product_id, contract_id=row.contract_id,
|
||||
contract_product_id=row.contract_product_id, product_id=row.product_id,
|
||||
|
||||
@@ -4,12 +4,12 @@ from decimal import Decimal
|
||||
from sqlalchemy import and_, or_, literal, func
|
||||
from fastapi import HTTPException
|
||||
from app.crud.crm.data_scope_helpers import apply_crm_owner_scope
|
||||
from app.models.crm_models import CrmContract, CrmContractProduct
|
||||
from app.models.crm_models import CrmContract, CrmContractProduct, CrmCustomer
|
||||
from app.models.pl_models import PlPlan
|
||||
from app.models.pl_quota_models import (PlContractCertLink as Link, PlContractCertProduct as Scope,
|
||||
PlPlanCertUsage as Usage, PlPlanCertUsageItem as Item, PlCertQtyLog as Log)
|
||||
from app.services.pl_data_scope import apply_pl_scope
|
||||
from app.services.pl_quota_cert_service import MODELS, _get_row
|
||||
from app.services.pl_quota_cert_service import MODELS, _get_row, get_cert, usage_policy
|
||||
|
||||
ZERO = Decimal('0')
|
||||
CONTRACT_TYPES = {'sale', 'purchase', 'framework_sale', 'framework_purchase', 'framework'}
|
||||
@@ -109,6 +109,39 @@ def _contracts(db, user):
|
||||
user.to_user_info(), db, user.tenant_no)
|
||||
|
||||
|
||||
def _customers(db, user):
|
||||
return apply_crm_owner_scope(_active(db, user, CrmCustomer), CrmCustomer,
|
||||
user.to_user_info(), db, user.tenant_no)
|
||||
|
||||
|
||||
def _customer_cert_counts(db, user):
|
||||
"""按客户汇总购买证+运输证数量;默认排除已核销/作废,与证件列表一致。"""
|
||||
parts = []
|
||||
for model in MODELS.values():
|
||||
query = apply_pl_scope(_active(db, user, model), model, user, db, user.tenant_no)
|
||||
query = query.filter(
|
||||
model.customer_id.isnot(None),
|
||||
model.customer_id != '',
|
||||
model.status.notin_(('closed', 'void')),
|
||||
)
|
||||
parts.append(query.with_entities(model.customer_id.label('customer_id'), literal(1).label('one')))
|
||||
combined = parts[0].union_all(*parts[1:]).subquery()
|
||||
return (
|
||||
db.query(combined.c.customer_id.label('customer_id'), func.count().label('cert_count'))
|
||||
.group_by(combined.c.customer_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
|
||||
def _customer_summary(row):
|
||||
return {
|
||||
'id': row.id,
|
||||
'customerId': row.id,
|
||||
'customerNo': row.customer_no or '',
|
||||
'customerName': row.customer_name or '',
|
||||
}
|
||||
|
||||
|
||||
def _certificates(db, user, keyword=None, cert_type=None, cert_no=None, cert_types=None):
|
||||
queries = []
|
||||
for kind, model in MODELS.items():
|
||||
@@ -351,6 +384,78 @@ def contract_statistics(db, user, contract_id):
|
||||
return {**_contract_summary(contract), 'certificates': result}
|
||||
|
||||
|
||||
def list_customers(db, user, keyword=None, page=1, page_size=20, customer_no=None, customer_name=None,
|
||||
cert_count_op=None, cert_count=None, cert_count_min=None, cert_count_max=None):
|
||||
counts = _customer_cert_counts(db, user)
|
||||
query = _customers(db, user).join(counts, counts.c.customer_id == CrmCustomer.id)
|
||||
if keyword:
|
||||
query = query.filter(or_(
|
||||
CrmCustomer.customer_no.contains(keyword, autoescape=True),
|
||||
CrmCustomer.customer_name.contains(keyword, autoescape=True),
|
||||
CrmCustomer.short_name.contains(keyword, autoescape=True),
|
||||
))
|
||||
if _text(customer_no):
|
||||
query = query.filter(CrmCustomer.customer_no.contains(_text(customer_no), autoescape=True))
|
||||
if _text(customer_name):
|
||||
query = query.filter(CrmCustomer.customer_name.contains(_text(customer_name), autoescape=True))
|
||||
for clause in _number_clauses(counts.c.cert_count, cert_count_op, cert_count, cert_count_min, cert_count_max):
|
||||
query = query.filter(clause)
|
||||
total = query.count()
|
||||
rows = query.with_entities(CrmCustomer, counts.c.cert_count).order_by(
|
||||
CrmCustomer.create_time.desc(), CrmCustomer.id
|
||||
).offset((page - 1) * page_size).limit(page_size).all()
|
||||
return {
|
||||
'list': [{**_customer_summary(customer), 'certCount': int(count or 0)} for customer, count in rows],
|
||||
'total': total,
|
||||
'page': page,
|
||||
'pageSize': page_size,
|
||||
}
|
||||
|
||||
|
||||
def customer_statistics(db, user, customer_id):
|
||||
customer = _customers(db, user).filter(CrmCustomer.id == customer_id).first()
|
||||
if customer is None:
|
||||
raise HTTPException(404, '客户不存在或无权访问')
|
||||
certificates = []
|
||||
for kind, model in MODELS.items():
|
||||
query = apply_pl_scope(_active(db, user, model), model, user, db, user.tenant_no)
|
||||
query = query.filter(
|
||||
model.customer_id == customer_id,
|
||||
model.status.notin_(('closed', 'void')),
|
||||
)
|
||||
for row in query.order_by(model.create_time.desc(), model.id).all():
|
||||
item = get_cert(db, user, kind, row.id)
|
||||
sale = item.get('sellerName') if kind == 'purchase' else item.get('shipperName')
|
||||
buy = item.get('buyerName') if kind == 'purchase' else item.get('consigneeName')
|
||||
certificates.append({
|
||||
'id': row.id,
|
||||
'certId': row.id,
|
||||
'certType': kind,
|
||||
'certNo': item.get('certNo') or '',
|
||||
'productNameOnCert': item.get('productNameOnCert') or '',
|
||||
'quantityUnit': item.get('quantityUnit') or '',
|
||||
'totalQty': item.get('totalQty'),
|
||||
'reservedQty': item.get('reservedQty'),
|
||||
'usedQty': item.get('usedQty'),
|
||||
'availableQty': item.get('availableQty'),
|
||||
'status': item.get('status') or '',
|
||||
'validFrom': item.get('validFrom'),
|
||||
'validTo': item.get('validTo'),
|
||||
'salePartyName': sale or '',
|
||||
'buyPartyName': buy or '',
|
||||
'validUseText': item.get('validUseText') or '',
|
||||
'usagePolicy': item.get('usagePolicy') or usage_policy(row.valid_use_text),
|
||||
'singleUseConsumed': bool(item.get('singleUseConsumed')),
|
||||
'openingConfirmed': bool(item.get('openingConfirmed')),
|
||||
'createTime': item.get('createTime'),
|
||||
})
|
||||
certificates.sort(
|
||||
key=lambda row: (str(row.get('createTime') or ''), row.get('certNo') or ''),
|
||||
reverse=True,
|
||||
)
|
||||
return {**_customer_summary(customer), 'certificates': certificates}
|
||||
|
||||
|
||||
def certificate_statistics(db, user, cert_type, cert_id):
|
||||
_get_row(db, user, cert_type, cert_id)
|
||||
certs = _certificates(db, user, cert_type=cert_type)
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.models.pl_models import PlPlan
|
||||
from app.models.crm_models import CrmContractProduct
|
||||
from app.schemas.pl.quota_usage import UsageManual, UsageRequest
|
||||
from app.services.pl_data_scope import assert_pl_row_in_scope
|
||||
from app.services.pl_quota_cert_service import _audit, _get_row, usage_policy
|
||||
from app.services.pl_quota_cert_service import _audit, _get_row, is_single_use_consumed, usage_policy
|
||||
from app.services.pl_quota_contract_service import _contract
|
||||
from app.services.pl_quota_allocation import (SourceDemand, PermitCandidate, ManualAllocation, Allocation,
|
||||
allocate_plan, settle_allocations, validate_allocation_pairing, InsufficientQuota)
|
||||
@@ -26,6 +26,26 @@ from app.services.pl_quota_units import same_quantity_unit, standard_conversion_
|
||||
ZERO = Decimal('0')
|
||||
|
||||
|
||||
def _normalize_vehicle_no(vehicle_no) -> str:
|
||||
return str(vehicle_no or '').strip()
|
||||
|
||||
|
||||
def _vehicle_on_cert(cert, vehicle_no) -> bool:
|
||||
"""计划车牌与证上车号比对时两侧都去空白,避免尾部空格误判。"""
|
||||
plate = _normalize_vehicle_no(vehicle_no)
|
||||
if not plate:
|
||||
return False
|
||||
plates = getattr(cert, 'vehicle_numbers_json', None) or []
|
||||
if isinstance(plates, str):
|
||||
try:
|
||||
plates = json.loads(plates)
|
||||
except Exception:
|
||||
plates = [plates]
|
||||
if not isinstance(plates, list):
|
||||
return False
|
||||
return plate in {str(p).strip() for p in plates if p is not None and str(p).strip()}
|
||||
|
||||
|
||||
def _reserve_input_mode(source_unit, cert_unit):
|
||||
if same_quantity_unit(source_unit, cert_unit):
|
||||
return 'same_unit'
|
||||
@@ -131,9 +151,8 @@ def _execute(db,user,r,digest,plan):
|
||||
selected={('purchase',x) for x in r.purchase_cert_ids}|{('transport',x) for x in r.transport_cert_ids}
|
||||
keys=selected|{(u.cert_type,u.cert_id) for u in old_rows}
|
||||
if r.action in ('settle', 'actual_adjust') and not r.allow_supplement and r.sources:
|
||||
from app.services.pl_quota_planning import resolve_contract_bound_certs
|
||||
try:
|
||||
purchase_ids, transport_ids = resolve_contract_bound_certs(db, user, r.sources)
|
||||
purchase_ids, transport_ids = _settle_cover_cert_ids(db, user, plan, r.sources)
|
||||
except HTTPException:
|
||||
purchase_ids, transport_ids = [], []
|
||||
keys |= {('purchase', cert_id) for cert_id in purchase_ids} | {('transport', cert_id) for cert_id in transport_ids}
|
||||
@@ -200,8 +219,10 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
|
||||
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('证件尚未启用复核')
|
||||
@@ -210,13 +231,11 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
|
||||
policy=usage_policy(c.valid_use_text)
|
||||
if policy=='needs_confirmation': raise ValueError('有效次数待确认')
|
||||
# 合同上常有多张运输证。车号对不上的证不参与本次分配,只要还有一张覆盖该主车号即可安排。
|
||||
if kind=='transport' and (not r.vehicle_no or r.vehicle_no not in (c.vehicle_numbers_json or [])):
|
||||
if kind=='transport' and not _vehicle_on_cert(c, r.vehicle_no):
|
||||
skipped_for_vehicle=True
|
||||
continue
|
||||
if policy=='once':
|
||||
previous=query(m.PlPlanCertUsage,cert_type=kind,cert_id=pid,del_flag=0).all()
|
||||
if c.opening_single_use_consumed or any(u.plan_id!=plan.id and (u.usage_status in ('reserved','in_transit','settled','exception') or (u.confirmation_snapshot or {}).get('executed')) for u in previous):
|
||||
raise ValueError('一次有效证件已使用或已占用')
|
||||
if policy=='once' and is_single_use_consumed(db, user, kind, c, exclude_plan_id=plan.id):
|
||||
raise ValueError('一次有效证件已使用或已占用')
|
||||
eligible=set()
|
||||
if customer_rule:
|
||||
# 新规则已由客户+品类整笔选证;账本不再依赖合同绑证 scope。
|
||||
@@ -233,7 +252,21 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
|
||||
own=usages[k].reserved_qty if k in usages else ZERO
|
||||
candidates[kind].append(PermitCandidate(pid,c.total_qty-c.used_qty-c.reserved_qty+own,c.quantity_unit,c.valid_to,frozenset(eligible),refs))
|
||||
if skipped_for_vehicle and not candidates['transport']:
|
||||
raise ValueError('车号不在运输证范围')
|
||||
plate = _normalize_vehicle_no(r.vehicle_no)
|
||||
if not plate:
|
||||
raise ValueError('安排运输须填写车牌号;当前未填写车牌,无法匹配运输证车号')
|
||||
nos = '、'.join(
|
||||
dict.fromkeys(
|
||||
str(certs[k].cert_no or k[1])
|
||||
for k in sorted(selected)
|
||||
if k[0] == 'transport' and k in certs
|
||||
)
|
||||
)
|
||||
suffix = f'(已选运输证 {nos})' if nos else ''
|
||||
raise ValueError(
|
||||
f'车牌号「{plate}」不在运输证车号范围内{suffix},'
|
||||
'请到运输证中补充该车号或更换车辆后再安排'
|
||||
)
|
||||
return sources,scopes,candidates
|
||||
|
||||
def _reserve(db,user,r,plan,certs,usages,items,selected,preview=False,lock_preview=False):
|
||||
@@ -362,35 +395,88 @@ def _validate_current_sources(r,active):
|
||||
raise ValueError('当前权威来源身份、计划量、单位或指纹已变化,请重新核对用证')
|
||||
|
||||
class ArchiveQuotaBlocked(ValueError):
|
||||
"""合同上的购买证、运输证仍不够本次实际量,归档必须整单回滚。"""
|
||||
"""可用购买证、运输证仍不够本次实际量,归档必须整单回滚。"""
|
||||
|
||||
|
||||
ARCHIVE_QUOTA_BLOCK = '合同绑定的购买证和运输证剩余额度不够覆盖本次实际数量,不能归档。请先补充证件额度或调整实际数量后再归档。'
|
||||
CUSTOMER_ARCHIVE_QUOTA_BLOCK = (
|
||||
'客户名下购买证和运输证剩余额度不够覆盖本次实际数量,不能归档。'
|
||||
'请先补充证件额度或调整实际数量后再归档。'
|
||||
)
|
||||
ARCHIVE_CONVERSION_EXCESS_BLOCK = (
|
||||
'实际数量超出安排时的预占,且原证件剩余额度不够按原换算系数继续扣减,不能归档。'
|
||||
'请把实际数量改小,或先补充证件额度后再归档。'
|
||||
)
|
||||
|
||||
|
||||
def _settle_cover_cert_ids(db, user, plan, sources):
|
||||
"""归档超额补扣证件池:新规则按客户+品类,旧规则按合同绑证。"""
|
||||
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
|
||||
if plan_uses_customer_category(plan):
|
||||
from app.services.pl_quota_customer_select import resolve_customer_category_cover_certs
|
||||
return resolve_customer_category_cover_certs(db, user, sources)
|
||||
from app.services.pl_quota_planning import resolve_contract_bound_certs
|
||||
return resolve_contract_bound_certs(db, user, sources)
|
||||
|
||||
|
||||
def _settlement_cover_cert(db, user, r, plan, kind, cert):
|
||||
"""合同上另有证件时,不合格的证跳过,不因此挡住其余还能扣的证。"""
|
||||
return _settlement_cover_reject_reason(db, user, r, plan, kind, cert) is None
|
||||
|
||||
|
||||
def _settlement_cover_reject_reason(db, user, r, plan, kind, cert):
|
||||
"""返回不可用于归档补扣的具体原因;可用时返回 None。"""
|
||||
from app.services.pl_quota_cert_service import ensure_zero_opening
|
||||
cert = ensure_zero_opening(db, user, kind, cert.id, row=cert)
|
||||
label = '购买证' if kind == 'purchase' else '运输证'
|
||||
no = str(cert.cert_no or cert.id)
|
||||
if not _query(db, user, m.PlCertQtyLog, cert_type=kind, cert_id=cert.id, event_type='opening', del_flag=0).first():
|
||||
return False
|
||||
return f'{label} {no} 尚未确认期初用量'
|
||||
if cert.status != 'active' or not cert.verified_time or not cert.verified_by:
|
||||
return False
|
||||
return f'{label} {no} 未启用或未复核'
|
||||
if cert.valid_from is None or cert.valid_to is None or not cert.valid_from <= r.on_date <= cert.valid_to:
|
||||
return False
|
||||
return f'{label} {no} 不在归档日期 {r.on_date.isoformat()} 的有效期内'
|
||||
policy = usage_policy(cert.valid_use_text)
|
||||
if policy == 'needs_confirmation':
|
||||
return False
|
||||
if kind == 'transport' and (not r.vehicle_no or r.vehicle_no not in (cert.vehicle_numbers_json or [])):
|
||||
return False
|
||||
if policy == 'once':
|
||||
previous = _query(db, user, m.PlPlanCertUsage, cert_type=kind, cert_id=cert.id, del_flag=0).all()
|
||||
if cert.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
|
||||
):
|
||||
return False
|
||||
return True
|
||||
return f'{label} {no} 有效次数待确认'
|
||||
if kind == 'transport':
|
||||
plate = _normalize_vehicle_no(r.vehicle_no)
|
||||
if not plate:
|
||||
return f'运输证 {no} 需要车牌号,当前计划未填写车牌'
|
||||
if not _vehicle_on_cert(cert, plate):
|
||||
raw = cert.vehicle_numbers_json or []
|
||||
if isinstance(raw, str):
|
||||
listed = raw
|
||||
else:
|
||||
listed = '、'.join(str(p).strip() for p in raw if str(p).strip())
|
||||
suffix = f'(证上现有车号:{listed})' if listed else ''
|
||||
return f'运输证 {no} 未包含车牌号「{plate}」{suffix}'
|
||||
if policy == 'once' and is_single_use_consumed(db, user, kind, cert, exclude_plan_id=plan.id):
|
||||
return f'{label} {no} 为一次有效且已使用'
|
||||
return None
|
||||
|
||||
|
||||
def _archive_cover_block_message(db, user, r, plan, certs, candidate_keys):
|
||||
"""补证候选全被跳过时,汇总具体原因,避免笼统额度不足。"""
|
||||
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
|
||||
customer_rule = plan_uses_customer_category(plan)
|
||||
reasons = []
|
||||
for kind, cid in candidate_keys:
|
||||
key = (kind, cid)
|
||||
cert = certs.get(key)
|
||||
if cert is None:
|
||||
label = '购买证' if kind == 'purchase' else '运输证'
|
||||
reasons.append(f'{label} {cid} 不存在或不可用')
|
||||
continue
|
||||
reason = _settlement_cover_reject_reason(db, user, r, plan, kind, cert)
|
||||
if reason:
|
||||
reasons.append(reason)
|
||||
if not reasons:
|
||||
return CUSTOMER_ARCHIVE_QUOTA_BLOCK if customer_rule else ARCHIVE_QUOTA_BLOCK
|
||||
unique = list(dict.fromkeys(reasons))
|
||||
detail = ';'.join(unique[:4])
|
||||
scope = '客户名下' if customer_rule else '合同上'
|
||||
return f'{detail}。{scope}没有其他可用证件覆盖本次实际数量,不能归档。请先处理上述问题后再归档。'
|
||||
|
||||
|
||||
_QTY = Decimal('0.000001')
|
||||
@@ -447,7 +533,7 @@ def _prepare_saved_conversion(groups, actual_by_source, certs):
|
||||
proposals[kind] = [row for row in rows if row.source_id != sid] + updated
|
||||
mine = updated
|
||||
if need > 0:
|
||||
return groups, [], ARCHIVE_QUOTA_BLOCK
|
||||
return groups, [], ARCHIVE_CONVERSION_EXCESS_BLOCK
|
||||
remaining = actual
|
||||
for row in sorted(mine, key=lambda item: item.allocation_seq):
|
||||
take = min(remaining, row.source_quantity)
|
||||
@@ -488,8 +574,8 @@ def _settle(db,user,r,certs,usages,items,plan):
|
||||
raise ArchiveQuotaBlocked(conversion_error)
|
||||
if generated:
|
||||
r = r.model_copy(update={'manual_actuals': generated})
|
||||
# 同单位超出预占时,自动改用合同上已绑定的购买证和运输证,含原证剩余额度。
|
||||
contract_cover = False
|
||||
# 同单位超出预占时,自动改用补扣证件池(新规则客户+品类,旧规则合同绑证),含原证剩余额度。
|
||||
auto_cover = False
|
||||
if not r.allow_supplement:
|
||||
for sid, value in r.actual_by_source.items():
|
||||
mine = [a for rows in groups.values() for a in rows if a.source_id == sid]
|
||||
@@ -497,7 +583,7 @@ def _settle(db,user,r,certs,usages,items,plan):
|
||||
continue
|
||||
capacity = sum((a.source_quantity for a in groups['purchase'] if a.source_id == sid), ZERO)
|
||||
if value > capacity:
|
||||
contract_cover = True
|
||||
auto_cover = True
|
||||
break
|
||||
# 原始容量不随补量扩大;新增证件原始容量为零,后续纠错始终先用原分配。
|
||||
base_actual=dict(r.actual_by_source)
|
||||
@@ -506,7 +592,7 @@ def _settle(db,user,r,certs,usages,items,plan):
|
||||
capacity=sum((a.source_quantity for a in groups['purchase'] if a.source_id==sid),ZERO)
|
||||
if value>capacity:
|
||||
excess[sid]=value-capacity
|
||||
if r.allow_supplement or contract_cover: base_actual[sid]=capacity
|
||||
if r.allow_supplement or auto_cover: base_actual[sid]=capacity
|
||||
# 人工显式补量可从当前实运来源拆出一段(如原证24、新证5),两段合计仍是29。
|
||||
# 两证有一侧同单位时由联合分配器配对;异单位补量仍须完整人工输入。
|
||||
if r.allow_supplement and r.manual_allocations:
|
||||
@@ -558,35 +644,52 @@ def _settle(db,user,r,certs,usages,items,plan):
|
||||
settled={kind:settle_allocations(rows,base_actual,manual_actuals=[a for a in _manual(r.manual_actuals) if a.permit_kind==kind]) for kind,rows in groups.items()}
|
||||
supplemental=[]
|
||||
if excess:
|
||||
if contract_cover:
|
||||
from app.services.pl_quota_planning import resolve_contract_bound_certs
|
||||
if auto_cover:
|
||||
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
|
||||
customer_rule = plan_uses_customer_category(plan)
|
||||
try:
|
||||
purchase_ids, transport_ids = resolve_contract_bound_certs(db, user, r.sources)
|
||||
purchase_ids, transport_ids = _settle_cover_cert_ids(db, user, plan, r.sources)
|
||||
except HTTPException as exc:
|
||||
raise ArchiveQuotaBlocked(str(getattr(exc, 'detail', exc))) from exc
|
||||
candidate_keys=[('purchase', x) for x in purchase_ids] + [('transport', x) for x in transport_ids]
|
||||
selected=set()
|
||||
for kind, cid in [('purchase', x) for x in purchase_ids] + [('transport', x) for x in transport_ids]:
|
||||
for kind, cid in candidate_keys:
|
||||
key=(kind, cid)
|
||||
if key not in certs:
|
||||
raise ArchiveQuotaBlocked(ARCHIVE_QUOTA_BLOCK)
|
||||
raise ArchiveQuotaBlocked(
|
||||
_archive_cover_block_message(db, user, r, plan, certs, candidate_keys)
|
||||
)
|
||||
if _settlement_cover_cert(db, user, r, plan, kind, certs[key]):
|
||||
selected.add(key)
|
||||
if not selected:
|
||||
raise ArchiveQuotaBlocked(ARCHIVE_QUOTA_BLOCK)
|
||||
raise ArchiveQuotaBlocked(
|
||||
_archive_cover_block_message(db, user, r, plan, certs, candidate_keys)
|
||||
)
|
||||
else:
|
||||
if not r.reason.strip() or not (r.evidence_urls.strip() or r.source_note.strip()):
|
||||
raise ValueError('补分配必须有理由及依据')
|
||||
selected={('purchase',x) for x in r.purchase_cert_ids}|{('transport',x) for x in r.transport_cert_ids}
|
||||
customer_rule = False
|
||||
# 校验器看到的是扣除原量、返还本计划旧账后的净可用量。
|
||||
base_totals={k:ZERO for k in usages}
|
||||
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:
|
||||
if not contract_cover:
|
||||
if not auto_cover:
|
||||
raise
|
||||
from app.services.pl_quota_diagnostics import archive_quantity_shortfall_message
|
||||
cert_rows=[]
|
||||
@@ -600,7 +703,8 @@ def _settle(db,user,r,certs,usages,items,plan):
|
||||
message=archive_quantity_shortfall_message(
|
||||
getattr(exc, 'diagnostics', None), cert_rows, r.actual_by_source, excess,
|
||||
{source.source_id: source.unit for source in r.sources})
|
||||
raise ArchiveQuotaBlocked(message or ARCHIVE_QUOTA_BLOCK) from exc
|
||||
fallback = CUSTOMER_ARCHIVE_QUOTA_BLOCK if customer_rule else ARCHIVE_QUOTA_BLOCK
|
||||
raise ArchiveQuotaBlocked(message or fallback) from exc
|
||||
for kind in ('purchase','transport'):
|
||||
for row in result[kind+'Allocations']:
|
||||
supplemental.append((kind,row))
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
@@ -61,6 +139,47 @@ def test_apply_plan_quota_new_rule_waive_skips_reserve_not_settle(monkeypatch):
|
||||
assert '待完善' in str(exc.value.detail)
|
||||
|
||||
|
||||
def test_settle_pending_fulfill_without_usage_rows_keeps_pending_message(monkeypatch):
|
||||
"""证书齐备待完善且无用量行时,归档结转错误不能回落成通用扣减文案。"""
|
||||
from contextlib import nullcontext
|
||||
|
||||
from app.services import pl_quota_business as biz
|
||||
from fastapi import HTTPException
|
||||
|
||||
plan = SimpleNamespace(
|
||||
id='p1',
|
||||
quota_cert_waived=1,
|
||||
quota_cert_pending_fulfill=1,
|
||||
cert_quota_rule_version='customer_category',
|
||||
cert_configuration={},
|
||||
archive_status='none',
|
||||
)
|
||||
user = SimpleNamespace(id='u', tenant_no='t')
|
||||
pending = '该计划仍处于证书齐备待完善,请先在证书齐备中完善并关闭后再归档'
|
||||
|
||||
class _Query:
|
||||
def filter_by(self, **kwargs):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
db = MagicMock()
|
||||
db.query.return_value = _Query()
|
||||
db.flush = MagicMock()
|
||||
db.begin_nested = MagicMock(return_value=nullcontext())
|
||||
|
||||
def boom(*_a, **_k):
|
||||
raise HTTPException(409, pending)
|
||||
|
||||
monkeypatch.setattr(biz, 'apply_plan_quota', boom)
|
||||
|
||||
assert biz.settle_plan_quota(db, user, plan, None) is False
|
||||
message = biz.read_quota_settlement_error(db, 't', 'p1', plan=plan)
|
||||
assert '待完善' in message
|
||||
assert '证件没有扣减' not in message
|
||||
|
||||
|
||||
def test_list_certs_default_excludes_closed_void(monkeypatch):
|
||||
from app.services import pl_quota_cert_service as svc
|
||||
|
||||
@@ -463,3 +582,653 @@ def test_candidate_context_customer_rule_skips_contract_scope(monkeypatch):
|
||||
assert 'delivery:sp1' in sources
|
||||
assert scopes[(('purchase', 'buy1'), 'delivery:sp1')] == (None, None)
|
||||
assert candidates['purchase'][0].product_ids == frozenset({'delivery:sp1'})
|
||||
|
||||
|
||||
def test_list_certs_filters_by_customer_id(monkeypatch):
|
||||
from app.services import pl_quota_cert_service as svc
|
||||
|
||||
class Col:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
class CustomerCol:
|
||||
def __eq__(self, other):
|
||||
return ('CUSTOMER_FILTER', other)
|
||||
|
||||
class FakeModel:
|
||||
__tablename__ = 'pl_purchase_cert'
|
||||
tenant_no = SimpleNamespace()
|
||||
del_flag = 0
|
||||
customer_id = CustomerCol()
|
||||
status = SimpleNamespace(notin_=lambda *a, **k: 'STATUS_FILTER')
|
||||
create_time = SimpleNamespace(desc=lambda: 'DESC')
|
||||
cert_no = seller_name = buyer_name = SimpleNamespace(contains=lambda *a, **k: True)
|
||||
__table__ = SimpleNamespace(columns=[Col('id'), Col('status')])
|
||||
|
||||
class Query:
|
||||
def __init__(self):
|
||||
self.filters = []
|
||||
|
||||
def filter(self, *a, **k):
|
||||
self.filters.extend(a)
|
||||
return self
|
||||
|
||||
def filter_by(self, **k):
|
||||
return self
|
||||
|
||||
def order_by(self, *a):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
q = Query()
|
||||
db = MagicMock()
|
||||
db.query.return_value = q
|
||||
monkeypatch.setattr(svc, '_model', lambda t: FakeModel)
|
||||
monkeypatch.setattr(svc, 'apply_pl_scope', lambda query, *a, **k: query)
|
||||
monkeypatch.setattr(svc, 'get_cert', lambda *a, **k: {})
|
||||
svc.list_certs(db, SimpleNamespace(tenant_no='t'), 'purchase', customer_id='cust-1')
|
||||
assert ('CUSTOMER_FILTER', 'cust-1') in q.filters
|
||||
assert any(f == 'STATUS_FILTER' for f in q.filters)
|
||||
|
||||
|
||||
def test_is_single_use_consumed_blocks_other_plan_usage():
|
||||
from app.services.pl_quota_cert_service import is_single_use_consumed
|
||||
from app.models.pl_quota_models import PlPlanCertUsage
|
||||
|
||||
cert = SimpleNamespace(id='buy1', valid_use_text='一次有效', opening_single_use_consumed=0)
|
||||
usage_row = SimpleNamespace(
|
||||
plan_id='other-plan',
|
||||
usage_status='reserved',
|
||||
confirmation_snapshot=None,
|
||||
)
|
||||
|
||||
class UsageQuery:
|
||||
def filter_by(self, **k):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return [usage_row]
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
def q_side(model):
|
||||
assert model is PlPlanCertUsage
|
||||
return UsageQuery()
|
||||
|
||||
db.query.side_effect = q_side
|
||||
user = SimpleNamespace(tenant_no='t')
|
||||
assert is_single_use_consumed(db, user, 'purchase', cert) is True
|
||||
assert is_single_use_consumed(db, user, 'purchase', cert, exclude_plan_id='other-plan') is False
|
||||
multi = SimpleNamespace(id='buy2', valid_use_text='多次有效', opening_single_use_consumed=1)
|
||||
assert is_single_use_consumed(db, user, 'purchase', multi) is False
|
||||
|
||||
|
||||
def test_whole_cover_skips_once_used_purchase_with_remaining(monkeypatch):
|
||||
"""一次有效证已被他计划占用时,即使余量足够也不能再选。"""
|
||||
from app.services import pl_quota_customer_select as sel
|
||||
from app.schemas.pl.quota_usage import UsageSource
|
||||
|
||||
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('10'),
|
||||
unit='吨',
|
||||
fingerprint='y' * 64,
|
||||
snapshot={'customerId': 'cust1'},
|
||||
requires_transport=False,
|
||||
)
|
||||
used_once = SimpleNamespace(
|
||||
id='buy-once', customer_id='cust1', category_id='cat1', status='active',
|
||||
verified_by='u', verified_time=datetime.now(), valid_from=None, valid_to=date(2026, 12, 1),
|
||||
total_qty=Decimal('100'), used_qty=Decimal('10'), reserved_qty=Decimal('0'),
|
||||
quantity_unit='吨', opening_single_use_consumed=0, valid_use_text='一次有效',
|
||||
)
|
||||
fresh = SimpleNamespace(
|
||||
id='buy-fresh', customer_id='cust1', category_id='cat1', status='active',
|
||||
verified_by='u', verified_time=datetime.now(), valid_from=None, valid_to=date(2027, 6, 1),
|
||||
total_qty=Decimal('100'), used_qty=Decimal('0'), reserved_qty=Decimal('0'),
|
||||
quantity_unit='吨', opening_single_use_consumed=0, valid_use_text='多次有效',
|
||||
)
|
||||
|
||||
class PurchaseQuery:
|
||||
def filter(self, *a, **k):
|
||||
return self
|
||||
|
||||
def order_by(self, *a, **k):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return [used_once, fresh]
|
||||
|
||||
class LogQuery:
|
||||
def filter_by(self, **k):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return SimpleNamespace(id='opening')
|
||||
|
||||
from app.models.pl_quota_models import PlPurchaseCert, PlCertQtyLog
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
def q_side(model):
|
||||
if model is PlPurchaseCert:
|
||||
return PurchaseQuery()
|
||||
if model is PlCertQtyLog:
|
||||
return LogQuery()
|
||||
return MagicMock()
|
||||
|
||||
db.query.side_effect = q_side
|
||||
user = SimpleNamespace(tenant_no='t')
|
||||
monkeypatch.setattr(sel, 'category_ids_for_product', lambda db, tenant, pid: {'cat1'})
|
||||
monkeypatch.setattr(
|
||||
sel,
|
||||
'is_single_use_consumed',
|
||||
lambda db, user, kind, cert, **kw: cert.id == 'buy-once',
|
||||
)
|
||||
purchase_ids, transport_ids, manuals = sel.select_whole_cover_certs(
|
||||
db, user, [source], vehicle_no='皖A12345', on_date=date(2026, 11, 14),
|
||||
)
|
||||
assert purchase_ids == ['buy-fresh']
|
||||
assert transport_ids == []
|
||||
assert manuals[0].permit_id == 'buy-fresh'
|
||||
|
||||
|
||||
def test_list_customers_requires_customer_cert_counts(monkeypatch):
|
||||
from app.services import pl_quota_statistics as stats
|
||||
|
||||
class Q:
|
||||
def __init__(self, total=1, rows=None):
|
||||
self._total = total
|
||||
self._rows = rows or []
|
||||
|
||||
def join(self, *a, **k):
|
||||
return self
|
||||
|
||||
def filter(self, *a, **k):
|
||||
return self
|
||||
|
||||
def count(self):
|
||||
return self._total
|
||||
|
||||
def with_entities(self, *a, **k):
|
||||
return self
|
||||
|
||||
def order_by(self, *a, **k):
|
||||
return self
|
||||
|
||||
def offset(self, *a, **k):
|
||||
return self
|
||||
|
||||
def limit(self, *a, **k):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return self._rows
|
||||
|
||||
customer = SimpleNamespace(id='cust1', customer_no='C1', customer_name='甲客户', create_time=None)
|
||||
monkeypatch.setattr(stats, '_customer_cert_counts', lambda db, user: SimpleNamespace(c=SimpleNamespace(customer_id='id', cert_count='cnt')))
|
||||
monkeypatch.setattr(stats, '_customers', lambda db, user: Q(rows=[(customer, 3)]))
|
||||
result = stats.list_customers(MagicMock(), SimpleNamespace(tenant_no='t', to_user_info=lambda: {}))
|
||||
assert result['total'] == 1
|
||||
assert result['list'][0]['customerName'] == '甲客户'
|
||||
assert result['list'][0]['certCount'] == 3
|
||||
|
||||
|
||||
def test_get_cert_customer_name_from_crm_not_consignee(monkeypatch):
|
||||
"""详情/编辑用 CRM 系统客户名,不用证面收货单位。"""
|
||||
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='cust-kanghua',
|
||||
consignee_name='国能宿州热电有限公司',
|
||||
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('consignee_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, '_refs', lambda *a, **k: [])
|
||||
monkeypatch.setattr(svc, '_has_history', lambda *a, **k: False)
|
||||
monkeypatch.setattr(svc, '_transport_derived_categories', 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['customerName'] == '滁州康华电子材料有限公司'
|
||||
assert result['consigneeName'] == '国能宿州热电有限公司'
|
||||
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
|
||||
|
||||
base = dict(
|
||||
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('30'),
|
||||
unit='吨',
|
||||
fingerprint='r' * 64,
|
||||
snapshot={'customerId': 'cust1'},
|
||||
requires_transport=True,
|
||||
)
|
||||
base.update(kw)
|
||||
return UsageSource(**base)
|
||||
|
||||
|
||||
def _ok_purchase(**kw):
|
||||
data = dict(
|
||||
id='buy1',
|
||||
cert_no='G-BUY-1',
|
||||
customer_id='cust1',
|
||||
category_id='cat1',
|
||||
status='active',
|
||||
verified_by='u',
|
||||
verified_time=datetime.now(),
|
||||
valid_from=None,
|
||||
valid_to=date(2027, 1, 1),
|
||||
total_qty=Decimal('100'),
|
||||
used_qty=Decimal('0'),
|
||||
reserved_qty=Decimal('0'),
|
||||
quantity_unit='吨',
|
||||
opening_single_use_consumed=0,
|
||||
)
|
||||
data.update(kw)
|
||||
return SimpleNamespace(**data)
|
||||
|
||||
|
||||
def _ok_transport(**kw):
|
||||
data = dict(
|
||||
id='tr1',
|
||||
cert_no='Y-TR-1',
|
||||
customer_id='cust1',
|
||||
status='active',
|
||||
verified_by='u',
|
||||
verified_time=datetime.now(),
|
||||
valid_from=None,
|
||||
valid_to=date(2027, 1, 1),
|
||||
total_qty=Decimal('100'),
|
||||
used_qty=Decimal('0'),
|
||||
reserved_qty=Decimal('0'),
|
||||
quantity_unit='吨',
|
||||
opening_single_use_consumed=0,
|
||||
vehicle_numbers_json=['皖A11111'],
|
||||
)
|
||||
data.update(kw)
|
||||
return SimpleNamespace(**data)
|
||||
|
||||
|
||||
def _mock_select_db(purchases, transports, refs):
|
||||
from app.models.pl_quota_models import (
|
||||
PlPurchaseCert,
|
||||
PlTransportCert,
|
||||
PlTransportCertPurchaseRef,
|
||||
PlCertQtyLog,
|
||||
)
|
||||
|
||||
class PurchaseQuery:
|
||||
def filter(self, *a, **k):
|
||||
return self
|
||||
|
||||
def order_by(self, *a, **k):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return list(purchases)
|
||||
|
||||
class TransportQuery:
|
||||
def filter(self, *a, **k):
|
||||
return self
|
||||
|
||||
def order_by(self, *a, **k):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return list(transports)
|
||||
|
||||
class RefQuery:
|
||||
def filter_by(self, **k):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return list(refs)
|
||||
|
||||
class LogQuery:
|
||||
def filter_by(self, **k):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return SimpleNamespace(id='opening')
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
def q_side(model):
|
||||
if model is PlPurchaseCert:
|
||||
return PurchaseQuery()
|
||||
if model is PlTransportCert:
|
||||
return TransportQuery()
|
||||
if model is PlTransportCertPurchaseRef:
|
||||
return RefQuery()
|
||||
if model is PlCertQtyLog:
|
||||
return LogQuery()
|
||||
return MagicMock()
|
||||
|
||||
db.query.side_effect = q_side
|
||||
return db
|
||||
|
||||
|
||||
def test_whole_cover_vehicle_mismatch_message_is_precise(monkeypatch):
|
||||
"""异地段购买证够用但运输证未含该车牌时,应点名车牌与证号。"""
|
||||
from app.services import pl_quota_customer_select as sel
|
||||
|
||||
purchase = _ok_purchase()
|
||||
transport = _ok_transport(vehicle_numbers_json=['皖A00000'])
|
||||
ref = SimpleNamespace(transport_cert_id='tr1', purchase_cert_id='buy1')
|
||||
db = _mock_select_db([purchase], [transport], [ref])
|
||||
monkeypatch.setattr(sel, 'category_ids_for_product', lambda *a, **k: {'cat1'})
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
sel.select_whole_cover_certs(
|
||||
db,
|
||||
SimpleNamespace(tenant_no='t'),
|
||||
[_remote_source()],
|
||||
vehicle_no='皖A9BE91',
|
||||
on_date=date(2026, 11, 14),
|
||||
)
|
||||
detail = str(exc.value.detail)
|
||||
assert exc.value.status_code == 409
|
||||
assert '皖A9BE91' in detail
|
||||
assert 'Y-TR-1' in detail
|
||||
assert '车号' in detail or '车牌' in detail
|
||||
assert '余量足够、有效期内且匹配品类/车号' not in detail
|
||||
|
||||
|
||||
def test_whole_cover_empty_vehicle_message_is_precise(monkeypatch):
|
||||
from app.services import pl_quota_customer_select as sel
|
||||
|
||||
purchase = _ok_purchase()
|
||||
transport = _ok_transport()
|
||||
ref = SimpleNamespace(transport_cert_id='tr1', purchase_cert_id='buy1')
|
||||
db = _mock_select_db([purchase], [transport], [ref])
|
||||
monkeypatch.setattr(sel, 'category_ids_for_product', lambda *a, **k: {'cat1'})
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
sel.select_whole_cover_certs(
|
||||
db,
|
||||
SimpleNamespace(tenant_no='t'),
|
||||
[_remote_source()],
|
||||
vehicle_no=' ',
|
||||
on_date=date(2026, 11, 14),
|
||||
)
|
||||
detail = str(exc.value.detail)
|
||||
assert '车牌' in detail
|
||||
assert '余量足够、有效期内且匹配品类/车号' not in detail
|
||||
|
||||
|
||||
def test_whole_cover_purchase_insufficient_message_is_precise(monkeypatch):
|
||||
from app.services import pl_quota_customer_select as sel
|
||||
|
||||
purchase = _ok_purchase(total_qty=Decimal('10'), cert_no='G-SMALL')
|
||||
db = _mock_select_db([purchase], [], [])
|
||||
monkeypatch.setattr(sel, 'category_ids_for_product', lambda *a, **k: {'cat1'})
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
sel.select_whole_cover_certs(
|
||||
db,
|
||||
SimpleNamespace(tenant_no='t'),
|
||||
[_remote_source(requires_transport=False, quantity=Decimal('30'))],
|
||||
vehicle_no='皖A1',
|
||||
on_date=date(2026, 11, 14),
|
||||
)
|
||||
detail = str(exc.value.detail)
|
||||
assert 'G-SMALL' in detail
|
||||
assert '余量' in detail or '不够' in detail
|
||||
assert '余量足够、有效期内且匹配品类/车号' not in detail
|
||||
|
||||
|
||||
def test_whole_cover_no_purchase_message_includes_customer_and_product(monkeypatch):
|
||||
"""无匹配购买证时,提示须点名异地客户与产品,便于对照建档。"""
|
||||
from app.services import pl_quota_customer_select as sel
|
||||
|
||||
db = _mock_select_db([], [], [])
|
||||
monkeypatch.setattr(sel, 'category_ids_for_product', lambda *a, **k: {'cat1'})
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
sel.select_whole_cover_certs(
|
||||
db,
|
||||
SimpleNamespace(tenant_no='t'),
|
||||
[
|
||||
_remote_source(
|
||||
snapshot={
|
||||
'customerId': 'cust1',
|
||||
'customerName': '滁州康华电子材料有限公司',
|
||||
'productName': '98% 工业硫酸',
|
||||
}
|
||||
)
|
||||
],
|
||||
vehicle_no='皖A9BE91',
|
||||
on_date=date(2026, 11, 14),
|
||||
)
|
||||
detail = str(exc.value.detail)
|
||||
assert exc.value.status_code == 409
|
||||
assert '异地段客户' in detail
|
||||
assert '滁州康华电子材料有限公司' in detail
|
||||
assert '98% 工业硫酸' in detail
|
||||
assert '匹配品类的有效购买证' in detail
|
||||
|
||||
|
||||
def test_resolve_customer_category_cover_certs_lists_pool(monkeypatch):
|
||||
from app.services import pl_quota_customer_select as sel
|
||||
|
||||
purchase = _ok_purchase()
|
||||
transport = _ok_transport()
|
||||
ref = SimpleNamespace(transport_cert_id='tr1', purchase_cert_id='buy1')
|
||||
db = _mock_select_db([purchase], [transport], [ref])
|
||||
monkeypatch.setattr(sel, 'category_ids_for_product', lambda *a, **k: {'cat1'})
|
||||
|
||||
purchase_ids, transport_ids = sel.resolve_customer_category_cover_certs(
|
||||
db,
|
||||
SimpleNamespace(tenant_no='t'),
|
||||
[_remote_source(snapshot={'customerId': 'cust1', 'customerName': '昆腾'})],
|
||||
)
|
||||
assert purchase_ids == ['buy1']
|
||||
assert transport_ids == ['tr1']
|
||||
|
||||
|
||||
def test_resolve_customer_category_cover_certs_missing_uses_customer_wording(monkeypatch):
|
||||
from app.services import pl_quota_customer_select as sel
|
||||
|
||||
db = _mock_select_db([], [], [])
|
||||
monkeypatch.setattr(sel, 'category_ids_for_product', lambda *a, **k: {'cat1'})
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
sel.resolve_customer_category_cover_certs(
|
||||
db,
|
||||
SimpleNamespace(tenant_no='t'),
|
||||
[_remote_source(snapshot={'customerId': 'cust1', 'customerName': '昆腾'})],
|
||||
)
|
||||
detail = str(exc.value.detail)
|
||||
assert '客户昆腾' in detail
|
||||
assert '合同' not in detail
|
||||
|
||||
|
||||
def test_settle_cover_cert_ids_customer_rule_skips_contract_bindings(monkeypatch):
|
||||
from app.services import pl_quota_usage_service as usage
|
||||
|
||||
plan = SimpleNamespace(id='plan', cert_quota_rule_version='customer_category')
|
||||
called = {'contract': 0, 'customer': 0}
|
||||
|
||||
def fake_customer(db, user, sources):
|
||||
called['customer'] += 1
|
||||
return ['buy1'], ['tr1']
|
||||
|
||||
def fake_contract(db, user, sources):
|
||||
called['contract'] += 1
|
||||
raise AssertionError('customer_category 归档超额不应查合同绑证')
|
||||
|
||||
monkeypatch.setattr(
|
||||
'app.services.pl_quota_customer_select.resolve_customer_category_cover_certs',
|
||||
fake_customer,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.services.pl_quota_planning.resolve_contract_bound_certs',
|
||||
fake_contract,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.services.pl_cert_quota_rule_config_service.plan_uses_customer_category',
|
||||
lambda p: True,
|
||||
)
|
||||
|
||||
purchase_ids, transport_ids = usage._settle_cover_cert_ids(
|
||||
MagicMock(), SimpleNamespace(tenant_no='t'), plan, [_remote_source()],
|
||||
)
|
||||
assert purchase_ids == ['buy1']
|
||||
assert transport_ids == ['tr1']
|
||||
assert called == {'contract': 0, 'customer': 1}
|
||||
|
||||
|
||||
def test_archive_cover_block_message_customer_rule_wording(monkeypatch):
|
||||
from app.services import pl_quota_usage_service as svc
|
||||
|
||||
plan = SimpleNamespace(id='plan', cert_quota_rule_version='customer_category')
|
||||
r = SimpleNamespace(vehicle_no='皖A1', on_date=date(2026, 11, 15))
|
||||
monkeypatch.setattr(
|
||||
'app.services.pl_cert_quota_rule_config_service.plan_uses_customer_category',
|
||||
lambda p: True,
|
||||
)
|
||||
message = svc._archive_cover_block_message(
|
||||
MagicMock(), SimpleNamespace(tenant_no='t'), r, plan, {}, [],
|
||||
)
|
||||
assert '客户名下' in message
|
||||
assert '合同' not in message
|
||||
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
@@ -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'
|
||||
|
||||
@@ -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):
|
||||
@@ -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': '重新启用'}),
|
||||
|
||||
@@ -64,6 +64,44 @@ def test_create_with_bindings_and_bad_create_has_no_pending_row(case):
|
||||
assert result['id'] not in ('g1', 'g2')
|
||||
|
||||
|
||||
def test_create_with_empty_contract_bindings_auto_enables(case):
|
||||
"""前端保存常带 contractBindings:[];必须与无合同集合路径一样自动启用并落库。"""
|
||||
c = case
|
||||
body = dict(
|
||||
certNo='G-AUTO',
|
||||
productNameOnCert='硫酸',
|
||||
totalQty='300',
|
||||
quantityUnit='吨',
|
||||
validUseText='多次有效',
|
||||
buyerName='买方',
|
||||
sellerName='卖方',
|
||||
contractBindings=[],
|
||||
)
|
||||
result = save(c, body, None)
|
||||
assert result['status'] == 'active'
|
||||
assert result['openingConfirmed'] is True
|
||||
assert result['verifiedBy'] == 'u'
|
||||
row = c.db.query(c.m.PlPurchaseCert).filter_by(id=result['id']).one()
|
||||
assert row.status == 'active'
|
||||
assert row.verified_by == 'u'
|
||||
assert c.db.query(c.m.PlCertQtyLog).filter_by(
|
||||
cert_id=result['id'], event_type='opening', del_flag=0,
|
||||
).count() == 1
|
||||
|
||||
|
||||
def test_get_cert_read_does_not_fake_enable_draft(case):
|
||||
"""只读详情/列表不得在未提交事务里把 draft 伪装成 active。"""
|
||||
from app.services import pl_quota_cert_service as certs
|
||||
|
||||
c = case
|
||||
row = c.db.query(c.m.PlPurchaseCert).filter_by(id='g1').one()
|
||||
assert row.status == 'draft'
|
||||
detail = certs.get_cert(c.db, c.user, 'purchase', 'g1')
|
||||
assert detail['status'] == 'draft'
|
||||
assert c.db.query(c.m.PlPurchaseCert).filter_by(id='g1').one().status == 'draft'
|
||||
assert c.db.query(c.m.PlCertQtyLog).filter_by(cert_id='g1', del_flag=0).count() == 0
|
||||
|
||||
|
||||
def test_hidden_contract_cannot_be_cleared_but_face_can_change(case, monkeypatch):
|
||||
from app.services import pl_quota_cert_bindings as svc
|
||||
from app.models.crm_models import CrmContract
|
||||
|
||||
@@ -317,3 +317,39 @@ def test_missing_contract_is_empty_binding_not_unreadable(case, contract_id):
|
||||
assert rows[0]['bindingsReadable'] is True
|
||||
assert rows[0]['certificates'] == []
|
||||
assert rows[1]['bindingsReadable'] is False
|
||||
|
||||
|
||||
def test_plan_quota_usages_include_cert_attachment_urls(case):
|
||||
from app.models.crm_models import CrmContract
|
||||
from app.models.pl_quota_models import (
|
||||
PlPlanCertUsage, PlPlanCertUsageItem, PlPurchaseCert, PlTransportCert,
|
||||
)
|
||||
from app.services.pl_quota_planning import get_plan_quota
|
||||
c = case
|
||||
for model in (CrmContract, PlPlanCertUsage, PlPlanCertUsageItem, PlPurchaseCert, PlTransportCert):
|
||||
model.__table__.create(c.db.bind)
|
||||
c.db.add(PlPurchaseCert(
|
||||
id='p1', cert_no='G-BUY', product_name_on_cert='硫酸', valid_use_text='一次有效',
|
||||
buyer_name='买方', seller_name='卖方', total_qty=33, quantity_unit='吨',
|
||||
attachment_urls='https://example.com/buy.jpg', tenant_no='t', status='active',
|
||||
))
|
||||
c.db.add(PlTransportCert(
|
||||
id='t1', cert_no='Y-TR', product_name_on_cert='硫酸', valid_use_text='多次有效',
|
||||
shipper_name='货主', total_qty=70, quantity_unit='吨', vehicle_numbers_json=['皖A1'],
|
||||
attachment_urls='', tenant_no='t', status='active',
|
||||
))
|
||||
c.db.add(PlPlanCertUsage(
|
||||
id='u1', tenant_no='t', plan_id='plan', plan_no_snapshot='PLAN',
|
||||
cert_type='purchase', cert_id='p1', cert_no_snapshot='G-BUY', cert_revision=1,
|
||||
quantity_unit='吨', reserved_qty=10, used_qty=0, usage_status='reserved',
|
||||
))
|
||||
c.db.add(PlPlanCertUsage(
|
||||
id='u2', tenant_no='t', plan_id='plan', plan_no_snapshot='PLAN',
|
||||
cert_type='transport', cert_id='t1', cert_no_snapshot='Y-TR', cert_revision=1,
|
||||
quantity_unit='吨', reserved_qty=10, used_qty=0, usage_status='reserved',
|
||||
))
|
||||
c.db.flush()
|
||||
usages = {u['certId']: u for u in get_plan_quota(c.db, c.user, 'plan')['usages']}
|
||||
assert usages['p1']['attachmentUrls'] == 'https://example.com/buy.jpg'
|
||||
assert usages['p1']['certNo'] == 'G-BUY'
|
||||
assert usages['t1']['attachmentUrls'] in ('', None)
|
||||
@@ -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
|
||||
@@ -245,7 +291,7 @@ def test_opening_consumed_and_expiry_vehicle_block(case):
|
||||
p.opening_single_use_consumed=0; p.valid_to=date(2000,1,1); c.db.flush()
|
||||
with pytest.raises(ValueError): run(c)
|
||||
p.valid_to=date(2099,12,31); c.db.flush()
|
||||
with pytest.raises(ValueError, match='车号不在运输证范围'): run(c,vehicle_no='unknown')
|
||||
with pytest.raises(ValueError, match='车牌号「unknown」不在运输证车号范围内'): run(c,vehicle_no='unknown')
|
||||
|
||||
|
||||
def test_reserve_uses_transport_cert_that_lists_the_vehicle(case):
|
||||
@@ -262,9 +308,19 @@ def test_reserve_uses_transport_cert_that_lists_the_vehicle(case):
|
||||
assert c.db.get(c.m.PlPurchaseCert, 'p2').reserved_qty == 0
|
||||
transport_usages = c.db.query(c.m.PlPlanCertUsage).filter_by(cert_type='transport', del_flag=0).all()
|
||||
assert [u.cert_id for u in transport_usages if u.usage_status == 'reserved'] == ['t']
|
||||
with pytest.raises(ValueError, match='车号不在运输证范围'):
|
||||
with pytest.raises(ValueError, match='车牌号「NONE」不在运输证车号范围内'):
|
||||
run(c, operation_id='none', vehicle_no='NONE', purchase_cert_ids=['p', 'p2'], transport_cert_ids=['t', 't2'])
|
||||
|
||||
|
||||
def test_reserve_accepts_vehicle_no_with_trailing_spaces(case):
|
||||
"""计划车牌尾部空格不得误判为不在运输证车号范围。"""
|
||||
c = case
|
||||
# 证上车号无空格;计划侧常带尾部空格(与 PO20261115096 现场一致)
|
||||
assert c.db.get(c.m.PlTransportCert, 't').vehicle_numbers_json == ['CAR']
|
||||
run(c, vehicle_no='CAR ')
|
||||
assert c.db.get(c.m.PlTransportCert, 't').reserved_qty == 29
|
||||
|
||||
|
||||
def test_no_cert_operation_is_still_idempotent(case):
|
||||
c=case
|
||||
first=run(c,sources=[],purchase_cert_ids=[],transport_cert_ids=[])
|
||||
@@ -498,3 +554,50 @@ def test_new_current_usage_rejects_without_out_of_order_cert_lock(case,monkeypat
|
||||
assert c.db.get(c.m.PlPurchaseCert,'p').reserved_qty==29
|
||||
assert all(u.reserved_qty==29 for u in c.db.query(c.m.PlPlanCertUsage).all())
|
||||
assert c.db.query(c.m.PlCertOperation).count()==1
|
||||
|
||||
|
||||
def test_archive_cover_block_message_names_vehicle(monkeypatch):
|
||||
"""归档补证被车号挡住时,应点名车牌与运输证,而不是笼统额度不足。"""
|
||||
from types import SimpleNamespace
|
||||
from datetime import date
|
||||
from app.services import pl_quota_usage_service as svc
|
||||
|
||||
transport = SimpleNamespace(
|
||||
id='t1',
|
||||
cert_no='Y-ARCH-1',
|
||||
status='active',
|
||||
verified_by='u',
|
||||
verified_time=date(2026, 1, 1),
|
||||
valid_from=date(2026, 1, 1),
|
||||
valid_to=date(2027, 12, 31),
|
||||
valid_use_text='多次有效',
|
||||
vehicle_numbers_json=['皖A00000'],
|
||||
)
|
||||
r = SimpleNamespace(vehicle_no='皖A9BE91', on_date=date(2026, 11, 15))
|
||||
plan = SimpleNamespace(id='plan1')
|
||||
user = SimpleNamespace(tenant_no='t')
|
||||
|
||||
class OpeningQuery:
|
||||
def first(self):
|
||||
return SimpleNamespace(id='opening')
|
||||
|
||||
db = SimpleNamespace()
|
||||
monkeypatch.setattr(
|
||||
svc,
|
||||
'_query',
|
||||
lambda db, user, model, lock=True, **filters: OpeningQuery(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.services.pl_quota_cert_service.ensure_zero_opening',
|
||||
lambda db, user, kind, cert_id, row=None: row or transport,
|
||||
)
|
||||
monkeypatch.setattr(svc, 'usage_policy', lambda text: 'multi')
|
||||
monkeypatch.setattr(svc, 'is_single_use_consumed', lambda *a, **k: False)
|
||||
|
||||
message = svc._archive_cover_block_message(
|
||||
db, user, r, plan, {('transport', 't1'): transport}, [('transport', 't1')],
|
||||
)
|
||||
assert '皖A9BE91' in message
|
||||
assert 'Y-ARCH-1' in message
|
||||
assert '未包含车牌号' in message
|
||||
assert '剩余额度不够覆盖本次实际数量' not in message
|
||||
@@ -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,16 @@
|
||||
-- 合同用证 → 客户用证(菜单展示名);保留菜单 ID 与权限码。
|
||||
SET NAMES utf8mb4;
|
||||
START TRANSACTION;
|
||||
|
||||
UPDATE sys_menu
|
||||
SET menu_name = '客户用证', update_time = NOW()
|
||||
WHERE del_flag = 0
|
||||
AND (
|
||||
id = 'PL_CONTRACT_QUOTA'
|
||||
OR (path = 'contract-quota' AND perms = 'pl:quota-cert:list')
|
||||
OR id = 'MINI_PL_CERT_CONTRACT'
|
||||
OR perms = 'mini:pl:contract-quota:list'
|
||||
)
|
||||
AND menu_name = '合同用证';
|
||||
|
||||
COMMIT;
|
||||
@@ -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`
|
||||
- 不改选证算法本身
|
||||
@@ -9,5 +9,5 @@ allowBuilds:
|
||||
core-js: false
|
||||
es5-ext: false
|
||||
esbuild: true
|
||||
protobufjs: false
|
||||
protobufjs: true
|
||||
vue-demi: true
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { resolveArchiveQuotaBlockMode } from '../src/utils/pl-archive-quota-block'
|
||||
|
||||
test('pending fulfill plan only alerts, no certificate-ready confirm', () => {
|
||||
assert.equal(
|
||||
resolveArchiveQuotaBlockMode(
|
||||
'实际数量已保存,证件没有扣减。请把实际数量改小,或先补充购买证和运输证,再重新归档。',
|
||||
'用证没有完成',
|
||||
{ quotaCertPendingFulfill: 1, quotaCertWaived: 1 }
|
||||
),
|
||||
'alert-pending'
|
||||
)
|
||||
})
|
||||
|
||||
test('already waived plan does not offer archive-time certificate ready', () => {
|
||||
assert.equal(
|
||||
resolveArchiveQuotaBlockMode('余额不足', '用证没有完成', { quotaCertWaived: 1 }),
|
||||
'alert-pending'
|
||||
)
|
||||
})
|
||||
|
||||
test('pending keywords still force alert mode without plan flags', () => {
|
||||
assert.equal(
|
||||
resolveArchiveQuotaBlockMode(
|
||||
'该计划仍处于证书齐备待完善,请先在证书齐备中完善并关闭后再归档',
|
||||
'用证没有完成',
|
||||
{}
|
||||
),
|
||||
'alert-pending'
|
||||
)
|
||||
})
|
||||
|
||||
test('unmarked quota block only alerts, never offers certificate ready', () => {
|
||||
assert.equal(
|
||||
resolveArchiveQuotaBlockMode(
|
||||
'实际数量已保存,证件没有扣减。请把实际数量改小,或先补充购买证和运输证,再重新归档。',
|
||||
'用证没有完成',
|
||||
{ quotaCertPendingFulfill: 0, quotaCertWaived: 0 }
|
||||
),
|
||||
'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'
|
||||
)
|
||||
})
|
||||
@@ -25,6 +25,20 @@ test('incomplete certificate filter is sent as quotaCertWaivedIn=0', () => {
|
||||
assert.deepEqual(params, { quotaCertWaivedIn: '0' })
|
||||
})
|
||||
|
||||
test('fulfill tab selection column is first among table columns', () => {
|
||||
const web = readFileSync(new URL('../src/views/pl/cert-ready/index.vue', import.meta.url), 'utf8')
|
||||
const selectionIdx = web.indexOf('type="selection"')
|
||||
const columnsIdx = web.indexOf('v-for="column in columns"')
|
||||
const actionsIdx = web.indexOf('label="操作"')
|
||||
assert.ok(selectionIdx > 0, 'selection column missing')
|
||||
assert.ok(columnsIdx > selectionIdx, 'selection must precede business columns')
|
||||
assert.ok(actionsIdx > columnsIdx, 'actions must follow business columns')
|
||||
assert.match(
|
||||
web,
|
||||
/v-if="listMode === 'fulfill'"[\s\S]*?type="selection"[\s\S]*?v-for="column in columns"/
|
||||
)
|
||||
})
|
||||
|
||||
test('web funnel and miniprogram status dropdown default to incomplete', () => {
|
||||
const web = readFileSync(new URL('../src/views/pl/cert-ready/index.vue', import.meta.url), 'utf8')
|
||||
const mobile = readFileSync(
|
||||
|
||||
@@ -203,13 +203,14 @@ test('real template keeps plans quota proof order and separate lazy contract sco
|
||||
canViewQuotaCert: true,
|
||||
canViewProof: true,
|
||||
detailActiveTab: 'proof',
|
||||
detailContract: { id: 42 }
|
||||
detailContract: { id: 42, customerId: 'cust-42' }
|
||||
}
|
||||
const rows = panes(ctx)
|
||||
assert.deepEqual(
|
||||
rows.map((v: any) => v.props.name),
|
||||
['plans', 'quota', 'proof']
|
||||
)
|
||||
assert.equal(rows[1].props.label, '客户证件与额度')
|
||||
assert.equal(rows[2].props.label, '运输计划合法使用证明')
|
||||
assert.equal(rows[2].props.lazy, '')
|
||||
assert.equal(panel(rows[1]), undefined)
|
||||
@@ -219,14 +220,19 @@ test('real template keeps plans quota proof order and separate lazy contract sco
|
||||
assert.equal(proof.props.active, true)
|
||||
assert.equal(proof.props.embedded, '')
|
||||
ctx.detailContract.id = 43
|
||||
ctx.detailContract.customerId = 'cust-43'
|
||||
ctx.currentDetailId = 43
|
||||
assert.equal(panel(panes(ctx)[2]).key, 43)
|
||||
ctx.detailActiveTab = 'quota'
|
||||
const quota = panel(panes(ctx)[1])
|
||||
assert.equal(quota.type?.name || quota.type, 'CustomerCertQuotaPanel')
|
||||
assert.equal(quota.props['customer-id'] ?? quota.props.customerId, 'cust-43')
|
||||
assert.equal(quota.props.active, true)
|
||||
ctx.canViewQuotaCert = false
|
||||
assert.deepEqual(
|
||||
panes(ctx).map((v: any) => v.props.name),
|
||||
['plans', 'proof']
|
||||
)
|
||||
ctx.detailActiveTab = 'quota'
|
||||
assert.equal(panel(panes(ctx)[1]), undefined)
|
||||
})
|
||||
|
||||
@@ -265,7 +271,14 @@ test('mounted template unmounts old contract and never mounts quota for proof-on
|
||||
}
|
||||
}),
|
||||
StopDocuments: Child('proof'),
|
||||
ContractQuotaPanel: Child('quota'),
|
||||
CustomerCertQuotaPanel: vue.defineComponent({
|
||||
props: ['customerId', 'active'],
|
||||
setup(props) {
|
||||
lifecycle.push(`mount:quota:${props.customerId}`)
|
||||
vue.onBeforeUnmount(() => lifecycle.push(`unmount:quota:${props.customerId}`))
|
||||
return () => vue.h('span')
|
||||
}
|
||||
}),
|
||||
PlContractLinkedPlansPanel: vue.defineComponent({
|
||||
setup() {
|
||||
return () => vue.h('span')
|
||||
@@ -282,7 +295,7 @@ test('mounted template unmounts old contract and never mounts quota for proof-on
|
||||
canViewQuotaCert: false,
|
||||
canViewProof: true,
|
||||
detailActiveTab: 'proof',
|
||||
detailContract: { id: 'A' }
|
||||
detailContract: { id: 'A', customerId: 'cust-A' }
|
||||
})
|
||||
const renderer = vue.createRenderer<any, any>({
|
||||
createElement: () => ({ children: [] }),
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
test('web contract detail embeds customer cert panel and renamed tab', () => {
|
||||
const source = readFileSync(
|
||||
new URL('../src/views/crm/contract/components/ContractDetailDialog.vue', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
assert.match(source, /label="客户证件与额度"/)
|
||||
assert.match(source, /CustomerCertQuotaPanel/)
|
||||
assert.doesNotMatch(source, /ContractQuotaPanel/)
|
||||
assert.match(source, /:customer-id="detailContract\.customerId"/)
|
||||
})
|
||||
|
||||
test('h5 contract detail includes customer cert tab', () => {
|
||||
const source = readFileSync(
|
||||
new URL('../src/views/m/crm/contract/detail.vue', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
assert.match(source, /CustomerCertQuotaTab/)
|
||||
assert.match(source, /客户证件与额度/)
|
||||
assert.match(source, /canViewQuotaCert/)
|
||||
assert.match(source, /:customer-id="contract\.customerId"/)
|
||||
})
|
||||
|
||||
test('customer cert panel lists by customerId without contract bindings', () => {
|
||||
const source = readFileSync(
|
||||
new URL('../src/views/pl/contract-quota/CustomerCertQuotaPanel.vue', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
assert.match(source, /quotaCertApi\.list\('purchase', \{ customerId \}\)/)
|
||||
assert.match(source, /quotaCertApi\.list\('transport', \{ customerId \}\)/)
|
||||
assert.match(source, /有效次数/)
|
||||
assert.match(source, /singleUseConsumed/)
|
||||
assert.doesNotMatch(source, /saveBindings|openCreate|bindings\(/)
|
||||
})
|
||||
|
||||
test('standalone contract-quota page is customer quota list', () => {
|
||||
const panel = readFileSync(
|
||||
new URL('../src/views/pl/contract-quota/CustomerQuotaPanel.vue', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const wrapper = readFileSync(
|
||||
new URL('../src/views/pl/contract-quota/ContractQuotaPanel.vue', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const router = readFileSync(new URL('../src/router/modules/pl.ts', import.meta.url), 'utf8')
|
||||
assert.match(panel, /客户用证/)
|
||||
assert.match(panel, /quotaStatisticsApi\.customers/)
|
||||
assert.match(wrapper, /CustomerQuotaPanel/)
|
||||
assert.match(router, /title: '客户用证'/)
|
||||
})
|
||||
|
||||
test('web customer quota panel uses toQueryParams from useServerColumnFilters', () => {
|
||||
const panel = readFileSync(
|
||||
new URL('../src/views/pl/contract-quota/CustomerQuotaPanel.vue', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
assert.match(panel, /toQueryParams/)
|
||||
assert.match(panel, /\.\.\.toQueryParams\(\)/)
|
||||
assert.doesNotMatch(panel, /\bbuildQuery\b/)
|
||||
assert.doesNotMatch(panel, /\bresetFilters\b/)
|
||||
assert.match(panel, /clearAllFilters/)
|
||||
assert.match(panel, /watchVisibleColumns/)
|
||||
})
|
||||
|
||||
test('web customer quota panel computes visibleDefs locally (not from composable)', () => {
|
||||
const panel = readFileSync(
|
||||
new URL('../src/views/pl/contract-quota/CustomerQuotaPanel.vue', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
panel,
|
||||
/const \{[^}]*\bvisibleDefs\b[^}]*\} = usePersistedListColumns/
|
||||
)
|
||||
assert.match(
|
||||
panel,
|
||||
/const \{ visibleColumns, checkAll, isIndeterminate, resetToDefault \} = usePersistedListColumns/
|
||||
)
|
||||
assert.match(
|
||||
panel,
|
||||
/const visibleDefs = computed\(\(\) =>\s*columns\.filter\(\(column\) => visibleColumns\.value\.includes\(column\.prop\)\)/
|
||||
)
|
||||
assert.match(panel, /v-for="column in visibleDefs"/)
|
||||
})
|
||||
|
||||
test('h5 customer tab renders customer fields without persisted column defs', () => {
|
||||
const tab = readFileSync(
|
||||
new URL('../src/views/m/pl/plan-cert/CustomerTab.vue', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
assert.doesNotMatch(tab, /usePersistedListColumns/)
|
||||
assert.doesNotMatch(tab, /visibleDefs/)
|
||||
assert.match(tab, /row\.customerName/)
|
||||
assert.match(tab, /row\.customerNo/)
|
||||
assert.match(tab, /row\.certCount/)
|
||||
})
|
||||
|
||||
test('h5 customer quota tab does not call buildQuery', () => {
|
||||
const tab = readFileSync(
|
||||
new URL('../src/views/m/pl/plan-cert/CustomerTab.vue', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
assert.doesNotMatch(tab, /\bbuildQuery\b/)
|
||||
assert.match(tab, /quotaStatisticsApi\.customers/)
|
||||
})
|
||||
|
||||
test('web customer quota panel wires viewport height like StatisticsPanel', () => {
|
||||
const panel = readFileSync(
|
||||
new URL('../src/views/pl/contract-quota/CustomerQuotaPanel.vue', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
assert.match(panel, /useElementSize\(tableWrapRef\)/)
|
||||
assert.match(
|
||||
panel,
|
||||
/const expansionStyle = computed\(\(\) => \(\{[\s\S]*width: tableViewportWidth/
|
||||
)
|
||||
assert.match(panel, /tableEl: tableWrapRef/)
|
||||
assert.match(panel, /pagerEl: pagerRef/)
|
||||
assert.doesNotMatch(panel, /\bwrapRef\s*:/)
|
||||
assert.doesNotMatch(
|
||||
panel,
|
||||
/const \{[^}]*\bexpansionStyle\b[^}]*\} = useListTableViewportHeight/
|
||||
)
|
||||
})
|
||||
@@ -7,7 +7,11 @@ const readyTab = readFileSync(
|
||||
'utf8'
|
||||
)
|
||||
const contractTab = readFileSync(
|
||||
new URL('../src/views/m/pl/plan-cert/ContractTab.vue', import.meta.url),
|
||||
new URL('../src/views/m/pl/plan-cert/CustomerTab.vue', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const planCertIndex = readFileSync(
|
||||
new URL('../src/views/m/pl/plan-cert/index.vue', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const archiveForm = readFileSync(
|
||||
@@ -30,9 +34,39 @@ test('H5 证书齐备对齐 2.0:待完善列表与完善并关闭,且文案
|
||||
assert.doesNotMatch(readyTab, /也不扣减额度/)
|
||||
})
|
||||
|
||||
test('H5 合同用证页对新规则给出只读提示', () => {
|
||||
assert.match(contractTab, /客户\+证用品类/)
|
||||
assert.match(contractTab, /仅供历史查看/)
|
||||
test('H5 客户用证按客户列表展示证件', () => {
|
||||
assert.match(contractTab, /quotaStatisticsApi\.customers/)
|
||||
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 建档页使用关联购买证且无证面引用残留', () => {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import ts from 'typescript'
|
||||
import * as vue from 'vue'
|
||||
|
||||
function loadFreightCalc() {
|
||||
const path = new URL('../src/composables/usePlFreightCalc.ts', import.meta.url)
|
||||
assert.ok(existsSync(path), 'usePlFreightCalc must exist')
|
||||
const source = readFileSync(path, 'utf8')
|
||||
const ast = ts.createSourceFile('usePlFreightCalc.ts', source, ts.ScriptTarget.Latest, true)
|
||||
const code = ast.statements
|
||||
.filter((node) => !ts.isImportDeclaration(node))
|
||||
.map((node) => node.getText(ast))
|
||||
.join('\n')
|
||||
const compiled = ts.transpileModule(code, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
||||
}).outputText
|
||||
const module = { exports: {} as Record<string, unknown> }
|
||||
const requireStub = () => {
|
||||
throw new Error('unexpected require in freight calc test harness')
|
||||
}
|
||||
// eslint-disable-next-line no-new-func
|
||||
const factory = new Function(
|
||||
'exports',
|
||||
'require',
|
||||
'module',
|
||||
'__filename',
|
||||
'__dirname',
|
||||
'computed',
|
||||
'ref',
|
||||
'watch',
|
||||
compiled + '\nmodule.exports = { usePlFreightCalc, sumProductActualQuantities };'
|
||||
)
|
||||
factory(
|
||||
module.exports,
|
||||
requireStub,
|
||||
module,
|
||||
'usePlFreightCalc.ts',
|
||||
'.',
|
||||
vue.computed,
|
||||
vue.ref,
|
||||
vue.watch
|
||||
)
|
||||
return module.exports as {
|
||||
usePlFreightCalc: typeof import('../src/composables/usePlFreightCalc').usePlFreightCalc
|
||||
}
|
||||
}
|
||||
|
||||
test('resetFreightCalc re-sums transport qty from existing product actuals', async () => {
|
||||
const { usePlFreightCalc } = loadFreightCalc()
|
||||
const products = vue.ref([{ actualQty: 0.04, unit: '吨' }, { actualQty: 0.04, unit: '吨' }])
|
||||
const hasFreight = vue.ref(1)
|
||||
const freightType = vue.ref('by_weight')
|
||||
|
||||
const scope = vue.effectScope()
|
||||
const calc = scope.run(() => usePlFreightCalc({ products, hasFreight, freightType }))
|
||||
assert.ok(calc)
|
||||
await vue.nextTick()
|
||||
assert.equal(calc!.transportQty.value, 0.08)
|
||||
|
||||
calc!.resetFreightCalc()
|
||||
await vue.nextTick()
|
||||
assert.equal(calc!.transportQtyTouched.value, false)
|
||||
assert.equal(
|
||||
calc!.transportQty.value,
|
||||
0.08,
|
||||
'reset after products already loaded must not leave transport qty empty'
|
||||
)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
test('resetFreightCalc restores product sum after manual transport qty edit', async () => {
|
||||
const { usePlFreightCalc } = loadFreightCalc()
|
||||
const products = vue.ref([{ actualQty: 0.04, unit: '吨' }])
|
||||
const hasFreight = vue.ref(1)
|
||||
const freightType = vue.ref('by_weight')
|
||||
|
||||
const scope = vue.effectScope()
|
||||
const calc = scope.run(() => usePlFreightCalc({ products, hasFreight, freightType }))
|
||||
assert.ok(calc)
|
||||
await vue.nextTick()
|
||||
|
||||
calc!.transportQty.value = 9.9
|
||||
calc!.markTransportQtyTouched()
|
||||
calc!.resetFreightCalc()
|
||||
await vue.nextTick()
|
||||
assert.equal(calc!.transportQtyTouched.value, false)
|
||||
assert.equal(calc!.transportQty.value, 0.04)
|
||||
scope.stop()
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const viewTabs = readFileSync(
|
||||
new URL('../src/components/business/pl-plan-view-tabs/index.vue', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const h5Detail = readFileSync(
|
||||
new URL('../src/views/m/pl/plan/detail.vue', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('Web 计划详情:关联证件在装送货后且无危化品证书信息区', () => {
|
||||
assert.doesNotMatch(viewTabs, /危化品证书信息/)
|
||||
assert.doesNotMatch(viewTabs, /PlPlanHazardCertViewTable/)
|
||||
const loadIdx = viewTabs.indexOf('装送货信息')
|
||||
const certIdx = viewTabs.indexOf('关联购买证 / 运输证')
|
||||
const freightIdx = viewTabs.indexOf('运费信息')
|
||||
assert.ok(loadIdx >= 0 && certIdx > loadIdx && freightIdx > certIdx)
|
||||
assert.match(viewTabs, /AttachmentUploadField/)
|
||||
assert.match(viewTabs, /attachmentUrls/)
|
||||
})
|
||||
|
||||
test('H5 计划详情:关联购买证/运输证替换危化品证书信息并展示附件', () => {
|
||||
assert.doesNotMatch(h5Detail, /危化品证书信息/)
|
||||
assert.match(h5Detail, /关联购买证 \/ 运输证/)
|
||||
assert.match(h5Detail, /关联证件/)
|
||||
const loadIdx = h5Detail.indexOf('plan-sec-load')
|
||||
const certIdx = h5Detail.indexOf('plan-sec-cert')
|
||||
const freightIdx = h5Detail.indexOf('plan-sec-freight')
|
||||
assert.ok(loadIdx >= 0 && certIdx > loadIdx && freightIdx > certIdx)
|
||||
assert.match(h5Detail, /AttachmentUploadField|CertAttachments/)
|
||||
assert.match(h5Detail, /attachmentUrls/)
|
||||
})
|
||||
@@ -105,6 +105,9 @@ function harness(fixed?: string) {
|
||||
useServerColumnFilters,
|
||||
createStatisticsLoader,
|
||||
quotaStatisticsApi: api,
|
||||
certQuotaRuleApi: {
|
||||
get: async () => ({ configured: false })
|
||||
},
|
||||
onBeforeUnmount: (fn: Function) => unmount.push(fn)
|
||||
}
|
||||
const src = ts.createSourceFile(
|
||||
@@ -154,16 +157,21 @@ test('cert tabs keep their identities and legal proof is a sibling menu', () =>
|
||||
assert.equal(legal.meta!.title, '合法使用证明')
|
||||
assert.deepEqual(
|
||||
group.children!.filter((r) => !r.meta?.isHide).map((r) => r.path),
|
||||
['quota-cert', 'contract-quota', 'certificate-usage', 'plan-cert', 'cert-ready']
|
||||
['quota-cert', 'cert-category', 'contract-quota', 'certificate-usage', 'plan-cert', 'cert-ready']
|
||||
)
|
||||
assert.equal(group.children![0].id, 'PL_QUOTA_CERT')
|
||||
assert.equal(group.children![3].id, 'PL00000000000000000000000000015')
|
||||
assert.equal(group.children!.find((r) => r.path === 'contract-quota')?.meta?.title, '客户用证')
|
||||
assert.equal(
|
||||
group.children!.find((r) => r.path === 'plan-cert')?.id,
|
||||
'PL00000000000000000000000000015'
|
||||
)
|
||||
assert.deepEqual(
|
||||
group.children!.filter((r) => !r.meta?.isHide).map((r) => r.meta!.menuCode),
|
||||
[
|
||||
'pl:quota-cert:list',
|
||||
'pl:quota-cert:list',
|
||||
'pl:quota-cert:list',
|
||||
'pl:quota-cert:list',
|
||||
'pl:plan-cert:list',
|
||||
'pl:cert-ready:list'
|
||||
]
|
||||
@@ -648,71 +656,58 @@ function compiledSfc(path: string, dependencies: Record<string, any>, inlineTemp
|
||||
)
|
||||
return new Function(...Object.keys(env), `${code}; return component`)(...Object.values(env))
|
||||
}
|
||||
for (const wrapper of [
|
||||
'contract-quota/ContractQuotaPanel.vue',
|
||||
'certificate-usage/CertificateUsagePanel.vue'
|
||||
]) {
|
||||
for (const active of [undefined, false])
|
||||
test(`compiled ${wrapper} ${active === false ? 'inactive skips requests' : 'defaults to active and loads'}`, async () => {
|
||||
const calls: string[] = []
|
||||
const dependencies = {
|
||||
useUserStore: () => vue.reactive({ accessToken: 'token', getUserInfo: { tenantNo: 'a' } }),
|
||||
useRouter: () => ({ push() {}, replace() {} }),
|
||||
fetchGetMenuCodes: async () => {
|
||||
calls.push('permissions')
|
||||
return { all: true }
|
||||
},
|
||||
quotaStatisticsApi: {
|
||||
contracts: async () => {
|
||||
calls.push('contracts')
|
||||
return { list: [], total: 0 }
|
||||
},
|
||||
certificates: async () => {
|
||||
calls.push('certificates')
|
||||
return { list: [], total: 0 }
|
||||
}
|
||||
},
|
||||
usePersistedListColumns,
|
||||
useServerColumnFilters,
|
||||
createStatisticsLoader,
|
||||
useElementSize: () => ({ width: vue.ref(1120) }),
|
||||
useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }),
|
||||
statusLabel: statisticsLabels.statusLabel,
|
||||
contractTypeLabel: statisticsLabels.contractTypeLabel
|
||||
}
|
||||
// Compile the real inner setup too; only its visual child tree is omitted here.
|
||||
const StatisticsPanel = compiledSfc(
|
||||
'../src/views/pl/quota-statistics/StatisticsPanel.vue',
|
||||
dependencies,
|
||||
false
|
||||
)
|
||||
StatisticsPanel.render = () => null
|
||||
const Wrapper = compiledSfc(`../src/views/pl/${wrapper}`, { StatisticsPanel })
|
||||
const renderer = vue.createRenderer<any, any>({
|
||||
createElement: () => ({}),
|
||||
createText: () => ({}),
|
||||
createComment: () => ({}),
|
||||
setText: () => {},
|
||||
setElementText: () => {},
|
||||
parentNode: () => null,
|
||||
nextSibling: () => null,
|
||||
insert: () => {},
|
||||
remove: () => {},
|
||||
patchProp: () => {}
|
||||
})
|
||||
const app = renderer.createApp(Wrapper, active === undefined ? {} : { active })
|
||||
app.mount({})
|
||||
await flush()
|
||||
await vue.nextTick()
|
||||
assert.deepEqual(
|
||||
calls,
|
||||
active === false
|
||||
? []
|
||||
: ['permissions', wrapper.startsWith('contract') ? 'contracts' : 'certificates']
|
||||
)
|
||||
app.unmount()
|
||||
for (const active of [undefined, false])
|
||||
test(`compiled certificate-usage/CertificateUsagePanel.vue ${active === false ? 'inactive skips requests' : 'defaults to active and loads'}`, async () => {
|
||||
const calls: string[] = []
|
||||
const dependencies = {
|
||||
useUserStore: () => vue.reactive({ accessToken: 'token', getUserInfo: { tenantNo: 'a' } }),
|
||||
useRouter: () => ({ push() {}, replace() {} }),
|
||||
fetchGetMenuCodes: async () => {
|
||||
calls.push('permissions')
|
||||
return { all: true }
|
||||
},
|
||||
quotaStatisticsApi: {
|
||||
certificates: async () => {
|
||||
calls.push('certificates')
|
||||
return { list: [], total: 0 }
|
||||
}
|
||||
},
|
||||
usePersistedListColumns,
|
||||
useServerColumnFilters,
|
||||
createStatisticsLoader,
|
||||
useElementSize: () => ({ width: vue.ref(1120) }),
|
||||
useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }),
|
||||
statusLabel: statisticsLabels.statusLabel,
|
||||
contractTypeLabel: statisticsLabels.contractTypeLabel
|
||||
}
|
||||
const StatisticsPanel = compiledSfc(
|
||||
'../src/views/pl/quota-statistics/StatisticsPanel.vue',
|
||||
dependencies,
|
||||
false
|
||||
)
|
||||
StatisticsPanel.render = () => null
|
||||
const Wrapper = compiledSfc('../src/views/pl/certificate-usage/CertificateUsagePanel.vue', {
|
||||
StatisticsPanel
|
||||
})
|
||||
}
|
||||
const renderer = vue.createRenderer<any, any>({
|
||||
createElement: () => ({}),
|
||||
createText: () => ({}),
|
||||
createComment: () => ({}),
|
||||
setText: () => {},
|
||||
setElementText: () => {},
|
||||
parentNode: () => null,
|
||||
nextSibling: () => null,
|
||||
insert: () => {},
|
||||
remove: () => {},
|
||||
patchProp: () => {}
|
||||
})
|
||||
const app = renderer.createApp(Wrapper, active === undefined ? {} : { active })
|
||||
app.mount({})
|
||||
await flush()
|
||||
await vue.nextTick()
|
||||
assert.deepEqual(calls, active === false ? [] : ['permissions', 'certificates'])
|
||||
app.unmount()
|
||||
})
|
||||
|
||||
test('expanded content follows the table viewport width and keeps alignment local', () => {
|
||||
const h = harness()
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
|
||||
const panelTitleClassList = [...panelTitleIconByText.map(([, cls]) => cls), 'auto-title-list']
|
||||
const panelTitleBarClass = 'title-bar-heading'
|
||||
const panelTitleBarTextRules = [/^(购买证与运输证|合法使用证明|计划用证|证件建档|证用品类|合同用证|证件用量|计划段次用证|证书齐备|合同证件与额度|运输证件规则)$/]
|
||||
const panelTitleBarTextRules = [/^(购买证与运输证|合法使用证明|计划用证|证件建档|证用品类|合同用证|客户用证|证件用量|计划段次用证|证书齐备|合同证件与额度|客户证件与额度|运输证件规则)$/]
|
||||
const panelTitleHeadingSelector =
|
||||
'.page-container .panel-top h2, .page-container .panel-top h3, .page-container .storage-content-header h3, .page-container .storage-aside__head h2, .page-container .user-dept-aside__head h2, .console-container .panel-top h2'
|
||||
|
||||
|
||||
@@ -624,6 +624,13 @@ export interface Plan {
|
||||
hazardCertReady?: number
|
||||
hazardCertReadyBy?: string
|
||||
hazardCertReadyTime?: string
|
||||
/** 证书齐备:新规则仅放行安排 */
|
||||
quotaCertWaived?: number
|
||||
quotaCertWaivedBy?: string
|
||||
quotaCertWaivedByName?: string
|
||||
quotaCertWaivedTime?: string
|
||||
/** 新规则证书齐备待完善 */
|
||||
quotaCertPendingFulfill?: number
|
||||
erpEntered?: number
|
||||
remark?: string
|
||||
createBy?: string
|
||||
|
||||
@@ -61,10 +61,13 @@ export interface QuotaCert extends Omit<CertBody, 'status'> {
|
||||
verifiedTime?: string | null
|
||||
openingConfirmed: boolean
|
||||
usagePolicy: 'once' | 'multiple' | 'needs_confirmation'
|
||||
singleUseConsumed?: boolean
|
||||
createTime?: string
|
||||
certType?: CertType
|
||||
derivedCategoryIds?: string[]
|
||||
derivedCategoryNames?: string[]
|
||||
customerName?: string | null
|
||||
categoryName?: string | null
|
||||
}
|
||||
export interface ScopeProduct {
|
||||
id?: string
|
||||
@@ -132,8 +135,10 @@ export interface LegalDocument {
|
||||
}
|
||||
const base = '/transport/quota-certs'
|
||||
export const quotaCertApi = {
|
||||
list: (kind: CertType, params: { keyword?: string; status?: string } = {}) =>
|
||||
request.get(`${base}/${kind}`, { params }).then(pickBxData<QuotaCert[]>),
|
||||
list: (
|
||||
kind: CertType,
|
||||
params: { keyword?: string; status?: string; customerId?: string } = {}
|
||||
) => request.get(`${base}/${kind}`, { params }).then(pickBxData<QuotaCert[]>),
|
||||
get: (kind: CertType, id: string) =>
|
||||
request.get(`${base}/${kind}/${id}`).then(pickBxData<QuotaCert>),
|
||||
save: (kind: CertType, body: CertBody, id?: string) =>
|
||||
@@ -504,6 +509,13 @@ export interface StatisticsPage<T> {
|
||||
export interface ContractStatisticsSummary extends ContractOption {
|
||||
certCount: number
|
||||
}
|
||||
export interface CustomerStatisticsSummary {
|
||||
id: string
|
||||
customerId: string
|
||||
customerNo: string
|
||||
customerName: string
|
||||
certCount: number
|
||||
}
|
||||
export interface CertificateStatisticsSummary {
|
||||
id: string
|
||||
certId: string
|
||||
@@ -522,6 +534,14 @@ export interface CertificateStatisticsSummary {
|
||||
buyPartyName?: string | null
|
||||
validUseText?: string | null
|
||||
}
|
||||
export interface CustomerStatistics extends CustomerStatisticsSummary {
|
||||
certificates: (CertificateStatisticsSummary & {
|
||||
usagePolicy?: QuotaCert['usagePolicy']
|
||||
singleUseConsumed?: boolean
|
||||
openingConfirmed?: boolean
|
||||
createTime?: string
|
||||
})[]
|
||||
}
|
||||
export interface StatisticsProduct {
|
||||
id: string
|
||||
contractProductId: string
|
||||
@@ -579,6 +599,8 @@ export interface StatisticsQuery {
|
||||
contractNo?: string
|
||||
contractName?: string
|
||||
contractTypeIn?: string
|
||||
customerNo?: string
|
||||
customerName?: string
|
||||
certCountOp?: string
|
||||
certCount?: string
|
||||
certCountMin?: string
|
||||
@@ -622,12 +644,18 @@ export const quotaStatisticsApi = {
|
||||
request
|
||||
.get(`${base}/statistics/contracts`, { params })
|
||||
.then(pickBxData<StatisticsPage<ContractStatisticsSummary>>),
|
||||
customers: (params: StatisticsQuery) =>
|
||||
request
|
||||
.get(`${base}/statistics/customers`, { params })
|
||||
.then(pickBxData<StatisticsPage<CustomerStatisticsSummary>>),
|
||||
certificates: (params: StatisticsQuery) =>
|
||||
request
|
||||
.get(`${base}/statistics/certificates`, { params })
|
||||
.then(pickBxData<StatisticsPage<CertificateStatisticsSummary>>),
|
||||
contract: (id: string) =>
|
||||
request.get(`${base}/contracts/${id}/statistics`).then(pickBxData<ContractStatistics>),
|
||||
customer: (id: string) =>
|
||||
request.get(`${base}/customers/${id}/statistics`).then(pickBxData<CustomerStatistics>),
|
||||
certificate: (kind: CertType, id: string) =>
|
||||
request.get(`${base}/${kind}/${id}/statistics`).then(pickBxData<CertificateStatistics>)
|
||||
}
|
||||
@@ -299,6 +299,7 @@
|
||||
extractArchiveLineOverageDetail,
|
||||
showArchiveLineOverageConfirm
|
||||
} from '@/utils/pl-contract-qty-reminder'
|
||||
import { resolveArchiveQuotaBlockMode } from '@/utils/pl-archive-quota-block'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
@@ -1602,37 +1603,18 @@
|
||||
|
||||
async function showArchiveQuotaBlockDialog(message: string, title = '不能归档') {
|
||||
const html = `<p class="pl-arrange-block-text">${escapeArchiveBlockText(message)}</p>`
|
||||
const pendingFulfill =
|
||||
message.includes('待完善') || message.includes('完善并关闭') || message.includes('证书齐备待完善')
|
||||
if (title !== '用证没有完成' || pendingFulfill) {
|
||||
const guide = pendingFulfill
|
||||
? `${html}<p class="pl-arrange-block-text">新规则下证书齐备只放行安排,不能绕过归档挂证。请到「证书齐备 → 已标记待完善证书」上传证件并执行完善并关闭后再归档。</p>`
|
||||
const mode = resolveArchiveQuotaBlockMode(message, title, plan.value)
|
||||
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
|
||||
}
|
||||
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>>) {
|
||||
@@ -1667,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
|
||||
|
||||
@@ -71,23 +71,6 @@
|
||||
<p v-else-if="!loading" class="pl-plan-form-empty-hint">暂无车辆信息</p>
|
||||
</section>
|
||||
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
<h3 class="pl-plan-form-section-title">关联购买证 / 运输证</h3>
|
||||
<p v-if="plan?.quotaCertPendingFulfill" class="pl-plan-form-empty-hint">
|
||||
证书齐备(待完善):请到「证书齐备 → 已标记待完善证书」挂证并完善并关闭
|
||||
</p>
|
||||
<div v-else-if="linkedCertRows.length" class="pl-plan-linked-certs">
|
||||
<div v-for="item in linkedCertRows" :key="item.key" class="pl-plan-linked-cert-row">
|
||||
<el-tag size="small" :type="item.kind === 'purchase' ? 'success' : 'warning'">
|
||||
{{ item.kind === 'purchase' ? '购买证' : '运输证' }}
|
||||
</el-tag>
|
||||
<span>{{ item.certNo || item.certId }}</span>
|
||||
<span class="text-slate-500">预占 {{ item.reservedQty }} / 已用 {{ item.usedQty }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="pl-plan-form-empty-hint">暂无关联证件</p>
|
||||
</section>
|
||||
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
<h3 class="pl-plan-form-section-title">装送货信息</h3>
|
||||
<PlPlanStopsSplitShell
|
||||
@@ -251,8 +234,31 @@
|
||||
</section>
|
||||
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
<h3 class="pl-plan-form-section-title">危化品证书信息</h3>
|
||||
<PlPlanHazardCertViewTable :products="hazardProducts" />
|
||||
<h3 class="pl-plan-form-section-title">关联购买证 / 运输证</h3>
|
||||
<p v-if="plan?.quotaCertPendingFulfill" class="pl-plan-form-empty-hint">
|
||||
证书齐备(待完善):请到「证书齐备 → 已标记待完善证书」挂证并完善并关闭
|
||||
</p>
|
||||
<div v-else-if="linkedCertRows.length" class="pl-plan-linked-certs">
|
||||
<div v-for="item in linkedCertRows" :key="item.key" class="pl-plan-linked-cert-block">
|
||||
<div class="pl-plan-linked-cert-block__meta">
|
||||
<el-tag size="small" :type="item.kind === 'purchase' ? 'success' : 'warning'">
|
||||
{{ item.kind === 'purchase' ? '购买证' : '运输证' }}
|
||||
</el-tag>
|
||||
<span class="pl-plan-linked-cert-block__no">{{ item.certNo || item.certId }}</span>
|
||||
<span class="pl-plan-linked-cert-block__qty"
|
||||
>预占 {{ item.reservedQty }} / 已用 {{ item.usedQty }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="pl-plan-linked-cert-block__slot">
|
||||
<span class="pl-plan-linked-cert-block__label">证件附件</span>
|
||||
<div v-if="item.attachmentUrls" class="pl-plan-linked-cert-block__attachment">
|
||||
<AttachmentUploadField :model-value="item.attachmentUrls" disabled />
|
||||
</div>
|
||||
<span v-else class="pl-plan-linked-cert-block__missing">未上传</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="pl-plan-form-empty-hint">暂无关联证件</p>
|
||||
</section>
|
||||
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
@@ -430,7 +436,6 @@
|
||||
type BrandQtyRow
|
||||
} from '@/components/business/brand-qty-chips-field/index.vue'
|
||||
import PlPlanDriverFeedbackDialog from '@/components/business/pl-plan-driver-feedback-dialog/index.vue'
|
||||
import PlPlanHazardCertViewTable from '@/components/business/pl-plan-hazard-cert-view-table/index.vue'
|
||||
import PlPlanArchiveBrandViewBlock from '@/components/business/pl-plan-archive-brand-view-block/index.vue'
|
||||
import PlPlanStopFeedbackPanel from '@/components/business/pl-plan-stop-feedback-panel/index.vue'
|
||||
import PlPlanStopProductTable from '@/components/business/pl-plan-stop-product-table/index.vue'
|
||||
@@ -476,7 +481,6 @@
|
||||
} from '@/utils/pl-plan-stop-form'
|
||||
import { buildArchiveBrandViewsBySegment } from '@/utils/pl-plan-archive-brand-view'
|
||||
import { resolveFreightUnitLabel } from '@/composables/usePlFreightCalc'
|
||||
import { isHazardProduct } from '@/composables/usePlToxicProductCertCheck'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
import { formatDate, formatDateTime } from '@/utils/format'
|
||||
import {
|
||||
@@ -1638,18 +1642,6 @@
|
||||
return fields
|
||||
})
|
||||
|
||||
/** 危化证区域展示 CRM 标准名(productRows 已回填 standardProductName;库内 productName 可能是别名) */
|
||||
const hazardProducts = computed(() =>
|
||||
products.value
|
||||
.filter((item) => isHazardProduct(item))
|
||||
.map((item) => {
|
||||
const formRow = item.id ? planProductFormById.value.get(String(item.id)) : undefined
|
||||
const standardName = String(formRow?.standardProductName || '').trim()
|
||||
if (!standardName || standardName === item.productName) return item
|
||||
return { ...item, productName: standardName }
|
||||
})
|
||||
)
|
||||
|
||||
const linkedCertRows = ref<
|
||||
Array<{
|
||||
key: string
|
||||
@@ -1658,6 +1650,7 @@
|
||||
certNo?: string
|
||||
reservedQty: string | number
|
||||
usedQty: string | number
|
||||
attachmentUrls: string
|
||||
}>
|
||||
>([])
|
||||
|
||||
@@ -1670,9 +1663,10 @@
|
||||
key: `${u.certType || u.cert_type}-${u.certId || u.cert_id}-${index}`,
|
||||
kind: String(u.certType || u.cert_type || ''),
|
||||
certId: String(u.certId || u.cert_id || ''),
|
||||
certNo: u.certNo || u.cert_no,
|
||||
certNo: u.certNo || u.cert_no || u.certNoSnapshot || u.cert_no_snapshot,
|
||||
reservedQty: u.reservedQty ?? u.reserved_qty ?? '—',
|
||||
usedQty: u.usedQty ?? u.used_qty ?? '—'
|
||||
usedQty: u.usedQty ?? u.used_qty ?? '—',
|
||||
attachmentUrls: String(u.attachmentUrls || u.attachment_urls || '').trim()
|
||||
}))
|
||||
} catch {
|
||||
linkedCertRows.value = []
|
||||
@@ -1932,6 +1926,93 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.pl-plan-linked-certs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 16px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border, #d9e8e2);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block__no {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block__qty {
|
||||
font-size: 13px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block__slot {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block__label {
|
||||
flex-shrink: 0;
|
||||
color: var(--mint-text);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block__attachment {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
|
||||
:deep(.attachment-upload) {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
:deep(.attachment-list) {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:deep(.attachment-item) {
|
||||
width: 140px;
|
||||
margin-bottom: 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.attachment-preview) {
|
||||
height: 96px;
|
||||
}
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block__missing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 140px;
|
||||
height: 96px;
|
||||
border: 1px dashed var(--border, #d9e8e2);
|
||||
border-radius: 8px;
|
||||
background: var(--table-head, #f8fafc);
|
||||
color: var(--mint-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pl-plan-kv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
|
||||
@@ -86,11 +86,13 @@ export function usePlFreightCalc(options: {
|
||||
)
|
||||
|
||||
function resetFreightCalc() {
|
||||
transportQty.value = null
|
||||
transportQtyTouched.value = false
|
||||
freightAmount.value = null
|
||||
freightAmountTouched.value = false
|
||||
freightPrice.value = null
|
||||
// 产品已加载时 watch 不会因 reset 重跑;须立即按当前实际数量回填,
|
||||
// 否则归档弹窗再次打开会误报「运输量须大于0」。
|
||||
transportQty.value = defaultTransportQty.value || null
|
||||
}
|
||||
|
||||
function markTransportQtyTouched() {
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
mode: 'list',
|
||||
title: '运输证件管理',
|
||||
search: '证件编号 / 品名',
|
||||
tabs: ['证件建档', '合同用证', '证件用量', '计划段次用证', '证书齐备'],
|
||||
tabs: ['证件建档', '客户用证', '证件用量', '计划段次用证', '证书齐备'],
|
||||
filters: [],
|
||||
caption: '正在加载运输证件管理'
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export const plRoutes: AppRouteRecord[] = [
|
||||
name: 'PlContractQuota',
|
||||
path: 'contract-quota',
|
||||
component: '/pl/contract-quota/index',
|
||||
meta: { title: '合同用证', keepAlive: false, menuCode: 'pl:quota-cert:list' }
|
||||
meta: { title: '客户用证', keepAlive: false, menuCode: 'pl:quota-cert:list' }
|
||||
},
|
||||
{
|
||||
id: 'PL_CERTIFICATE_USAGE',
|
||||
|
||||
@@ -171,11 +171,17 @@ export const staticRoutes: AppRouteRecordRaw[] = [
|
||||
component: () => import('@views/m/pl/plan-cert/archive-detail.vue'),
|
||||
meta: { title: '证件建档', isHideTab: true, isMpWebview: true }
|
||||
},
|
||||
{
|
||||
path: '/m/pl/plan-cert/customer/:id',
|
||||
name: 'MpPlPlanCertCustomer',
|
||||
component: () => import('@views/m/pl/plan-cert/customer-detail.vue'),
|
||||
meta: { title: '客户用证', isHideTab: true, isMpWebview: true }
|
||||
},
|
||||
{
|
||||
path: '/m/pl/plan-cert/contract/:id',
|
||||
name: 'MpPlPlanCertContract',
|
||||
component: () => import('@views/m/pl/plan-cert/contract-detail.vue'),
|
||||
meta: { title: '合同用证', isHideTab: true, isMpWebview: true }
|
||||
redirect: { name: 'MpPlPlanCert', query: { tab: 'contract' } },
|
||||
meta: { title: '客户用证', isHideTab: true, isMpWebview: true }
|
||||
},
|
||||
{
|
||||
path: '/m/pl/plan-cert/usage/:kind/:id',
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/** 归档用证拦截弹窗:只提醒,不在归档界面提供「证书齐备」操作。 */
|
||||
|
||||
export type ArchiveQuotaBlockMode = 'alert-pending' | 'alert-plain'
|
||||
|
||||
export type ArchiveQuotaBlockPlanFlags = {
|
||||
quotaCertPendingFulfill?: number | boolean | null
|
||||
quotaCertWaived?: number | boolean | null
|
||||
}
|
||||
|
||||
function flagOn(value: number | boolean | null | undefined) {
|
||||
return value === true || Number(value || 0) === 1
|
||||
}
|
||||
|
||||
export function resolveArchiveQuotaBlockMode(
|
||||
message: string,
|
||||
title: string,
|
||||
plan?: ArchiveQuotaBlockPlanFlags | null
|
||||
): ArchiveQuotaBlockMode {
|
||||
const pendingByMessage =
|
||||
message.includes('待完善') ||
|
||||
message.includes('完善并关闭') ||
|
||||
message.includes('证书齐备待完善')
|
||||
const pendingByPlan = flagOn(plan?.quotaCertPendingFulfill) || flagOn(plan?.quotaCertWaived)
|
||||
if (title !== '用证没有完成') return 'alert-plain'
|
||||
if (pendingByPlan || pendingByMessage) return 'alert-pending'
|
||||
return 'alert-plain'
|
||||
}
|
||||
@@ -504,13 +504,12 @@
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane v-if="canViewQuotaCert" label="合同证件与额度" name="quota" lazy>
|
||||
<ContractQuotaPanel
|
||||
<el-tab-pane v-if="canViewQuotaCert" label="客户证件与额度" name="quota" lazy>
|
||||
<CustomerCertQuotaPanel
|
||||
v-if="visible && detailActiveTab === 'quota' && detailContract?.id && String(detailContract.id) === String(currentDetailId)"
|
||||
:key="detailContract.id"
|
||||
:contract-id="String(detailContract.id)"
|
||||
:key="String(detailContract.customerId || detailContract.id)"
|
||||
:customer-id="detailContract.customerId"
|
||||
:active="detailActiveTab === 'quota'"
|
||||
embedded
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
@@ -1060,7 +1059,7 @@ import UploadDialog from './UploadDialog.vue';
|
||||
import ContractFormDialog from './ContractFormDialog.vue';
|
||||
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue';
|
||||
import type { ViewField } from '@/components/business/system-view-dialog/types';
|
||||
import ContractQuotaPanel from '@/views/pl/contract-quota/ContractQuotaPanel.vue';
|
||||
import CustomerCertQuotaPanel from '@/views/pl/contract-quota/CustomerCertQuotaPanel.vue';
|
||||
import StopDocuments from '@/views/pl/legal-use-document/StopDocuments.vue';
|
||||
import PlContractLinkedPlansPanel from '@/components/business/pl-contract-linked-plans-panel/index.vue';
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="mp-customer-cert-quota">
|
||||
<p v-if="!customerId" class="mp-wv-empty">合同未关联客户,无法展示证件</p>
|
||||
<p v-else-if="error" class="mp-wv-empty" role="alert">
|
||||
{{ error }}
|
||||
<button class="mp-wv-btn" type="button" @click="load">重试</button>
|
||||
</p>
|
||||
<div v-else-if="loading" class="mp-cert-page-loading"><van-loading /></div>
|
||||
<template v-else>
|
||||
<p class="mp-customer-cert-quota__tip">
|
||||
展示该客户全部购买证、运输证;证件关联客户,不再按合同绑定。
|
||||
</p>
|
||||
<van-empty v-if="!rows.length" description="该客户暂无购买证或运输证" />
|
||||
<article
|
||||
v-for="row in rows"
|
||||
:key="row.certType + row.id"
|
||||
class="mp-wv-section"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="openDetail(row)"
|
||||
@keydown.enter.prevent="openDetail(row)"
|
||||
>
|
||||
<div class="mp-wv-card__head">
|
||||
<div>
|
||||
<h3>{{ row.certNo || '—' }}</h3>
|
||||
<p>{{ row.productNameOnCert || '未填写证面品名' }}</p>
|
||||
<p>有效期:{{ row.validFrom || '—' }} 至 {{ row.validTo || '—' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mp-cert-tags">
|
||||
<span
|
||||
class="status-tag mp-cert-kind-tag"
|
||||
:class="`status-tag--${certKindMobileTone(row.certType)}`"
|
||||
>{{ certKindLabel(row.certType) }}</span
|
||||
>
|
||||
<span class="status-tag" :class="`status-tag--${statusTone(row.status)}`">{{
|
||||
certStatusLabel(row.status)
|
||||
}}</span>
|
||||
<span v-if="row.singleUseConsumed" class="status-tag status-tag--danger"
|
||||
>已使用不可再用</span
|
||||
>
|
||||
</div>
|
||||
<div class="mp-wv-card__kv">
|
||||
<div class="mp-wv-card__kv-row"
|
||||
><span>有效次数</span><span>{{ row.validUseText || '—' }}</span></div
|
||||
>
|
||||
<div class="mp-wv-card__kv-row"
|
||||
><span>核定</span><span>{{ qtyText(row.totalQty, row.quantityUnit) }}</span></div
|
||||
>
|
||||
<div class="mp-wv-card__kv-row"
|
||||
><span>预占</span><span>{{ qtyText(row.reservedQty, row.quantityUnit) }}</span></div
|
||||
>
|
||||
<div class="mp-wv-card__kv-row"
|
||||
><span>已用</span><span>{{ qtyText(row.usedQty, row.quantityUnit) }}</span></div
|
||||
>
|
||||
<div class="mp-wv-card__kv-row"
|
||||
><span>余量</span><span>{{ qtyText(row.availableQty, row.quantityUnit) }}</span></div
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
||||
import { quotaCertApi, type CertType, type QuotaCert } from '@/api/pl/quota-cert'
|
||||
import { certKindLabel, certKindMobileTone } from '@/views/pl/quota-cert/cert-list-face'
|
||||
import { formatCertQty } from '@/views/pl/quota-cert/model'
|
||||
|
||||
type CustomerCertRow = QuotaCert & { certType: CertType }
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ customerId?: string | number | null; active?: boolean }>(),
|
||||
{ active: true }
|
||||
)
|
||||
|
||||
const router = useRouter()
|
||||
const dictStore = useDictStore()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const rows = ref<CustomerCertRow[]>([])
|
||||
let loadEpoch = 0
|
||||
|
||||
function unitLabel(unit?: string | null) {
|
||||
const label = getProductUnitLabel(dictStore.getLabel, unit)
|
||||
return !label || label === '—' ? '' : label
|
||||
}
|
||||
|
||||
function qtyText(value: unknown, unit?: string | null) {
|
||||
const text = formatCertQty(value)
|
||||
const name = unitLabel(unit)
|
||||
return name ? `${text} ${name}` : text
|
||||
}
|
||||
|
||||
function certStatusLabel(status?: string | null) {
|
||||
return (
|
||||
{
|
||||
draft: '待核对',
|
||||
active: '证件已启用',
|
||||
suspended: '证件已停用',
|
||||
closed: '证件已核销',
|
||||
void: '证件已作废'
|
||||
}[status || ''] ||
|
||||
status ||
|
||||
'—'
|
||||
)
|
||||
}
|
||||
|
||||
function statusTone(status?: string | null) {
|
||||
if (status === 'active') return 'success'
|
||||
if (status === 'suspended') return 'warning'
|
||||
if (status === 'closed' || status === 'void') return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const customerId = props.customerId == null ? '' : String(props.customerId).trim()
|
||||
const epoch = ++loadEpoch
|
||||
if (!customerId) {
|
||||
rows.value = []
|
||||
error.value = ''
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [purchase, transport] = await Promise.all([
|
||||
quotaCertApi.list('purchase', { customerId }),
|
||||
quotaCertApi.list('transport', { customerId })
|
||||
])
|
||||
if (epoch !== loadEpoch) return
|
||||
rows.value = [
|
||||
...(purchase || []).map((item) => ({ ...item, certType: 'purchase' as const })),
|
||||
...(transport || []).map((item) => ({ ...item, certType: 'transport' as const }))
|
||||
].sort((a, b) => String(b.createTime || '').localeCompare(String(a.createTime || '')))
|
||||
} catch (e: any) {
|
||||
if (epoch !== loadEpoch) return
|
||||
rows.value = []
|
||||
error.value = e?.message || '加载客户证件失败'
|
||||
} finally {
|
||||
if (epoch === loadEpoch) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(row: CustomerCertRow) {
|
||||
void router.push(`/m/pl/plan-cert/archive/${row.certType}/${row.id}`)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.customerId, props.active],
|
||||
([, active]) => {
|
||||
if (active) void load()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mp-customer-cert-quota__tip {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--mp-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.mp-wv-section {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mp-cert-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 8px 0 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -342,6 +342,19 @@
|
||||
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'quota'" class="mp-contract-detail-pane">
|
||||
<section class="mp-wv-section mp-contract-detail-card">
|
||||
<h3>客户证件与额度</h3>
|
||||
<CustomerCertQuotaTab
|
||||
v-if="canViewQuotaCert && activeTab === 'quota'"
|
||||
:key="String(contract.customerId || contract.id)"
|
||||
:customer-id="contract.customerId"
|
||||
:active="activeTab === 'quota'"
|
||||
/>
|
||||
<p v-else class="mp-wv-card__meta">无证件查看权限</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'plans'" class="mp-contract-detail-pane">
|
||||
<section class="mp-wv-section mp-contract-detail-card">
|
||||
<h3>运输计划</h3>
|
||||
@@ -933,6 +946,8 @@
|
||||
} from '@/utils/mp-webview'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
import { fetchGetMenuCodes } from '@/api/auth'
|
||||
import CustomerCertQuotaTab from './CustomerCertQuotaTab.vue'
|
||||
import '@/styles/mp-webview.scss'
|
||||
import '../customer/customer.scss'
|
||||
|
||||
@@ -970,6 +985,7 @@
|
||||
type DetailTabKey =
|
||||
| 'contract'
|
||||
| 'plans'
|
||||
| 'quota'
|
||||
| 'payment'
|
||||
| 'invoice'
|
||||
| 'approval'
|
||||
@@ -979,6 +995,7 @@
|
||||
const acting = ref(false)
|
||||
const errorText = ref('')
|
||||
const activeTab = ref<DetailTabKey>('contract')
|
||||
const canViewQuotaCert = ref(false)
|
||||
const detailBody = ref<HTMLElement>()
|
||||
const partiesExpanded = ref(false)
|
||||
const approverPickerOpen = ref(false)
|
||||
@@ -1056,14 +1073,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
const detailTabs: Array<{ key: DetailTabKey; label: string }> = [
|
||||
{ key: 'contract', label: '合同信息' },
|
||||
{ key: 'plans', label: '运输计划' },
|
||||
{ key: 'payment', label: '回款记录' },
|
||||
{ key: 'invoice', label: '开票记录' },
|
||||
{ key: 'approval', label: '审批流程' },
|
||||
{ key: 'operation', label: '操作记录' }
|
||||
]
|
||||
const detailTabs = computed(() => {
|
||||
const tabs: Array<{ key: DetailTabKey; label: string }> = [
|
||||
{ key: 'contract', label: '合同信息' },
|
||||
{ key: 'plans', label: '运输计划' }
|
||||
]
|
||||
if (canViewQuotaCert.value) tabs.push({ key: 'quota', label: '客户证件与额度' })
|
||||
tabs.push(
|
||||
{ key: 'payment', label: '回款记录' },
|
||||
{ key: 'invoice', label: '开票记录' },
|
||||
{ key: 'approval', label: '审批流程' },
|
||||
{ key: 'operation', label: '操作记录' }
|
||||
)
|
||||
return tabs
|
||||
})
|
||||
|
||||
async function refreshQuotaCertPermission() {
|
||||
try {
|
||||
const permissions = await fetchGetMenuCodes()
|
||||
const codes = permissions?.menuCodes || []
|
||||
canViewQuotaCert.value =
|
||||
!!permissions?.all || !!permissions?.isSuperAdmin || codes.includes('pl:quota-cert:list')
|
||||
} catch {
|
||||
canViewQuotaCert.value = false
|
||||
}
|
||||
if (!canViewQuotaCert.value && activeTab.value === 'quota') {
|
||||
activeTab.value = 'contract'
|
||||
}
|
||||
}
|
||||
|
||||
const unreceivedAmount = computed(() => {
|
||||
const amount = Number(contract.value?.amount) || 0
|
||||
@@ -1573,7 +1610,7 @@
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getContract(id)
|
||||
const [res] = await Promise.all([getContract(id), refreshQuotaCertPermission()])
|
||||
if (!isCurrentLoad(requestSeq, id)) return
|
||||
const data = ((res as any)?.data ?? res) as ContractVO
|
||||
if (String(data?.id || '') !== id) throw new Error('合同不存在或无权查看')
|
||||
|
||||
@@ -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
|
||||
>
|
||||
@@ -60,12 +66,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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,144 +1,7 @@
|
||||
<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>
|
||||
</p>
|
||||
<div v-else-if="authReady && !allowed" class="mp-wv-empty">
|
||||
<div class="mp-wv-empty__title">无证件查看权限</div>
|
||||
</div>
|
||||
<van-list v-else 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"
|
||||
@click="open(row.id)"
|
||||
>
|
||||
<div class="mp-wv-card__head">
|
||||
<div>
|
||||
<div class="mp-wv-card__no">{{ row.contractNo || '未编号合同' }}</div>
|
||||
<div class="mp-wv-card__sub">{{ row.contractName || '—' }}</div>
|
||||
</div>
|
||||
<span class="status-tag status-tag--info">{{ contractTypeLabel(row.contractType) }}</span>
|
||||
</div>
|
||||
<div class="mp-wv-card__kv">
|
||||
<div class="mp-wv-card__kv-row">
|
||||
<span>关联证件数</span><span>{{ row.certCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mp-wv-card-actions mp-wv-card-actions--single" @click.stop>
|
||||
<button class="mp-wv-btn mp-wv-btn--primary" type="button" @click="open(row.id)">查看</button>
|
||||
</div>
|
||||
</article>
|
||||
</van-list>
|
||||
<van-empty
|
||||
v-if="authReady && allowed && !loading && finished && !error && !rows.length"
|
||||
description="暂无合同用证数据"
|
||||
/>
|
||||
</main>
|
||||
<CustomerTab :keyword="keyword" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { fetchGetMenuCodes } from '@/api/auth'
|
||||
import { quotaStatisticsApi, type ContractStatisticsSummary } from '@/api/pl/quota-cert'
|
||||
import { contractTypeLabel } from '@/views/pl/quota-statistics/labels'
|
||||
|
||||
const props = defineProps<{ keyword: string }>()
|
||||
const router = useRouter()
|
||||
const rows = ref<ContractStatisticsSummary[]>([])
|
||||
const loading = ref(true)
|
||||
const finished = ref(false)
|
||||
const error = ref('')
|
||||
const allowed = ref(false)
|
||||
const authReady = ref(false)
|
||||
const page = ref(1)
|
||||
let epoch = 0
|
||||
let checking = true
|
||||
|
||||
function open(id: string) {
|
||||
void router.push(`/m/pl/plan-cert/contract/${id}`)
|
||||
}
|
||||
async function init() {
|
||||
const current = epoch
|
||||
try {
|
||||
const permissions = await fetchGetMenuCodes()
|
||||
if (current !== epoch) return
|
||||
const codes = permissions?.menuCodes || []
|
||||
allowed.value =
|
||||
!!permissions?.all || !!permissions?.isSuperAdmin || codes.includes('pl:quota-cert:list')
|
||||
authReady.value = true
|
||||
checking = false
|
||||
if (!allowed.value) {
|
||||
finished.value = true
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
await load()
|
||||
} catch {
|
||||
if (current !== epoch) return
|
||||
checking = false
|
||||
authReady.value = true
|
||||
finished.value = true
|
||||
loading.value = false
|
||||
error.value = '权限加载失败,请重新打开页面'
|
||||
}
|
||||
}
|
||||
function reload() {
|
||||
if (!allowed.value) {
|
||||
void init()
|
||||
return
|
||||
}
|
||||
epoch++
|
||||
page.value = 1
|
||||
rows.value = []
|
||||
loading.value = true
|
||||
finished.value = false
|
||||
error.value = ''
|
||||
void load()
|
||||
}
|
||||
async function load() {
|
||||
if (checking || !allowed.value) return
|
||||
const current = epoch
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await quotaStatisticsApi.contracts({
|
||||
page: page.value,
|
||||
pageSize: 20,
|
||||
keyword: props.keyword || undefined
|
||||
})
|
||||
if (current !== epoch) return
|
||||
const list = data?.list || []
|
||||
rows.value = page.value === 1 ? list : rows.value.concat(list)
|
||||
finished.value = rows.value.length >= (data?.total || 0) || list.length < 20
|
||||
page.value += 1
|
||||
} catch {
|
||||
if (current === epoch) {
|
||||
error.value = '统计加载失败,请重试'
|
||||
finished.value = true
|
||||
}
|
||||
} finally {
|
||||
if (current === epoch) loading.value = false
|
||||
}
|
||||
}
|
||||
watch(
|
||||
() => props.keyword,
|
||||
() => {
|
||||
if (!checking && allowed.value) reload()
|
||||
}
|
||||
)
|
||||
void init()
|
||||
import CustomerTab from './CustomerTab.vue'
|
||||
defineProps<{ keyword: string }>()
|
||||
</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>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<main class="mp-h5-list-content mp-plan-cert-list">
|
||||
<p class="mp-customer-quota-tip">按客户展示购买证、运输证;证件关联客户,不再按合同绑定。</p>
|
||||
<p v-if="error" class="mp-wv-empty" role="alert">
|
||||
{{ error }}
|
||||
<button class="mp-wv-btn" type="button" @click="reload">重试</button>
|
||||
</p>
|
||||
<div v-else-if="authReady && !allowed" class="mp-wv-empty">
|
||||
<div class="mp-wv-empty__title">无证件查看权限</div>
|
||||
</div>
|
||||
<van-list v-else 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"
|
||||
@click="open(row.id)"
|
||||
>
|
||||
<div class="mp-wv-card__head">
|
||||
<div>
|
||||
<div class="mp-wv-card__no">{{ row.customerName || '未命名客户' }}</div>
|
||||
<div class="mp-wv-card__sub">{{ row.customerNo || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mp-wv-card__kv">
|
||||
<div class="mp-wv-card__kv-row">
|
||||
<span>关联证件数</span><span>{{ row.certCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mp-wv-card-actions mp-wv-card-actions--single" @click.stop>
|
||||
<button class="mp-wv-btn mp-wv-btn--primary" type="button" @click="open(row.id)"
|
||||
>查看</button
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
</van-list>
|
||||
<van-empty
|
||||
v-if="authReady && allowed && !loading && finished && !error && !rows.length"
|
||||
description="暂无客户用证数据"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { fetchGetMenuCodes } from '@/api/auth'
|
||||
import { quotaStatisticsApi, type CustomerStatisticsSummary } from '@/api/pl/quota-cert'
|
||||
|
||||
const props = defineProps<{ keyword: string }>()
|
||||
const router = useRouter()
|
||||
const rows = ref<CustomerStatisticsSummary[]>([])
|
||||
const loading = ref(true)
|
||||
const finished = ref(false)
|
||||
const error = ref('')
|
||||
const allowed = ref(false)
|
||||
const authReady = ref(false)
|
||||
const page = ref(1)
|
||||
let epoch = 0
|
||||
let checking = true
|
||||
|
||||
function open(id: string) {
|
||||
void router.push(`/m/pl/plan-cert/customer/${id}`)
|
||||
}
|
||||
async function init() {
|
||||
const current = epoch
|
||||
try {
|
||||
const permissions = await fetchGetMenuCodes()
|
||||
if (current !== epoch) return
|
||||
const codes = permissions?.menuCodes || []
|
||||
allowed.value =
|
||||
!!permissions?.all || !!permissions?.isSuperAdmin || codes.includes('pl:quota-cert:list')
|
||||
authReady.value = true
|
||||
checking = false
|
||||
if (!allowed.value) {
|
||||
finished.value = true
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
await load()
|
||||
} catch {
|
||||
if (current !== epoch) return
|
||||
checking = false
|
||||
authReady.value = true
|
||||
finished.value = true
|
||||
loading.value = false
|
||||
error.value = '权限加载失败,请重新打开页面'
|
||||
}
|
||||
}
|
||||
function reload() {
|
||||
if (!allowed.value) {
|
||||
void init()
|
||||
return
|
||||
}
|
||||
epoch++
|
||||
page.value = 1
|
||||
rows.value = []
|
||||
loading.value = true
|
||||
finished.value = false
|
||||
error.value = ''
|
||||
void load()
|
||||
}
|
||||
async function load() {
|
||||
if (checking || !allowed.value) return
|
||||
const current = epoch
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await quotaStatisticsApi.customers({
|
||||
page: page.value,
|
||||
pageSize: 20,
|
||||
keyword: props.keyword || undefined
|
||||
})
|
||||
if (current !== epoch) return
|
||||
const list = data?.list || []
|
||||
rows.value = page.value === 1 ? list : rows.value.concat(list)
|
||||
finished.value = rows.value.length >= (data?.total || 0) || list.length < 20
|
||||
page.value += 1
|
||||
} catch {
|
||||
if (current === epoch) {
|
||||
error.value = '统计加载失败,请重试'
|
||||
finished.value = true
|
||||
}
|
||||
} finally {
|
||||
if (current === epoch) loading.value = false
|
||||
}
|
||||
}
|
||||
watch(
|
||||
() => props.keyword,
|
||||
() => {
|
||||
if (!checking && allowed.value) reload()
|
||||
}
|
||||
)
|
||||
void init()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mp-customer-quota-tip {
|
||||
margin: 8px 12px 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #ecfdf5;
|
||||
color: #065f46;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -1201,19 +1201,20 @@
|
||||
return
|
||||
}
|
||||
const certRecord = (cert || {}) as Record<string, any>
|
||||
let name =
|
||||
String(certRecord.customerName || '').trim() ||
|
||||
systemCustomer.value.customerName ||
|
||||
customerOptions.value.find((item) => item.id === id)?.customerName ||
|
||||
''
|
||||
let name = ''
|
||||
try {
|
||||
const detail = (await fetchCustomerDetail({ id })) as Record<string, any>
|
||||
const data = detail?.data || detail
|
||||
name = String(data?.customerName || '').trim()
|
||||
} catch {
|
||||
name = ''
|
||||
}
|
||||
// 系统客户以 CRM 为准;禁止用证面收货单位等缓存名顶替。
|
||||
if (!name) {
|
||||
try {
|
||||
const detail = (await fetchCustomerDetail({ id })) as Record<string, any>
|
||||
const data = detail?.data || detail
|
||||
name = String(data?.customerName || '').trim() || id
|
||||
} catch {
|
||||
name = id
|
||||
}
|
||||
name =
|
||||
String(certRecord.customerName || '').trim() ||
|
||||
customerOptions.value.find((item) => item.id === id)?.customerName ||
|
||||
id
|
||||
}
|
||||
systemCustomer.value = { id, customerName: name }
|
||||
form.value.customerId = id
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<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">
|
||||
{{ error }}
|
||||
<button class="mp-wv-btn" type="button" @click="retry">重试</button>
|
||||
</p>
|
||||
<div v-else-if="!authReady || loading" class="mp-cert-page-loading"><van-loading /></div>
|
||||
<div v-else-if="!allowed" class="mp-wv-empty">
|
||||
<div class="mp-wv-empty__title">无证件查看权限</div>
|
||||
</div>
|
||||
<template v-else-if="detail">
|
||||
<section class="mp-wv-section">
|
||||
<div class="mp-cert-contract-head">
|
||||
<div class="mp-cert-contract-head__main">
|
||||
<h3>{{ detail.customerName || '未命名客户' }}</h3>
|
||||
<p>{{ detail.customerNo || '—' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mp-wv-card__kv">
|
||||
<div class="mp-wv-card__kv-row">
|
||||
<span>关联证件数</span><span>{{ detail.certCount ?? certificates.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<CustomerCertQuotaTab :customer-id="customerId" :active="true" />
|
||||
</template>
|
||||
</main>
|
||||
</MpWebviewShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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)
|
||||
const error = ref('')
|
||||
const detail = ref<CustomerStatistics | null>(null)
|
||||
const customerId = computed(() => String(route.params.id || '').trim())
|
||||
const certificates = computed(() => detail.value?.certificates || [])
|
||||
|
||||
async function load() {
|
||||
const id = customerId.value
|
||||
if (!id) {
|
||||
error.value = '缺少客户 ID'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const permissions = await fetchGetMenuCodes()
|
||||
const codes = permissions?.menuCodes || []
|
||||
allowed.value =
|
||||
!!permissions?.all || !!permissions?.isSuperAdmin || codes.includes('pl:quota-cert:list')
|
||||
authReady.value = true
|
||||
if (!allowed.value) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
detail.value = await quotaStatisticsApi.customer(id)
|
||||
} catch (e: any) {
|
||||
detail.value = null
|
||||
error.value = e?.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
void load()
|
||||
}
|
||||
|
||||
watch(customerId, () => void load(), { immediate: true })
|
||||
</script>
|
||||
@@ -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,
|
||||
@@ -44,7 +60,21 @@ export function certFaceGroups(
|
||||
: display(cert, field[0]),
|
||||
wide: longFaceKeys.has(field[0]) || field[0] === 'vehicleNumbersJson'
|
||||
}))
|
||||
if (index === 0) fields.unshift({ label: '状态', value: display(cert, 'status') })
|
||||
if (index === 0) {
|
||||
fields.unshift(
|
||||
{ label: '状态', value: display(cert, 'status') },
|
||||
{
|
||||
label: '系统客户',
|
||||
value: String(cert.customerName || '').trim() || '—',
|
||||
wide: true
|
||||
},
|
||||
{
|
||||
label: '证用品类',
|
||||
value: certCategoryLabel(cert, kind),
|
||||
wide: true
|
||||
}
|
||||
)
|
||||
}
|
||||
return { title: group.title, fields }
|
||||
})
|
||||
groups[groups.length - 1].fields.push({
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
const committed = ref('')
|
||||
const CERT_TABS = [
|
||||
{ name: 'archive', title: '证件建档', perm: 'mini:pl:quota-cert:list' },
|
||||
{ name: 'contract', title: '合同用证', perm: 'mini:pl:contract-quota:list' },
|
||||
{ name: 'contract', title: '客户用证', perm: 'mini:pl:contract-quota:list' },
|
||||
{ name: 'usage', title: '证件用量', perm: 'mini:pl:certificate-usage:list' },
|
||||
{ name: 'plan', title: '计划段次用证', perm: 'mini:pl:plan-cert:segment' },
|
||||
{ name: 'ready', title: '证书齐备', perm: 'mini:pl:cert-ready:list' }
|
||||
@@ -361,7 +361,7 @@
|
||||
]
|
||||
const placeholder = computed(() =>
|
||||
tab.value === 'contract'
|
||||
? '合同编号 / 名称 / 销售或购买单位'
|
||||
? '客户名称 / 编号'
|
||||
: tab.value === 'plan' || tab.value === 'ready'
|
||||
? '计划编号 / 供方 / 需方'
|
||||
: '证件编号 / 品名 / 销售或购买单位'
|
||||
|
||||
@@ -248,7 +248,37 @@
|
||||
</section>
|
||||
|
||||
<section id="plan-sec-cert" class="mp-wv-section mp-h5-anchor">
|
||||
<h3>危化品证书信息</h3>
|
||||
<h3>关联购买证 / 运输证</h3>
|
||||
<p v-if="plan.quotaCertPendingFulfill" class="plan-empty-hint">
|
||||
证书齐备(待完善):请到「证书齐备 → 已标记待完善证书」挂证并完善并关闭
|
||||
</p>
|
||||
<div v-else-if="linkedCertRows.length" class="plan-linked-certs">
|
||||
<div v-for="item in linkedCertRows" :key="item.key" class="plan-linked-cert-block">
|
||||
<div class="plan-linked-cert-block__meta">
|
||||
<span
|
||||
class="status-tag"
|
||||
:class="
|
||||
item.kind === 'purchase' ? 'status-tag--success' : 'status-tag--warning'
|
||||
"
|
||||
>{{ item.kind === 'purchase' ? '购买证' : '运输证' }}</span
|
||||
>
|
||||
<span class="plan-linked-cert-block__no">{{ item.certNo || item.certId }}</span>
|
||||
<span class="plan-vehicle-card__meta"
|
||||
>预占 {{ item.reservedQty }} / 已用 {{ item.usedQty }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="plan-linked-cert-block__slot">
|
||||
<div class="plan-subcard__label">证件附件</div>
|
||||
<AttachmentUploadField
|
||||
v-if="item.attachmentUrls"
|
||||
:model-value="item.attachmentUrls"
|
||||
disabled
|
||||
/>
|
||||
<p v-else class="plan-linked-cert-block__missing">未上传</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="plan-empty-hint">暂无关联证件</p>
|
||||
<div class="plan-cert-actions">
|
||||
<van-button block plain type="primary" @click="quotaOpen = true">查看用证</van-button>
|
||||
<van-button
|
||||
@@ -536,10 +566,40 @@
|
||||
const errorText = ref('')
|
||||
const plan = ref<Plan | null>(null)
|
||||
const quotaOpen = ref(false)
|
||||
const linkedCertRows = ref<
|
||||
Array<{
|
||||
key: string
|
||||
kind: string
|
||||
certId: string
|
||||
certNo?: string
|
||||
reservedQty: string | number
|
||||
usedQty: string | number
|
||||
attachmentUrls: string
|
||||
}>
|
||||
>([])
|
||||
const needsLegalUseDocument = computed(() =>
|
||||
(plan.value?.products || []).some((row) => resolveProductHazardFlags(row).isExplosive)
|
||||
)
|
||||
const quotaTitle = computed(() => `计划用证 · ${plan.value?.planNo || ''}`)
|
||||
|
||||
async function loadLinkedCerts(planId: string) {
|
||||
linkedCertRows.value = []
|
||||
try {
|
||||
const { planQuotaApi } = await import('@/api/pl/quota-cert')
|
||||
const data = await planQuotaApi.get(planId)
|
||||
linkedCertRows.value = (data?.usages || []).map((u: any, index: number) => ({
|
||||
key: `${u.certType || u.cert_type}-${u.certId || u.cert_id}-${index}`,
|
||||
kind: String(u.certType || u.cert_type || ''),
|
||||
certId: String(u.certId || u.cert_id || ''),
|
||||
certNo: u.certNo || u.cert_no || u.certNoSnapshot || u.cert_no_snapshot,
|
||||
reservedQty: u.reservedQty ?? u.reserved_qty ?? '—',
|
||||
usedQty: u.usedQty ?? u.used_qty ?? '—',
|
||||
attachmentUrls: String(u.attachmentUrls || u.attachment_urls || '').trim()
|
||||
}))
|
||||
} catch {
|
||||
linkedCertRows.value = []
|
||||
}
|
||||
}
|
||||
const deliveryIndex = ref(0)
|
||||
const moreOpen = ref(false)
|
||||
const voidOpen = ref(false)
|
||||
@@ -568,7 +628,7 @@
|
||||
{ key: 'base', label: '基础信息', anchor: 'plan-sec-base' },
|
||||
{ key: 'transport', label: '运输信息', anchor: 'plan-sec-transport' },
|
||||
{ key: 'load', label: '装送货', anchor: 'plan-sec-load' },
|
||||
{ key: 'cert', label: '危化品证', anchor: 'plan-sec-cert' },
|
||||
{ key: 'cert', label: '关联证件', anchor: 'plan-sec-cert' },
|
||||
{ key: 'freight', label: '运费', anchor: 'plan-sec-freight' },
|
||||
{ key: 'remark', label: '备注', anchor: 'plan-sec-remark' }
|
||||
]
|
||||
@@ -751,6 +811,7 @@
|
||||
loadVersion++
|
||||
actionVersion++
|
||||
plan.value = null
|
||||
linkedCertRows.value = []
|
||||
readDetail.value = null
|
||||
partialErrors.value = []
|
||||
errorText.value = ''
|
||||
@@ -807,9 +868,12 @@
|
||||
readDetail.value = detail
|
||||
partialErrors.value = [...detail.errors, ...(arrangeFailed ? ['安排信息'] : [])]
|
||||
plan.value = data
|
||||
await loadLinkedCerts(id)
|
||||
if (!current()) return
|
||||
} catch {
|
||||
if (!current()) return
|
||||
plan.value = null
|
||||
linkedCertRows.value = []
|
||||
resetArrangeActions()
|
||||
errorText.value = '计划详情加载失败或记录不存在,请重试'
|
||||
} finally {
|
||||
@@ -1516,6 +1580,57 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.plan-linked-certs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-linked-cert-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #d9e8e2;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.plan-linked-cert-block__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
}
|
||||
|
||||
.plan-linked-cert-block__no {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.plan-linked-cert-block__slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plan-linked-cert-block__missing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 140px;
|
||||
height: 96px;
|
||||
margin: 0;
|
||||
border: 1px dashed #d9e8e2;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mp-plan-quota-sheet {
|
||||
|
||||
@@ -50,6 +50,14 @@
|
||||
class="list-table"
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<el-table-column
|
||||
v-if="listMode === 'fulfill'"
|
||||
type="selection"
|
||||
width="48"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:reserve-selection="false"
|
||||
/>
|
||||
<el-table-column
|
||||
v-for="column in columns"
|
||||
:key="column.prop"
|
||||
@@ -93,12 +101,6 @@
|
||||
<span v-else>{{ row[column.prop] || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="listMode === 'fulfill'"
|
||||
type="selection"
|
||||
width="48"
|
||||
:reserve-selection="false"
|
||||
/>
|
||||
<el-table-column label="操作" min-width="260" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="listMode === 'fulfill'">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template><StatisticsPanel mode="contract" v-bind="props" /></template>
|
||||
<template><CustomerQuotaPanel v-bind="props" /></template>
|
||||
<script setup lang="ts">
|
||||
import StatisticsPanel from '../quota-statistics/StatisticsPanel.vue'
|
||||
import CustomerQuotaPanel from './CustomerQuotaPanel.vue'
|
||||
const props = withDefaults(
|
||||
defineProps<{ contractId?: string; embedded?: boolean; active?: boolean }>(),
|
||||
defineProps<{ customerId?: string; embedded?: boolean; active?: boolean }>(),
|
||||
{ active: true }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<div class="dialog-list-panel customer-cert-quota-panel">
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row contract-quota-toolbar">
|
||||
<span class="contract-quota-toolbar__tip"
|
||||
>展示该客户全部购买证、运输证(证件关联客户,不再按合同绑定)。额度按单张证件核算,多个合同共用共享余额。</span
|
||||
>
|
||||
<el-button :loading="loading" @click="load"
|
||||
><el-icon><Refresh /></el-icon>刷新</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" class="mb-3"
|
||||
><el-button @click="load"><el-icon><Refresh /></el-icon>重试</el-button></el-alert
|
||||
>
|
||||
<div v-loading="loading">
|
||||
<el-empty
|
||||
v-if="!customerId"
|
||||
description="合同未关联客户,无法展示证件"
|
||||
/>
|
||||
<el-empty
|
||||
v-else-if="!loading && !error && !rows.length"
|
||||
description="该客户暂无购买证或运输证"
|
||||
/>
|
||||
<ListTable
|
||||
v-else-if="rows.length"
|
||||
table-key="crm-customer-cert-quota"
|
||||
:data="rows"
|
||||
class="list-table"
|
||||
stripe
|
||||
empty-text="该客户暂无购买证或运输证"
|
||||
>
|
||||
<el-table-column type="index" label="序号" width="68" align="center" header-align="center" />
|
||||
<el-table-column label="证种" min-width="100" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="certKindTagType(row.certType)">{{
|
||||
certKindLabel(row.certType)
|
||||
}}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="证号" min-width="160" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" @click="openDetail(row)">{{ row.certNo || '—' }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="productNameOnCert"
|
||||
label="证面品名"
|
||||
min-width="140"
|
||||
:show-overflow-tooltip="true"
|
||||
/>
|
||||
<el-table-column label="有效次数" min-width="120" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.validUseText || '—' }}</span>
|
||||
<el-tag
|
||||
v-if="row.singleUseConsumed"
|
||||
class="ml-1"
|
||||
type="danger"
|
||||
size="small"
|
||||
>已使用不可再用</el-tag
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="核定" min-width="110" align="right" header-align="center">
|
||||
<template #default="{ row }">{{ qtyText(row.totalQty, row.quantityUnit) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预占" min-width="110" align="right" header-align="center">
|
||||
<template #default="{ row }">{{ qtyText(row.reservedQty, row.quantityUnit) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已用" min-width="110" align="right" header-align="center">
|
||||
<template #default="{ row }">{{ qtyText(row.usedQty, row.quantityUnit) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="余量" min-width="110" align="right" header-align="center">
|
||||
<template #default="{ row }">{{ qtyText(row.availableQty, row.quantityUnit) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="有效期" min-width="200" align="center" header-align="center">
|
||||
<template #default="{ row }"
|
||||
>{{ row.validFrom || '—' }} 至 {{ row.validTo || '—' }}</template
|
||||
>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" min-width="110" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="statusTagType(row.status)">{{
|
||||
certStatusLabel(row.status)
|
||||
}}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</ListTable>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="detailVisible"
|
||||
class="dialog-form-layout"
|
||||
title="证件详情"
|
||||
width="720px"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-loading="detailLoading" class="customer-cert-detail">
|
||||
<template v-if="detail">
|
||||
<div class="customer-cert-detail__tags">
|
||||
<el-tag size="small" :type="certKindTagType(detail.certType)">{{
|
||||
certKindLabel(detail.certType)
|
||||
}}</el-tag>
|
||||
<el-tag size="small" :type="statusTagType(detail.status)">{{
|
||||
certStatusLabel(detail.status)
|
||||
}}</el-tag>
|
||||
<el-tag v-if="detail.singleUseConsumed" size="small" type="danger"
|
||||
>已使用不可再用</el-tag
|
||||
>
|
||||
</div>
|
||||
<dl class="customer-cert-detail__kv">
|
||||
<div v-for="item in detailFields" :key="item.label">
|
||||
<dt>{{ item.label }}</dt>
|
||||
<dd>{{ item.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</template>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
||||
import { quotaCertApi, type CertType, type QuotaCert } from '@/api/pl/quota-cert'
|
||||
import { certKindLabel, certKindTagType } from '@/views/pl/quota-cert/cert-list-face'
|
||||
import { formatCertQty } from '@/views/pl/quota-cert/model'
|
||||
|
||||
type CustomerCertRow = QuotaCert & { certType: CertType }
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ customerId?: string | number | null; active?: boolean }>(),
|
||||
{ active: true }
|
||||
)
|
||||
|
||||
const dictStore = useDictStore()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const rows = ref<CustomerCertRow[]>([])
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<CustomerCertRow | null>(null)
|
||||
let loadEpoch = 0
|
||||
|
||||
const detailFields = computed(() => {
|
||||
const row = detail.value
|
||||
if (!row) return []
|
||||
return [
|
||||
{ label: '证号', value: row.certNo || '—' },
|
||||
{ label: '证面品名', value: row.productNameOnCert || '—' },
|
||||
{ label: '有效次数', value: row.validUseText || '—' },
|
||||
{ label: '核定', value: qtyText(row.totalQty, row.quantityUnit) },
|
||||
{ label: '预占', value: qtyText(row.reservedQty, row.quantityUnit) },
|
||||
{ label: '已用', value: qtyText(row.usedQty, row.quantityUnit) },
|
||||
{ label: '余量', value: qtyText(row.availableQty, row.quantityUnit) },
|
||||
{ label: '有效期起', value: row.validFrom || '—' },
|
||||
{ label: '有效期止', value: row.validTo || '—' },
|
||||
{ label: '状态', value: certStatusLabel(row.status) }
|
||||
]
|
||||
})
|
||||
|
||||
function unitLabel(unit?: string | null) {
|
||||
const label = getProductUnitLabel(dictStore.getLabel, unit)
|
||||
return !label || label === '—' ? '' : label
|
||||
}
|
||||
|
||||
function qtyText(value: unknown, unit?: string | null) {
|
||||
const text = formatCertQty(value)
|
||||
const name = unitLabel(unit)
|
||||
return name ? `${text} ${name}` : text
|
||||
}
|
||||
|
||||
function certStatusLabel(status?: string | null) {
|
||||
return (
|
||||
{
|
||||
draft: '待核对',
|
||||
active: '证件已启用',
|
||||
suspended: '证件已停用',
|
||||
closed: '证件已核销',
|
||||
void: '证件已作废'
|
||||
}[status || ''] ||
|
||||
status ||
|
||||
'—'
|
||||
)
|
||||
}
|
||||
|
||||
function statusTagType(status?: string | null) {
|
||||
if (status === 'active') return 'success'
|
||||
if (status === 'suspended') return 'warning'
|
||||
if (status === 'closed' || status === 'void') return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const customerId = props.customerId == null ? '' : String(props.customerId).trim()
|
||||
const epoch = ++loadEpoch
|
||||
if (!customerId) {
|
||||
rows.value = []
|
||||
error.value = ''
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [purchase, transport] = await Promise.all([
|
||||
quotaCertApi.list('purchase', { customerId }),
|
||||
quotaCertApi.list('transport', { customerId })
|
||||
])
|
||||
if (epoch !== loadEpoch) return
|
||||
rows.value = [
|
||||
...(purchase || []).map((item) => ({ ...item, certType: 'purchase' as const })),
|
||||
...(transport || []).map((item) => ({ ...item, certType: 'transport' as const }))
|
||||
].sort((a, b) => String(b.createTime || '').localeCompare(String(a.createTime || '')))
|
||||
} catch (e: any) {
|
||||
if (epoch !== loadEpoch) return
|
||||
rows.value = []
|
||||
error.value = e?.message || '加载客户证件失败'
|
||||
} finally {
|
||||
if (epoch === loadEpoch) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(row: CustomerCertRow) {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
detail.value = null
|
||||
try {
|
||||
const data = await quotaCertApi.get(row.certType, row.id)
|
||||
detail.value = { ...data, certType: row.certType }
|
||||
} catch (e: any) {
|
||||
detailVisible.value = false
|
||||
ElMessage.error(e?.message || '加载证件详情失败')
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.customerId, props.active],
|
||||
([, active]) => {
|
||||
if (active) void load()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.contract-quota-toolbar {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.contract-quota-toolbar__tip {
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.customer-cert-detail__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.customer-cert-detail__kv {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 16px;
|
||||
margin: 0;
|
||||
|
||||
div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-size: 12px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--mint-text, #0f172a);
|
||||
}
|
||||
}
|
||||
|
||||
.ml-1 {
|
||||
margin-left: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<section :class="embedded ? 'dialog-list-panel' : 'panel'">
|
||||
<div v-if="!embedded" class="panel-top"><h2 class="title-bar-heading">客户用证</h2></div>
|
||||
<div v-if="!customerId" class="list-toolbar">
|
||||
<div class="list-toolbar-row cert-list-toolbar-row">
|
||||
<ListQuickSearchInput
|
||||
v-model="keyword"
|
||||
placeholder="客户编号 / 名称"
|
||||
@search="search"
|
||||
@clear="search"
|
||||
/>
|
||||
<el-button v-if="keyword || hasActiveFilters" @click="reset"
|
||||
><el-icon><RefreshLeft /></el-icon>清除筛选</el-button
|
||||
>
|
||||
<ListToolbarEndActions
|
||||
:loading="loading"
|
||||
:column-defs="columns"
|
||||
v-model:visible-columns="visibleColumns"
|
||||
v-model:check-all="checkAll"
|
||||
:is-indeterminate="isIndeterminate"
|
||||
@refresh="load"
|
||||
@reset-columns="resetToDefault"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="customerId" class="list-toolbar">
|
||||
<div class="list-toolbar-row contract-quota-toolbar">
|
||||
<span class="contract-quota-toolbar__tip"
|
||||
>展示该客户全部购买证、运输证;额度按单张证件核算,多个合同共用共享余额。</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false"
|
||||
><el-button @click="allowed ? load() : init()"
|
||||
><el-icon><Refresh /></el-icon>重试</el-button
|
||||
></el-alert
|
||||
>
|
||||
<div v-if="customerId" v-loading="loading">
|
||||
<CustomerCertQuotaPanel
|
||||
v-if="fixedCustomerId"
|
||||
:customer-id="fixedCustomerId"
|
||||
:active="active && !loading"
|
||||
/>
|
||||
<el-empty
|
||||
v-else-if="!loading && !error"
|
||||
:description="allowed ? '客户不存在或无权查看' : '无证件查看权限'"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="list-body list-wrap">
|
||||
<div ref="tableWrapRef" class="table-scroll">
|
||||
<ListTable
|
||||
ref="tableRef"
|
||||
table-key="pl-customer-quota-statistics"
|
||||
:height="tableHeight"
|
||||
:data="rows"
|
||||
row-key="id"
|
||||
stripe
|
||||
class="list-table"
|
||||
v-loading="loading"
|
||||
@expand-change="expand"
|
||||
>
|
||||
<el-table-column type="expand">
|
||||
<template #default="{ row }">
|
||||
<div
|
||||
v-loading="details[row.id]?.loading"
|
||||
class="view-section statistics-expansion"
|
||||
:style="expansionStyle"
|
||||
>
|
||||
<el-alert
|
||||
v-if="details[row.id]?.error"
|
||||
title="详情加载失败"
|
||||
type="error"
|
||||
:closable="false"
|
||||
><el-button @click="loadDetail(row)"
|
||||
><el-icon><Refresh /></el-icon>重试</el-button
|
||||
></el-alert
|
||||
>
|
||||
<CustomerCertQuotaPanel
|
||||
v-else-if="details[row.id]?.ready"
|
||||
:customer-id="row.id"
|
||||
:active="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-for="column in visibleDefs"
|
||||
:key="column.prop"
|
||||
:prop="column.prop"
|
||||
:label="column.label"
|
||||
:min-width="column.type === 'text' ? 170 : 130"
|
||||
>
|
||||
<template #header>
|
||||
<div class="col-header-with-filter">
|
||||
<span class="col-header-with-filter__label">{{ column.label }}</span>
|
||||
<ColumnHeaderFilter
|
||||
:prop="column.prop"
|
||||
:label="column.label"
|
||||
:type="column.type"
|
||||
:enable-sort="false"
|
||||
:options="column.enumOptions || []"
|
||||
:model-value="filters[column.prop] || null"
|
||||
:is-active="isFilterActive(column.prop)"
|
||||
@update:model-value="(v) => setFilterState(column.prop, v)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<span>{{ row[column.prop] ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</ListTable>
|
||||
</div>
|
||||
<div ref="pagerRef" class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="20"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="load"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Refresh, RefreshLeft } from '@element-plus/icons-vue'
|
||||
import { fetchGetMenuCodes } from '@/api/auth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { quotaStatisticsApi } from '@/api/pl/quota-cert'
|
||||
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
|
||||
import { useServerColumnFilters } from '@/composables/useServerColumnFilters'
|
||||
import { useListTableViewportHeight } from '@/composables/useListTableViewportHeight'
|
||||
import type { ColumnFilterDef } from '@/composables/useClientColumnFilters'
|
||||
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
|
||||
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
|
||||
import ColumnHeaderFilter from '@/components/business/column-header-filter/index.vue'
|
||||
import CustomerCertQuotaPanel from './CustomerCertQuotaPanel.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ customerId?: string; embedded?: boolean; active?: boolean }>(),
|
||||
{ active: true }
|
||||
)
|
||||
|
||||
const user = useUserStore()
|
||||
const allowed = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const keyword = ref('')
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const rows = ref<Record<string, any>[]>([])
|
||||
const fixedCustomerId = ref('')
|
||||
const details = reactive<Record<string, { loading: boolean; error?: boolean; ready?: boolean }>>(
|
||||
{}
|
||||
)
|
||||
let epoch = 0
|
||||
|
||||
const text = (prop: string, label: string): ColumnFilterDef => ({
|
||||
prop,
|
||||
label,
|
||||
type: 'text',
|
||||
queryKey: prop
|
||||
})
|
||||
const number = (prop: string, label: string): ColumnFilterDef => ({
|
||||
prop,
|
||||
label,
|
||||
type: 'number',
|
||||
queryKey: prop
|
||||
})
|
||||
const columns: ColumnFilterDef[] = [
|
||||
text('customerNo', '客户编号'),
|
||||
text('customerName', '客户名称'),
|
||||
number('certCount', '关联证件数')
|
||||
]
|
||||
const {
|
||||
filters,
|
||||
hasActiveFilters,
|
||||
isFilterActive,
|
||||
setFilterState,
|
||||
clearAllFilters,
|
||||
toQueryParams,
|
||||
watchVisibleColumns
|
||||
} = useServerColumnFilters(columns, { onChange: search })
|
||||
const { visibleColumns, checkAll, isIndeterminate, resetToDefault } = usePersistedListColumns(
|
||||
'pl-customer-quota-columns-v1',
|
||||
columns
|
||||
)
|
||||
// usePersistedListColumns 不返回 visibleDefs;须本地按可见列过滤,否则只剩展开列
|
||||
const visibleDefs = computed(() =>
|
||||
columns.filter((column) => visibleColumns.value.includes(column.prop))
|
||||
)
|
||||
watchVisibleColumns(visibleColumns)
|
||||
const tableWrapRef = ref<HTMLElement | null>(null)
|
||||
const pagerRef = ref<HTMLElement | null>(null)
|
||||
const tableRef = ref()
|
||||
// 展开区只锁宽度,高度随证件明细撑开;表体高度须走 tableEl/pagerEl,否则卡在 240 下限裁切展开
|
||||
const { width: tableViewportWidth } = useElementSize(tableWrapRef)
|
||||
const expansionStyle = computed(() => ({
|
||||
width: tableViewportWidth.value > 0 ? `${tableViewportWidth.value}px` : undefined
|
||||
}))
|
||||
const { tableHeight, recalc: recalcTableHeight } = useListTableViewportHeight({
|
||||
tableEl: tableWrapRef,
|
||||
pagerEl: pagerRef,
|
||||
tableRef,
|
||||
enabled: computed(() => !props.embedded)
|
||||
})
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const p = await fetchGetMenuCodes()
|
||||
allowed.value =
|
||||
!!p?.all || !!p?.isSuperAdmin || (p?.menuCodes || []).includes('pl:quota-cert:list')
|
||||
if (allowed.value) await load()
|
||||
else error.value = '无证件查看权限'
|
||||
} catch {
|
||||
error.value = '权限加载失败'
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
page.value = 1
|
||||
void load()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
keyword.value = ''
|
||||
clearAllFilters()
|
||||
search()
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!props.active) return
|
||||
const current = ++epoch
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
if (props.customerId) {
|
||||
const data = await quotaStatisticsApi.customer(String(props.customerId))
|
||||
if (current !== epoch) return
|
||||
fixedCustomerId.value = data?.id || String(props.customerId)
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
} else {
|
||||
fixedCustomerId.value = ''
|
||||
const data = await quotaStatisticsApi.customers({
|
||||
page: page.value,
|
||||
pageSize: 20,
|
||||
keyword: keyword.value || undefined,
|
||||
...toQueryParams()
|
||||
})
|
||||
if (current !== epoch) return
|
||||
rows.value = data?.list || []
|
||||
total.value = data?.total || 0
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (current !== epoch) return
|
||||
error.value = e?.message || '加载失败'
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
fixedCustomerId.value = ''
|
||||
} finally {
|
||||
if (current === epoch) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function expand(row: { id: string }, expanded: unknown[]) {
|
||||
const open = Array.isArray(expanded)
|
||||
? expanded.some((item: any) => item?.id === row.id)
|
||||
: Boolean(expanded)
|
||||
if (open) void loadDetail(row)
|
||||
else nextTick(() => recalcTableHeight())
|
||||
}
|
||||
|
||||
async function loadDetail(row: { id: string }) {
|
||||
details[row.id] = { loading: true, error: false, ready: false }
|
||||
try {
|
||||
await quotaStatisticsApi.customer(row.id)
|
||||
details[row.id] = { loading: false, ready: true }
|
||||
} catch {
|
||||
details[row.id] = { loading: false, error: true }
|
||||
} finally {
|
||||
nextTick(() => {
|
||||
recalcTableHeight()
|
||||
const inst = tableRef.value
|
||||
if (inst && typeof inst.doLayout === 'function') inst.doLayout()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.customerId, props.active, JSON.stringify([user.accessToken, user.getUserInfo])],
|
||||
() => {
|
||||
if (props.active) void init()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.contract-quota-toolbar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.contract-quota-toolbar__tip {
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.statistics-expansion {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
padding: 12px 16px 16px;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
@@ -1656,7 +1656,7 @@ async function handleArchive(row: Plan) {
|
||||
ElMessage.warning('无运输计划归档操作权限')
|
||||
return
|
||||
}
|
||||
// 打开归档弹窗:已归档走调整;未归档/已暂存可直接打开(证书齐备仅在「确认归档」时校验,以便支持暂存)
|
||||
// 打开归档弹窗:已归档走调整;未归档/已暂存可直接打开(用证校验在「确认归档」时执行,以便支持暂存)
|
||||
archivePlanId.value = row.id
|
||||
archiveDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -666,7 +642,7 @@
|
||||
type ContractOption,
|
||||
type ScopeProduct
|
||||
} from '@/api/pl/quota-cert'
|
||||
import { fetchCustomerOptions } from '@/api/crm/customer'
|
||||
import { fetchCustomerDetail, fetchCustomerOptions } from '@/api/crm/customer'
|
||||
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
|
||||
import CertContractBinder from './CertContractBinder.vue'
|
||||
import {
|
||||
@@ -761,28 +737,32 @@
|
||||
|
||||
async function ensureCustomerOption(id?: string | null, nameHint = '') {
|
||||
if (!id) return
|
||||
if (customerOptions.value.some((c) => c.id === id)) return
|
||||
if (nameHint) {
|
||||
customerOptions.value = [{ id, customerName: nameHint }, ...customerOptions.value]
|
||||
return
|
||||
}
|
||||
let name = ''
|
||||
try {
|
||||
const result = await fetchCustomerOptions({ keyword: '', limit: 200 })
|
||||
const list = Array.isArray(result)
|
||||
? result
|
||||
: (result as any)?.list || (result as any)?.data || []
|
||||
const hit = (list || []).find((row: any) => String(row.id) === id)
|
||||
if (hit) {
|
||||
customerOptions.value = [
|
||||
{ id, customerName: String(hit.customerName || hit.name || id) },
|
||||
...customerOptions.value
|
||||
]
|
||||
} else {
|
||||
customerOptions.value = [{ id, customerName: id }, ...customerOptions.value]
|
||||
}
|
||||
const detail = (await fetchCustomerDetail({ id })) as Record<string, any>
|
||||
const data = detail?.data || detail
|
||||
name = String(data?.customerName || data?.name || '').trim()
|
||||
} catch {
|
||||
customerOptions.value = [{ id, customerName: id }, ...customerOptions.value]
|
||||
name = ''
|
||||
}
|
||||
if (!name) {
|
||||
try {
|
||||
const result = await fetchCustomerOptions({ keyword: nameHint || '', limit: 200 })
|
||||
const list = Array.isArray(result)
|
||||
? result
|
||||
: (result as any)?.list || (result as any)?.data || []
|
||||
const hit = (list || []).find((row: any) => String(row.id) === id)
|
||||
name = String(hit?.customerName || hit?.name || '').trim()
|
||||
} catch {
|
||||
name = ''
|
||||
}
|
||||
}
|
||||
// 系统客户名称以 CRM 为准;nameHint 仅作 CRM 不可用时的兜底(应为接口返回的 customerName)。
|
||||
const label = name || String(nameHint || '').trim() || id
|
||||
customerOptions.value = [
|
||||
{ id, customerName: label },
|
||||
...customerOptions.value.filter((c) => c.id !== id)
|
||||
]
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
@@ -1048,8 +1028,17 @@
|
||||
value: statusLabels[detail.value.status],
|
||||
tagType: tagType(detail.value.status)
|
||||
}
|
||||
const systemCustomerField: ViewField = {
|
||||
label: '系统客户',
|
||||
value: String(detail.value.customerName || '').trim() || '—',
|
||||
span: 'full'
|
||||
}
|
||||
const groups = certFormGroups(kind.value).map((group, index) =>
|
||||
detailGroup(group.title, group.fields, index === 0 ? [statusField] : [])
|
||||
detailGroup(
|
||||
group.title,
|
||||
group.fields,
|
||||
index === 0 ? [statusField, systemCustomerField] : []
|
||||
)
|
||||
)
|
||||
groups[groups.length - 1].fields.push({
|
||||
label: '系统备注',
|
||||
@@ -1424,7 +1413,7 @@
|
||||
applyCertForm(targetKind, cert)
|
||||
await ensureCustomerOption(
|
||||
form.value.customerId,
|
||||
formKind.value === 'purchase' ? form.value.buyerName : form.value.consigneeName
|
||||
String((cert as QuotaCert).customerName || '').trim()
|
||||
)
|
||||
if (targetKind === 'transport') purchases.value = purchaseOptions()
|
||||
} catch (error) {
|
||||
@@ -1805,12 +1794,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 {
|
||||
|
||||
@@ -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="客户品类额度生效时间">
|
||||
|
||||
Reference in new issue
Block a user