Compare commits

...
Author SHA1 Message Date
wuzilongandCursor ba62ec7b36 fix(pl): 证书齐备待完善列表将多选列置于首列。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 15:13:14 +08:00
wuzilong 579285a7bf merge: issue #34 归档运费运输量回填修复。
将任务分支合入本地 V3.0,供本机验收测试;未推送远端集成分支。
2026-11-15 15:03:44 +08:00
wuzilongandCursor f41e47601d fix(pl): 归档再次打开时按实际数量回填运费运输量。
resetFreightCalc 在产品已加载后会清空运输量且不再触发 watch,导致再次归档误报运输量须大于0。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 15:03:44 +08:00
wuzilongandCursor e0598ed07b fix(pl): 证件建档后自动启用,取消期初确认门槛。
保存即写入期初已用0并置为active;Web/H5仅保留复核入口,存量未确认证在读取或选证时自动补期初。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 14:53:43 +08:00
wuzilongandCursor 02d576abfe fix(pl): 证书齐备待完善归档不再误出齐备按钮。
归档结转无用量行时保留待完善错误文案,前端按计划齐备状态只提醒完善证件。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 14:51:45 +08:00
wuzilongandCursor 95ecb7236e fix(pl): 计划详情用关联证件附件替换危化品证书区。
Web 与 H5 详情将关联购买证/运输证移到装送货后并以附件卡片展示;用证接口回填证件附件 URL。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 14:46:46 +08:00
wuzilongandCursor ea46761f40 feat(pl): 合同用证改为客户用证并修复列表筛选。
证件额度按客户展示;Web 客户列表改用 toQueryParams,修复 buildQuery is not a function。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 14:46:46 +08:00
48 changed files with 2565 additions and 439 deletions

No files matched your search

+1 -1
View File
@@ -3807,7 +3807,7 @@ def archive_plan(
db.flush() db.flush()
result = get_plan(id, db, user) result = get_plan(id, db, user)
if not quota_settled: 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 return result
+25 -1
View File
@@ -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)) 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') @router.get('/statistics/certificates')
def certificate_statistics_list(keyword: str = Query('', max_length=200), def certificate_statistics_list(keyword: str = Query('', max_length=200),
cert_no: str = Query('', alias='certNo', 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)) 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') @router.get('/{cert_type}/{cert_id}/statistics')
def certificate_statistics(cert_type: CertType, cert_id: str, db: Session = Depends(get_db), def certificate_statistics(cert_type: CertType, cert_id: str, db: Session = Depends(get_db),
user: DbUserContext = Depends(current_user)): 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}') @router.get('/{cert_type}')
def list_certs(cert_type: CertType, keyword: Optional[str] = None, status: Optional[str] = None, 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)): 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'))]) @router.post('/{cert_type}/recognize', dependencies=[Depends(require_menu_code('pl:quota-cert:edit'))])
+16 -2
View File
@@ -171,26 +171,40 @@ def settle_plan_quota(db, user, plan, payload, *, adjustment=False):
except ArchiveQuotaBlocked as exc: except ArchiveQuotaBlocked as exc:
raise HTTPException(409, str(exc)) from exc raise HTTPException(409, str(exc)) from exc
except (HTTPException, ValueError, AllocationIncomplete) as 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() rows = db.query(PlPlanCertUsage).filter_by(tenant_no=user.tenant_no, plan_id=plan.id, del_flag=0).all()
for row in rows: for row in rows:
if not adjustment: if not adjustment:
row.usage_status = 'exception' row.usage_status = 'exception'
row.confirmation_snapshot = {**(row.confirmation_snapshot or {}), 'executed': True, row.confirmation_snapshot = {**(row.confirmation_snapshot or {}), 'executed': True,
'settlementError': str(getattr(exc, 'detail', exc))} 'settlementError': message}
row.update_by = user.id row.update_by = user.id
if not adjustment: if not adjustment:
plan.archive_status = 'exception' plan.archive_status = 'exception'
return False 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 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() rows = db.query(PlPlanCertUsage).filter_by(tenant_no=tenant_no, plan_id=plan_id, del_flag=0).all()
for row in rows: for row in rows:
message = (row.confirmation_snapshot or {}).get('settlementError') message = (row.confirmation_snapshot or {}).get('settlementError')
if isinstance(message, str) and message.strip(): if isinstance(message, str) and message.strip():
return 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 '实际数量已保存,证件没有扣减。请把实际数量改小,或先补充购买证和运输证,再重新归档。' return '实际数量已保存,证件没有扣减。请把实际数量改小,或先补充购买证和运输证,再重新归档。'
+195 -6
View File
@@ -9,6 +9,7 @@ from fastapi import HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from app.models.crm_models import CrmCustomer
from app.models.pl_quota_models import ( from app.models.pl_quota_models import (
PlPurchaseCert, PlTransportCert, PlTransportCertPurchaseRef, PlPurchaseCert, PlTransportCert, PlTransportCertPurchaseRef,
PlCertQtyLog, PlPlanCertUsage, PlContractCertLink, PlCertOperation, 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') 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): def _assert_known_usage_policy(row):
if usage_policy(row.valid_use_text) == 'needs_confirmation': if usage_policy(row.valid_use_text) == 'needs_confirmation':
raise HTTPException(400, '有效次数原文待确认,明确一次或多次有效后才能启用') raise HTTPException(400, '有效次数原文待确认,明确一次或多次有效后才能启用')
@@ -85,8 +119,28 @@ def _transport_derived_categories(db, user, transport_cert_id):
return list(dict.fromkeys(r.category_id for r in rows if r.category_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 get_cert(db, user, cert_type, cert_id, current=False): def get_cert(db, user, cert_type, cert_id, current=False):
row = _get_row(db, user, cert_type, cert_id, lock=current) row = _get_row(db, user, cert_type, cert_id, lock=current)
# 存量未期初确认的证件在读取时自动补期初0并启用(void/closed 除外)。
if not current:
row = ensure_zero_opening(db, user, cert_type, cert_id, row=row)
result = _row_to_dict(row) result = _row_to_dict(row)
result['usagePolicy'] = usage_policy(row.valid_use_text) result['usagePolicy'] = usage_policy(row.valid_use_text)
opening_query = db.query(PlCertQtyLog.id).filter_by( opening_query = db.query(PlCertQtyLog.id).filter_by(
@@ -96,17 +150,23 @@ def get_cert(db, user, cert_type, cert_id, current=False):
opening_query = opening_query.with_for_update() opening_query = opening_query.with_for_update()
result['openingConfirmed'] = opening_query.first() is not None result['openingConfirmed'] = opening_query.first() is not None
result['availableQty'] = row.total_qty - row.used_qty - row.reserved_qty 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 == 'transport': if cert_type == 'transport':
result['purchaseRefs'] = [_row_to_dict(r) for r in _refs(db, user, cert_id, lock=current)] result['purchaseRefs'] = [_row_to_dict(r) for r in _refs(db, user, cert_id, lock=current)]
result['referencesEditable'] = not _has_history(db, user, cert_type, cert_id, lock=current) result['referencesEditable'] = not _has_usage_beyond_zero_opening(
db, user, cert_type, cert_id, lock=current
)
result['derivedCategoryIds'] = _transport_derived_categories(db, user, cert_id) result['derivedCategoryIds'] = _transport_derived_categories(db, user, cert_id)
return result 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) model = _model(cert_type)
query = db.query(model).filter(model.tenant_no == user.tenant_no, model.del_flag == 0) 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) query = apply_pl_scope(query, model, user, db, user.tenant_no)
if customer_id:
query = query.filter(model.customer_id == customer_id)
if keyword: if keyword:
sale_name = model.seller_name if cert_type == 'purchase' else model.shipper_name 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 buy_name = model.buyer_name if cert_type == 'purchase' else model.consignee_name
@@ -169,8 +229,11 @@ def _refs_keep_verified(pending, removed):
def _has_history(db, user, cert_type, cert_id, lock=True): def _has_history(db, user, cert_type, cert_id, lock=True):
"""任意额度流水或计划占用均视为已有历史(含自动期初),用于锁证号/核定量等身份。"""
for model in (PlCertQtyLog, PlPlanCertUsage): 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: if lock:
query = query.with_for_update() query = query.with_for_update()
if query.first() is not None: if query.first() is not None:
@@ -178,6 +241,35 @@ def _has_history(db, user, cert_type, cert_id, lock=True):
return False 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): def _validate_refs(db, user, cert_id, submitted, historical, purchases, current=False):
if submitted is None: if submitted is None:
raise HTTPException(400, '引用集合不可为null;清空请提交[]') raise HTTPException(400, '引用集合不可为null;清空请提交[]')
@@ -282,7 +374,8 @@ def prepare_cert(db, user, cert_type, data, cert_id=None, *, locked=None, effect
if duplicate and duplicate.id != cert_id: if duplicate and duplicate.id != cert_id:
raise HTTPException(400, '证号已存在,删除后也不得重建额度池') raise HTTPException(400, '证号已存在,删除后也不得重建额度池')
effective_id = cert_id or effective_id or str(uuid4()) 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} 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) 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, return dict(model=model, row=row, values=values, cert_id=cert_id, effective_id=effective_id,
@@ -329,19 +422,98 @@ def apply_cert(db, user, prepared):
return row 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): 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)) 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) return get_cert(db, user, cert_type, row.id)
def delete_cert(db, user, cert_type, cert_id): def delete_cert(db, user, cert_type, cert_id):
row = _get_row(db,user,cert_type,cert_id,lock=True) 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, '已有用量或流水的证件只能停用、核销或作废') 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(): 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, '证件已绑定合同') 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(): 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, '证件被运输证引用') raise HTTPException(400, '证件被运输证引用')
for log in logs:
log.del_flag = 1
log.update_by = user.id
row.del_flag = 1 row.del_flag = 1
row.update_by = user.id row.update_by = user.id
if cert_type == 'transport': if cert_type == 'transport':
@@ -388,7 +560,24 @@ def _confirm_cert(db, user, cert_type, cert_id, data):
_assert_known_usage_policy(row) _assert_known_usage_policy(row)
if row.status == 'void': raise HTTPException(400, '作废证件不能启用') if row.status == 'void': raise HTTPException(400, '作废证件不能启用')
if row.status == 'closed': 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, '期初已确认,不能重复初始化额度') raise HTTPException(400, '期初已确认,不能重复初始化额度')
if body.used_qty > row.total_qty: if body.used_qty > row.total_qty:
raise HTTPException(400, '期初已用量不能超过核定量') raise HTTPException(400, '期初已用量不能超过核定量')
@@ -12,6 +12,7 @@ from app.models.pl_quota_models import (
) )
from app.schemas.pl.quota_planning import ManualQuantity from app.schemas.pl.quota_planning import ManualQuantity
from app.services.pl_cert_category_service import category_ids_for_product 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 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: def _vehicle_ok(cert, vehicle_no: str) -> bool:
plates = cert.vehicle_numbers_json or [] plates = cert.vehicle_numbers_json or []
if not isinstance(plates, list): if not isinstance(plates, list):
@@ -116,11 +127,12 @@ def select_whole_cover_certs(db, user, sources, *, vehicle_no: str, on_date: dat
for purchase in candidates: for purchase in candidates:
if not purchase.verified_by or not purchase.verified_time: if not purchase.verified_by or not purchase.verified_time:
continue continue
_ensure_opening_quiet(db, user, 'purchase', purchase.id, purchase)
if not _has_opening(db, user, 'purchase', purchase.id): if not _has_opening(db, user, 'purchase', purchase.id):
continue continue
if not _in_force(purchase, on_date): if not _in_force(purchase, on_date):
continue continue
if purchase.opening_single_use_consumed: if is_single_use_consumed(db, user, 'purchase', purchase):
continue continue
try: try:
need = _permit_qty_for(Decimal(source.quantity), source.unit, purchase.quantity_unit) need = _permit_qty_for(Decimal(source.quantity), source.unit, purchase.quantity_unit)
@@ -165,11 +177,12 @@ def select_whole_cover_certs(db, user, sources, *, vehicle_no: str, on_date: dat
for transport in transports: for transport in transports:
if not transport.verified_by or not transport.verified_time: if not transport.verified_by or not transport.verified_time:
continue continue
_ensure_opening_quiet(db, user, 'transport', transport.id, transport)
if not _has_opening(db, user, 'transport', transport.id): if not _has_opening(db, user, 'transport', transport.id):
continue continue
if not _in_force(transport, on_date): if not _in_force(transport, on_date):
continue continue
if transport.opening_single_use_consumed: if is_single_use_consumed(db, user, 'transport', transport):
continue continue
if not _vehicle_ok(transport, vehicle_no): if not _vehicle_ok(transport, vehicle_no):
continue continue
+6 -2
View File
@@ -351,7 +351,11 @@ def get_plan_quota(db, user, plan_id):
), ),
'usages': []} 'usages': []}
for usage in 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() 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 return result
+107 -2
View File
@@ -4,12 +4,12 @@ from decimal import Decimal
from sqlalchemy import and_, or_, literal, func from sqlalchemy import and_, or_, literal, func
from fastapi import HTTPException from fastapi import HTTPException
from app.crud.crm.data_scope_helpers import apply_crm_owner_scope 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_models import PlPlan
from app.models.pl_quota_models import (PlContractCertLink as Link, PlContractCertProduct as Scope, from app.models.pl_quota_models import (PlContractCertLink as Link, PlContractCertProduct as Scope,
PlPlanCertUsage as Usage, PlPlanCertUsageItem as Item, PlCertQtyLog as Log) PlPlanCertUsage as Usage, PlPlanCertUsageItem as Item, PlCertQtyLog as Log)
from app.services.pl_data_scope import apply_pl_scope 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') ZERO = Decimal('0')
CONTRACT_TYPES = {'sale', 'purchase', 'framework_sale', 'framework_purchase', 'framework'} 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) 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): def _certificates(db, user, keyword=None, cert_type=None, cert_no=None, cert_types=None):
queries = [] queries = []
for kind, model in MODELS.items(): for kind, model in MODELS.items():
@@ -351,6 +384,78 @@ def contract_statistics(db, user, contract_id):
return {**_contract_summary(contract), 'certificates': result} 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): def certificate_statistics(db, user, cert_type, cert_id):
_get_row(db, user, cert_type, cert_id) _get_row(db, user, cert_type, cert_id)
certs = _certificates(db, user, cert_type=cert_type) certs = _certificates(db, user, cert_type=cert_type)
+9 -14
View File
@@ -16,7 +16,7 @@ from app.models.pl_models import PlPlan
from app.models.crm_models import CrmContractProduct from app.models.crm_models import CrmContractProduct
from app.schemas.pl.quota_usage import UsageManual, UsageRequest 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_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_contract_service import _contract
from app.services.pl_quota_allocation import (SourceDemand, PermitCandidate, ManualAllocation, Allocation, from app.services.pl_quota_allocation import (SourceDemand, PermitCandidate, ManualAllocation, Allocation,
allocate_plan, settle_allocations, validate_allocation_pairing, InsufficientQuota) allocate_plan, settle_allocations, validate_allocation_pairing, InsufficientQuota)
@@ -200,8 +200,10 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
skipped_for_vehicle=False skipped_for_vehicle=False
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_uses_customer_category
customer_rule = plan_uses_customer_category(plan) customer_rule = plan_uses_customer_category(plan)
from app.services.pl_quota_cert_service import ensure_zero_opening
for k in sorted(selected): for k in sorted(selected):
kind,pid=k; c=certs[k] 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 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=='closed': raise ValueError('已核销证件不能再安排用证')
if c.status!='active' or not c.verified_time or not c.verified_by: raise ValueError('证件尚未启用复核') if c.status!='active' or not c.verified_time or not c.verified_by: raise ValueError('证件尚未启用复核')
@@ -213,10 +215,8 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
if kind=='transport' and (not r.vehicle_no or r.vehicle_no not in (c.vehicle_numbers_json or [])): if kind=='transport' and (not r.vehicle_no or r.vehicle_no not in (c.vehicle_numbers_json or [])):
skipped_for_vehicle=True skipped_for_vehicle=True
continue continue
if policy=='once': if policy=='once' and is_single_use_consumed(db, user, kind, c, exclude_plan_id=plan.id):
previous=query(m.PlPlanCertUsage,cert_type=kind,cert_id=pid,del_flag=0).all() raise ValueError('一次有效证件已使用或已占用')
if c.opening_single_use_consumed or any(u.plan_id!=plan.id and (u.usage_status in ('reserved','in_transit','settled','exception') or (u.confirmation_snapshot or {}).get('executed')) for u in previous):
raise ValueError('一次有效证件已使用或已占用')
eligible=set() eligible=set()
if customer_rule: if customer_rule:
# 新规则已由客户+品类整笔选证;账本不再依赖合同绑证 scope。 # 新规则已由客户+品类整笔选证;账本不再依赖合同绑证 scope。
@@ -370,6 +370,8 @@ ARCHIVE_QUOTA_BLOCK = '合同绑定的购买证和运输证剩余额度不够覆
def _settlement_cover_cert(db, user, r, plan, kind, cert): def _settlement_cover_cert(db, user, r, plan, kind, cert):
"""合同上另有证件时,不合格的证跳过,不因此挡住其余还能扣的证。""" """合同上另有证件时,不合格的证跳过,不因此挡住其余还能扣的证。"""
from app.services.pl_quota_cert_service import ensure_zero_opening
cert = ensure_zero_opening(db, user, kind, cert.id, row=cert)
if not _query(db, user, m.PlCertQtyLog, cert_type=kind, cert_id=cert.id, event_type='opening', del_flag=0).first(): if not _query(db, user, m.PlCertQtyLog, cert_type=kind, cert_id=cert.id, event_type='opening', del_flag=0).first():
return False return False
if cert.status != 'active' or not cert.verified_time or not cert.verified_by: if cert.status != 'active' or not cert.verified_time or not cert.verified_by:
@@ -381,15 +383,8 @@ def _settlement_cover_cert(db, user, r, plan, kind, cert):
return False return False
if kind == 'transport' and (not r.vehicle_no or r.vehicle_no not in (cert.vehicle_numbers_json or [])): if kind == 'transport' and (not r.vehicle_no or r.vehicle_no not in (cert.vehicle_numbers_json or [])):
return False return False
if policy == 'once': if policy == 'once' and is_single_use_consumed(db, user, kind, cert, exclude_plan_id=plan.id):
previous = _query(db, user, m.PlPlanCertUsage, cert_type=kind, cert_id=cert.id, del_flag=0).all() return False
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 True
@@ -61,6 +61,47 @@ def test_apply_plan_quota_new_rule_waive_skips_reserve_not_settle(monkeypatch):
assert '待完善' in str(exc.value.detail) 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): def test_list_certs_default_excludes_closed_void(monkeypatch):
from app.services import pl_quota_cert_service as svc from app.services import pl_quota_cert_service as svc
@@ -463,3 +504,251 @@ def test_candidate_context_customer_rule_skips_contract_scope(monkeypatch):
assert 'delivery:sp1' in sources assert 'delivery:sp1' in sources
assert scopes[(('purchase', 'buy1'), 'delivery:sp1')] == (None, None) assert scopes[(('purchase', 'buy1'), 'delivery:sp1')] == (None, None)
assert candidates['purchase'][0].product_ids == frozenset({'delivery:sp1'}) 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'
+3 -2
View File
@@ -57,7 +57,7 @@ def test_permission_guards_validation_and_atomic_creation(api):
saved = client.post(path, json=payload()) saved = client.post(path, json=payload())
assert saved.status_code == 200 assert saved.status_code == 200
row = saved.json() 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.put(path + '/' + row['id'], json={'totalQty': '-1'}).status_code == 422
assert client.get(path + '/' + row['id']).json()['totalQty'] == '30.000000' assert client.get(path + '/' + row['id']).json()['totalQty'] == '30.000000'
app.dependency_overrides[auth] = lambda: {'role': 'admin', 'user_id': 'u', 'tenant_no': 'other'} 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 response.status_code == 200
assert client.post(path, json=body).status_code == 200 assert client.post(path, json=body).status_code == 200
logs = client.get(base + '/' + row['id'] + '/logs').json() 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,'totalQty':'40'}).status_code == 409
assert client.post(path, json={**body,'operationId':'adjust-2','sourceNote':''}).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' assert client.get(base + '/' + row['id']).json()['totalQty'] == '35.000000'
+39 -17
View File
@@ -41,14 +41,31 @@ def transport(**kw):
return data 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): def test_manual_optional_image_complete_fields(ctx):
db,u,s,m = 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') 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) row = s.save_cert(db,u,'purchase',data)
assert row['buyerAddress'] == '地址' and row['attachmentUrls'] is None assert row['buyerAddress'] == '地址' and row['attachmentUrls'] is None
assert row['totalQty'] == Decimal('100.123456') assert row['totalQty'] == Decimal('100.123456')
assert row['status'] == 'draft' assert row['status'] == 'active'
assert db.query(m.PlCertQtyLog).count() == 0 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() db.rollback()
assert db.query(m.PlPurchaseCert).count() == 0 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): def test_opening_confirmation_remains_visible_after_reverification_required(ctx):
db, user, svc, models = ctx db, user, svc, models = ctx
row = svc.save_cert(db, user, 'purchase', purchase()) row = svc.save_cert(db, user, 'purchase', purchase())
assert row['openingConfirmed'] is False assert row['openingConfirmed'] is True
row = svc.confirm_cert(db, user, 'purchase', row['id'], { assert row['status'] == 'active'
'operationId': 'op-ui', 'usedQty': '0', 'singleUseConsumed': False, 'reason': '全新证'})
changed = svc.save_cert(db, user, 'purchase', {'buyerAddress': '更正地址'}, row['id']) changed = svc.save_cert(db, user, 'purchase', {'buyerAddress': '更正地址'}, row['id'])
assert changed['verifiedTime'] is None assert changed['verifiedTime'] is None
assert changed['openingConfirmed'] is True 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): def test_confirmation_and_history_protection(ctx):
db,u,s,m=ctx db,u,s,m=ctx
row=s.save_cert(db,u,'purchase',purchase()) 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'}) 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'}) 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 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'}): 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.save_cert(db,u,'purchase',data,row['id'])
with pytest.raises(HTTPException): s.delete_cert(db,u,'purchase',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') db.query(m.PlPurchaseCert).one().used_qty=Decimal('5')
retry=s.confirm_cert(db,u,'purchase',row['id'],opening()) retry=s.confirm_cert(db,u,'purchase',row['id'],opening())
assert retry['usedQty']==Decimal('5') assert retry['usedQty']==Decimal('5')
assert db.query(m.PlCertQtyLog).count()==1 assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).count()==1
assert db.query(m.PlCertOperation).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(usedQty='7'))
with pytest.raises(HTTPException): s.confirm_cert(db,u,'purchase',row['id'],opening(operationId='second')) 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 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'}) 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 result['status']=='active' and result['usedQty']==Decimal('5')
assert db.query(m.PlCertQtyLog).count()==1 assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).count()==1
assert db.query(m.PlCertOperation).count()==2 # 自动期初 + 手工期初 + 复核
assert db.query(m.PlCertOperation).count()==3
result=s.verify_cert(db,u,'purchase',row['id'],{'operationId':'verify-1','reason':'地址更正核对','evidenceUrls':'https://minio/evidence'}) 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): 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()) 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页,经张三核实'}) 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 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): 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) result=fn(db,u,'purchase',p['id'],body)
assert missed and result['id']==p['id'] assert missed and result['id']==p['id']
assert db.query(m.PlCertOperation).count()==count 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): 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()) p=s.save_cert(db,u,'purchase',purchase())
row=s.confirm_cert(db,u,'purchase',p['id'],{**opening(),'reason':' '}) row=s.confirm_cert(db,u,'purchase',p['id'],{**opening(),'reason':' '})
assert row['status']=='active' 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']) s.save_cert(db,u,'purchase',{'buyerAddress':'更正地址'},p['id'])
verified=s.verify_cert(db,u,'purchase',p['id'],{'operationId':'verify-blank','reason':''}) verified=s.verify_cert(db,u,'purchase',p['id'],{'operationId':'verify-blank','reason':''})
assert verified['status']=='active' and verified['verifiedTime'] 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): def test_rejected_opening_rolls_back_claimed_operation(ctx):
db,u,s,m=ctx db,u,s,m=ctx
p=s.save_cert(db,u,'purchase',purchase()) 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): with pytest.raises(HTTPException):
s.confirm_cert(db,u,'purchase',p['id'],{**opening(),'usedQty':'999'}) s.confirm_cert(db,u,'purchase',p['id'],{**opening(),'usedQty':'999'})
assert db.query(m.PlCertOperation).count()==0 assert db.query(m.PlCertOperation).count()==auto_ops
assert db.query(m.PlCertQtyLog).count()==0 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): 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'] used = confirmed['usedQty']
closed = s.save_cert(db, u, 'purchase', {'status': 'closed'}, row['id']) closed = s.save_cert(db, u, 'purchase', {'status': 'closed'}, row['id'])
assert closed['status'] == 'closed' and closed['usedQty'] == used 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 ( for action, payload in (
('confirm', {**opening(), 'operationId': 'reopen-opening'}), ('confirm', {**opening(), 'operationId': 'reopen-opening'}),
('verify', {'operationId': 'reopen-verify', 'reason': '重新启用'}), ('verify', {'operationId': 'reopen-verify', 'reason': '重新启用'}),
+36
View File
@@ -317,3 +317,39 @@ def test_missing_contract_is_empty_binding_not_unreadable(case, contract_id):
assert rows[0]['bindingsReadable'] is True assert rows[0]['bindingsReadable'] is True
assert rows[0]['certificates'] == [] assert rows[0]['certificates'] == []
assert rows[1]['bindingsReadable'] is False 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)
@@ -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,47 @@
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 legacy quota block still offers certificate ready', () => {
assert.equal(
resolveArchiveQuotaBlockMode(
'实际数量已保存,证件没有扣减。请把实际数量改小,或先补充购买证和运输证,再重新归档。',
'用证没有完成',
{ quotaCertPendingFulfill: 0, quotaCertWaived: 0 }
),
'confirm-ready'
)
})
test('non quota-title blocks stay plain alert', () => {
assert.equal(resolveArchiveQuotaBlockMode('不能归档:缺证', '不能归档', {}), 'alert-plain')
})
@@ -25,6 +25,20 @@ test('incomplete certificate filter is sent as quotaCertWaivedIn=0', () => {
assert.deepEqual(params, { 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', () => { 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 web = readFileSync(new URL('../src/views/pl/cert-ready/index.vue', import.meta.url), 'utf8')
const mobile = readFileSync( const mobile = readFileSync(
+17 -4
View File
@@ -203,13 +203,14 @@ test('real template keeps plans quota proof order and separate lazy contract sco
canViewQuotaCert: true, canViewQuotaCert: true,
canViewProof: true, canViewProof: true,
detailActiveTab: 'proof', detailActiveTab: 'proof',
detailContract: { id: 42 } detailContract: { id: 42, customerId: 'cust-42' }
} }
const rows = panes(ctx) const rows = panes(ctx)
assert.deepEqual( assert.deepEqual(
rows.map((v: any) => v.props.name), rows.map((v: any) => v.props.name),
['plans', 'quota', 'proof'] ['plans', 'quota', 'proof']
) )
assert.equal(rows[1].props.label, '客户证件与额度')
assert.equal(rows[2].props.label, '运输计划合法使用证明') assert.equal(rows[2].props.label, '运输计划合法使用证明')
assert.equal(rows[2].props.lazy, '') assert.equal(rows[2].props.lazy, '')
assert.equal(panel(rows[1]), undefined) 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.active, true)
assert.equal(proof.props.embedded, '') assert.equal(proof.props.embedded, '')
ctx.detailContract.id = 43 ctx.detailContract.id = 43
ctx.detailContract.customerId = 'cust-43'
ctx.currentDetailId = 43 ctx.currentDetailId = 43
assert.equal(panel(panes(ctx)[2]).key, 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 ctx.canViewQuotaCert = false
assert.deepEqual( assert.deepEqual(
panes(ctx).map((v: any) => v.props.name), panes(ctx).map((v: any) => v.props.name),
['plans', 'proof'] ['plans', 'proof']
) )
ctx.detailActiveTab = 'quota'
assert.equal(panel(panes(ctx)[1]), undefined) 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'), 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({ PlContractLinkedPlansPanel: vue.defineComponent({
setup() { setup() {
return () => vue.h('span') return () => vue.h('span')
@@ -282,7 +295,7 @@ test('mounted template unmounts old contract and never mounts quota for proof-on
canViewQuotaCert: false, canViewQuotaCert: false,
canViewProof: true, canViewProof: true,
detailActiveTab: 'proof', detailActiveTab: 'proof',
detailContract: { id: 'A' } detailContract: { id: 'A', customerId: 'cust-A' }
}) })
const renderer = vue.createRenderer<any, any>({ const renderer = vue.createRenderer<any, any>({
createElement: () => ({ children: [] }), createElement: () => ({ children: [] }),
@@ -0,0 +1,75 @@
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('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/)
})
+10 -4
View File
@@ -7,7 +7,11 @@ const readyTab = readFileSync(
'utf8' 'utf8'
) )
const contractTab = readFileSync( 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' 'utf8'
) )
const archiveForm = readFileSync( const archiveForm = readFileSync(
@@ -30,9 +34,11 @@ test('H5 证书齐备对齐 2.0:待完善列表与完善并关闭,且文案
assert.doesNotMatch(readyTab, /也不扣减额度/) assert.doesNotMatch(readyTab, /也不扣减额度/)
}) })
test('H5 合同用证页对新规则给出只读提示', () => { test('H5 客户用证按客户列表展示证件', () => {
assert.match(contractTab, /客户\+证用品类/) assert.match(contractTab, /quotaStatisticsApi\.customers/)
assert.match(contractTab, /仅供历史查看/) assert.match(contractTab, /\/m\/pl\/plan-cert\/customer\//)
assert.match(contractTab, /暂无客户用证数据/)
assert.match(planCertIndex, /title: '客户用证'/)
}) })
test('H5 建档页使用关联购买证且无证面引用残留', () => { 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/)
})
+61 -66
View File
@@ -105,6 +105,9 @@ function harness(fixed?: string) {
useServerColumnFilters, useServerColumnFilters,
createStatisticsLoader, createStatisticsLoader,
quotaStatisticsApi: api, quotaStatisticsApi: api,
certQuotaRuleApi: {
get: async () => ({ configured: false })
},
onBeforeUnmount: (fn: Function) => unmount.push(fn) onBeforeUnmount: (fn: Function) => unmount.push(fn)
} }
const src = ts.createSourceFile( 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.equal(legal.meta!.title, '合法使用证明')
assert.deepEqual( assert.deepEqual(
group.children!.filter((r) => !r.meta?.isHide).map((r) => r.path), 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![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( assert.deepEqual(
group.children!.filter((r) => !r.meta?.isHide).map((r) => r.meta!.menuCode), 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:quota-cert:list', 'pl:quota-cert:list',
'pl:quota-cert:list',
'pl:plan-cert:list', 'pl:plan-cert:list',
'pl:cert-ready: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)) return new Function(...Object.keys(env), `${code}; return component`)(...Object.values(env))
} }
for (const wrapper of [ for (const active of [undefined, false])
'contract-quota/ContractQuotaPanel.vue', test(`compiled certificate-usage/CertificateUsagePanel.vue ${active === false ? 'inactive skips requests' : 'defaults to active and loads'}`, async () => {
'certificate-usage/CertificateUsagePanel.vue' const calls: string[] = []
]) { const dependencies = {
for (const active of [undefined, false]) useUserStore: () => vue.reactive({ accessToken: 'token', getUserInfo: { tenantNo: 'a' } }),
test(`compiled ${wrapper} ${active === false ? 'inactive skips requests' : 'defaults to active and loads'}`, async () => { useRouter: () => ({ push() {}, replace() {} }),
const calls: string[] = [] fetchGetMenuCodes: async () => {
const dependencies = { calls.push('permissions')
useUserStore: () => vue.reactive({ accessToken: 'token', getUserInfo: { tenantNo: 'a' } }), return { all: true }
useRouter: () => ({ push() {}, replace() {} }), },
fetchGetMenuCodes: async () => { quotaStatisticsApi: {
calls.push('permissions') certificates: async () => {
return { all: true } calls.push('certificates')
}, return { list: [], total: 0 }
quotaStatisticsApi: { }
contracts: async () => { },
calls.push('contracts') usePersistedListColumns,
return { list: [], total: 0 } useServerColumnFilters,
}, createStatisticsLoader,
certificates: async () => { useElementSize: () => ({ width: vue.ref(1120) }),
calls.push('certificates') useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }),
return { list: [], total: 0 } statusLabel: statisticsLabels.statusLabel,
} contractTypeLabel: statisticsLabels.contractTypeLabel
}, }
usePersistedListColumns, const StatisticsPanel = compiledSfc(
useServerColumnFilters, '../src/views/pl/quota-statistics/StatisticsPanel.vue',
createStatisticsLoader, dependencies,
useElementSize: () => ({ width: vue.ref(1120) }), false
useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }), )
statusLabel: statisticsLabels.statusLabel, StatisticsPanel.render = () => null
contractTypeLabel: statisticsLabels.contractTypeLabel const Wrapper = compiledSfc('../src/views/pl/certificate-usage/CertificateUsagePanel.vue', {
} StatisticsPanel
// 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()
}) })
} 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', () => { test('expanded content follows the table viewport width and keeps alignment local', () => {
const h = harness() const h = harness()
+1 -1
View File
@@ -111,7 +111,7 @@
const panelTitleClassList = [...panelTitleIconByText.map(([, cls]) => cls), 'auto-title-list'] const panelTitleClassList = [...panelTitleIconByText.map(([, cls]) => cls), 'auto-title-list']
const panelTitleBarClass = 'title-bar-heading' const panelTitleBarClass = 'title-bar-heading'
const panelTitleBarTextRules = [/^(购买证与运输证|合法使用证明|计划用证|证件建档|证用品类|合同用证|证件用量|计划段次用证|证书齐备|合同证件与额度|运输证件规则)$/] const panelTitleBarTextRules = [/^(购买证与运输证|合法使用证明|计划用证|证件建档|证用品类|合同用证|客户用证|证件用量|计划段次用证|证书齐备|合同证件与额度|客户证件与额度|运输证件规则)$/]
const panelTitleHeadingSelector = 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' '.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'
+7
View File
@@ -624,6 +624,13 @@ export interface Plan {
hazardCertReady?: number hazardCertReady?: number
hazardCertReadyBy?: string hazardCertReadyBy?: string
hazardCertReadyTime?: string hazardCertReadyTime?: string
/** 证书齐备:新规则仅放行安排 */
quotaCertWaived?: number
quotaCertWaivedBy?: string
quotaCertWaivedByName?: string
quotaCertWaivedTime?: string
/** 新规则证书齐备待完善 */
quotaCertPendingFulfill?: number
erpEntered?: number erpEntered?: number
remark?: string remark?: string
createBy?: string createBy?: string
+28 -2
View File
@@ -61,6 +61,7 @@ export interface QuotaCert extends Omit<CertBody, 'status'> {
verifiedTime?: string | null verifiedTime?: string | null
openingConfirmed: boolean openingConfirmed: boolean
usagePolicy: 'once' | 'multiple' | 'needs_confirmation' usagePolicy: 'once' | 'multiple' | 'needs_confirmation'
singleUseConsumed?: boolean
createTime?: string createTime?: string
certType?: CertType certType?: CertType
derivedCategoryIds?: string[] derivedCategoryIds?: string[]
@@ -132,8 +133,10 @@ export interface LegalDocument {
} }
const base = '/transport/quota-certs' const base = '/transport/quota-certs'
export const quotaCertApi = { export const quotaCertApi = {
list: (kind: CertType, params: { keyword?: string; status?: string } = {}) => list: (
request.get(`${base}/${kind}`, { params }).then(pickBxData<QuotaCert[]>), kind: CertType,
params: { keyword?: string; status?: string; customerId?: string } = {}
) => request.get(`${base}/${kind}`, { params }).then(pickBxData<QuotaCert[]>),
get: (kind: CertType, id: string) => get: (kind: CertType, id: string) =>
request.get(`${base}/${kind}/${id}`).then(pickBxData<QuotaCert>), request.get(`${base}/${kind}/${id}`).then(pickBxData<QuotaCert>),
save: (kind: CertType, body: CertBody, id?: string) => save: (kind: CertType, body: CertBody, id?: string) =>
@@ -504,6 +507,13 @@ export interface StatisticsPage<T> {
export interface ContractStatisticsSummary extends ContractOption { export interface ContractStatisticsSummary extends ContractOption {
certCount: number certCount: number
} }
export interface CustomerStatisticsSummary {
id: string
customerId: string
customerNo: string
customerName: string
certCount: number
}
export interface CertificateStatisticsSummary { export interface CertificateStatisticsSummary {
id: string id: string
certId: string certId: string
@@ -522,6 +532,14 @@ export interface CertificateStatisticsSummary {
buyPartyName?: string | null buyPartyName?: string | null
validUseText?: string | null validUseText?: string | null
} }
export interface CustomerStatistics extends CustomerStatisticsSummary {
certificates: (CertificateStatisticsSummary & {
usagePolicy?: QuotaCert['usagePolicy']
singleUseConsumed?: boolean
openingConfirmed?: boolean
createTime?: string
})[]
}
export interface StatisticsProduct { export interface StatisticsProduct {
id: string id: string
contractProductId: string contractProductId: string
@@ -579,6 +597,8 @@ export interface StatisticsQuery {
contractNo?: string contractNo?: string
contractName?: string contractName?: string
contractTypeIn?: string contractTypeIn?: string
customerNo?: string
customerName?: string
certCountOp?: string certCountOp?: string
certCount?: string certCount?: string
certCountMin?: string certCountMin?: string
@@ -622,12 +642,18 @@ export const quotaStatisticsApi = {
request request
.get(`${base}/statistics/contracts`, { params }) .get(`${base}/statistics/contracts`, { params })
.then(pickBxData<StatisticsPage<ContractStatisticsSummary>>), .then(pickBxData<StatisticsPage<ContractStatisticsSummary>>),
customers: (params: StatisticsQuery) =>
request
.get(`${base}/statistics/customers`, { params })
.then(pickBxData<StatisticsPage<CustomerStatisticsSummary>>),
certificates: (params: StatisticsQuery) => certificates: (params: StatisticsQuery) =>
request request
.get(`${base}/statistics/certificates`, { params }) .get(`${base}/statistics/certificates`, { params })
.then(pickBxData<StatisticsPage<CertificateStatisticsSummary>>), .then(pickBxData<StatisticsPage<CertificateStatisticsSummary>>),
contract: (id: string) => contract: (id: string) =>
request.get(`${base}/contracts/${id}/statistics`).then(pickBxData<ContractStatistics>), 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) => certificate: (kind: CertType, id: string) =>
request.get(`${base}/${kind}/${id}/statistics`).then(pickBxData<CertificateStatistics>) request.get(`${base}/${kind}/${id}/statistics`).then(pickBxData<CertificateStatistics>)
} }
@@ -299,6 +299,7 @@
extractArchiveLineOverageDetail, extractArchiveLineOverageDetail,
showArchiveLineOverageConfirm showArchiveLineOverageConfirm
} from '@/utils/pl-contract-qty-reminder' } from '@/utils/pl-contract-qty-reminder'
import { resolveArchiveQuotaBlockMode } from '@/utils/pl-archive-quota-block'
const props = defineProps<{ const props = defineProps<{
visible: boolean visible: boolean
@@ -1602,12 +1603,12 @@
async function showArchiveQuotaBlockDialog(message: string, title = '不能归档') { async function showArchiveQuotaBlockDialog(message: string, title = '不能归档') {
const html = `<p class="pl-arrange-block-text">${escapeArchiveBlockText(message)}</p>` const html = `<p class="pl-arrange-block-text">${escapeArchiveBlockText(message)}</p>`
const pendingFulfill = const mode = resolveArchiveQuotaBlockMode(message, title, plan.value)
message.includes('待完善') || message.includes('完善并关闭') || message.includes('证书齐备待完善') if (mode !== 'confirm-ready') {
if (title !== '用证没有完成' || pendingFulfill) { const guide =
const guide = pendingFulfill mode === 'alert-pending'
? `${html}<p class="pl-arrange-block-text">新规则下证书齐备只放行安排,不能绕过归档挂证。请到「证书齐备 → 已标记待完善证书」上传证件并执行完善并关闭后再归档。</p>` ? `${html}<p class="pl-arrange-block-text">该运输计划已走证书齐备,归档前须完善证件。请到「证书齐备 → 已标记待完善证书」上传购买证/运输证并执行完善并关闭后再归档。</p>`
: html : html
await ElMessageBox.alert(guide, title, { await ElMessageBox.alert(guide, title, {
type: 'warning', type: 'warning',
confirmButtonText: '知道了', confirmButtonText: '知道了',
@@ -71,23 +71,6 @@
<p v-else-if="!loading" class="pl-plan-form-empty-hint">暂无车辆信息</p> <p v-else-if="!loading" class="pl-plan-form-empty-hint">暂无车辆信息</p>
</section> </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"> <section class="pl-plan-form-section pl-plan-form-section--plain">
<h3 class="pl-plan-form-section-title">装送货信息</h3> <h3 class="pl-plan-form-section-title">装送货信息</h3>
<PlPlanStopsSplitShell <PlPlanStopsSplitShell
@@ -251,8 +234,31 @@
</section> </section>
<section class="pl-plan-form-section pl-plan-form-section--plain"> <section class="pl-plan-form-section pl-plan-form-section--plain">
<h3 class="pl-plan-form-section-title">危化品证书信息</h3> <h3 class="pl-plan-form-section-title">关联购买证 / 运输证</h3>
<PlPlanHazardCertViewTable :products="hazardProducts" /> <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>
<section class="pl-plan-form-section pl-plan-form-section--plain"> <section class="pl-plan-form-section pl-plan-form-section--plain">
@@ -430,7 +436,6 @@
type BrandQtyRow type BrandQtyRow
} from '@/components/business/brand-qty-chips-field/index.vue' } from '@/components/business/brand-qty-chips-field/index.vue'
import PlPlanDriverFeedbackDialog from '@/components/business/pl-plan-driver-feedback-dialog/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 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 PlPlanStopFeedbackPanel from '@/components/business/pl-plan-stop-feedback-panel/index.vue'
import PlPlanStopProductTable from '@/components/business/pl-plan-stop-product-table/index.vue' import PlPlanStopProductTable from '@/components/business/pl-plan-stop-product-table/index.vue'
@@ -476,7 +481,6 @@
} from '@/utils/pl-plan-stop-form' } from '@/utils/pl-plan-stop-form'
import { buildArchiveBrandViewsBySegment } from '@/utils/pl-plan-archive-brand-view' import { buildArchiveBrandViewsBySegment } from '@/utils/pl-plan-archive-brand-view'
import { resolveFreightUnitLabel } from '@/composables/usePlFreightCalc' import { resolveFreightUnitLabel } from '@/composables/usePlFreightCalc'
import { isHazardProduct } from '@/composables/usePlToxicProductCertCheck'
import { useDictStore } from '@/store/modules/dict' import { useDictStore } from '@/store/modules/dict'
import { formatDate, formatDateTime } from '@/utils/format' import { formatDate, formatDateTime } from '@/utils/format'
import { import {
@@ -1638,18 +1642,6 @@
return fields 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< const linkedCertRows = ref<
Array<{ Array<{
key: string key: string
@@ -1658,6 +1650,7 @@
certNo?: string certNo?: string
reservedQty: string | number reservedQty: string | number
usedQty: string | number usedQty: string | number
attachmentUrls: string
}> }>
>([]) >([])
@@ -1670,9 +1663,10 @@
key: `${u.certType || u.cert_type}-${u.certId || u.cert_id}-${index}`, key: `${u.certType || u.cert_type}-${u.certId || u.cert_id}-${index}`,
kind: String(u.certType || u.cert_type || ''), kind: String(u.certType || u.cert_type || ''),
certId: String(u.certId || u.cert_id || ''), 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 ?? '—', 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 { } catch {
linkedCertRows.value = [] linkedCertRows.value = []
@@ -1932,6 +1926,93 @@
white-space: pre-wrap; 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 { .pl-plan-kv-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -86,11 +86,13 @@ export function usePlFreightCalc(options: {
) )
function resetFreightCalc() { function resetFreightCalc() {
transportQty.value = null
transportQtyTouched.value = false transportQtyTouched.value = false
freightAmount.value = null freightAmount.value = null
freightAmountTouched.value = false freightAmountTouched.value = false
freightPrice.value = null freightPrice.value = null
// 产品已加载时 watch 不会因 reset 重跑;须立即按当前实际数量回填,
// 否则归档弹窗再次打开会误报「运输量须大于0」。
transportQty.value = defaultTransportQty.value || null
} }
function markTransportQtyTouched() { function markTransportQtyTouched() {
+1 -1
View File
@@ -133,7 +133,7 @@
mode: 'list', mode: 'list',
title: '运输证件管理', title: '运输证件管理',
search: '证件编号 / 品名', search: '证件编号 / 品名',
tabs: ['证件建档', '合同用证', '证件用量', '计划段次用证', '证书齐备'], tabs: ['证件建档', '客户用证', '证件用量', '计划段次用证', '证书齐备'],
filters: [], filters: [],
caption: '正在加载运输证件管理' caption: '正在加载运输证件管理'
} }
+1 -1
View File
@@ -80,7 +80,7 @@ export const plRoutes: AppRouteRecord[] = [
name: 'PlContractQuota', name: 'PlContractQuota',
path: 'contract-quota', path: 'contract-quota',
component: '/pl/contract-quota/index', 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', id: 'PL_CERTIFICATE_USAGE',
+8 -2
View File
@@ -171,11 +171,17 @@ export const staticRoutes: AppRouteRecordRaw[] = [
component: () => import('@views/m/pl/plan-cert/archive-detail.vue'), component: () => import('@views/m/pl/plan-cert/archive-detail.vue'),
meta: { title: '证件建档', isHideTab: true, isMpWebview: true } 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', path: '/m/pl/plan-cert/contract/:id',
name: 'MpPlPlanCertContract', name: 'MpPlPlanCertContract',
component: () => import('@views/m/pl/plan-cert/contract-detail.vue'), redirect: { name: 'MpPlPlanCert', query: { tab: 'contract' } },
meta: { title: '合同用证', isHideTab: true, isMpWebview: true } meta: { title: '客户用证', isHideTab: true, isMpWebview: true }
}, },
{ {
path: '/m/pl/plan-cert/usage/:kind/:id', path: '/m/pl/plan-cert/usage/:kind/:id',
@@ -0,0 +1,27 @@
/** 归档用证拦截弹窗:已齐备/待完善只提醒,未齐备旧规则才可再点证书齐备。 */
export type ArchiveQuotaBlockMode = 'alert-pending' | 'alert-plain' | 'confirm-ready'
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 'confirm-ready'
}
@@ -504,13 +504,12 @@
/> />
</el-tab-pane> </el-tab-pane>
<el-tab-pane v-if="canViewQuotaCert" label="合同证件与额度" name="quota" lazy> <el-tab-pane v-if="canViewQuotaCert" label="客户证件与额度" name="quota" lazy>
<ContractQuotaPanel <CustomerCertQuotaPanel
v-if="visible && detailActiveTab === 'quota' && detailContract?.id && String(detailContract.id) === String(currentDetailId)" v-if="visible && detailActiveTab === 'quota' && detailContract?.id && String(detailContract.id) === String(currentDetailId)"
:key="detailContract.id" :key="String(detailContract.customerId || detailContract.id)"
:contract-id="String(detailContract.id)" :customer-id="detailContract.customerId"
:active="detailActiveTab === 'quota'" :active="detailActiveTab === 'quota'"
embedded
/> />
</el-tab-pane> </el-tab-pane>
@@ -1060,7 +1059,7 @@ import UploadDialog from './UploadDialog.vue';
import ContractFormDialog from './ContractFormDialog.vue'; import ContractFormDialog from './ContractFormDialog.vue';
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue'; import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue';
import type { ViewField } from '@/components/business/system-view-dialog/types'; 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 StopDocuments from '@/views/pl/legal-use-document/StopDocuments.vue';
import PlContractLinkedPlansPanel from '@/components/business/pl-contract-linked-plans-panel/index.vue'; import PlContractLinkedPlansPanel from '@/components/business/pl-contract-linked-plans-panel/index.vue';
import { 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>
+46 -9
View File
@@ -342,6 +342,19 @@
</div> </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"> <div v-show="activeTab === 'plans'" class="mp-contract-detail-pane">
<section class="mp-wv-section mp-contract-detail-card"> <section class="mp-wv-section mp-contract-detail-card">
<h3>运输计划</h3> <h3>运输计划</h3>
@@ -933,6 +946,8 @@
} from '@/utils/mp-webview' } from '@/utils/mp-webview'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { useDictStore } from '@/store/modules/dict' import { useDictStore } from '@/store/modules/dict'
import { fetchGetMenuCodes } from '@/api/auth'
import CustomerCertQuotaTab from './CustomerCertQuotaTab.vue'
import '@/styles/mp-webview.scss' import '@/styles/mp-webview.scss'
import '../customer/customer.scss' import '../customer/customer.scss'
@@ -970,6 +985,7 @@
type DetailTabKey = type DetailTabKey =
| 'contract' | 'contract'
| 'plans' | 'plans'
| 'quota'
| 'payment' | 'payment'
| 'invoice' | 'invoice'
| 'approval' | 'approval'
@@ -979,6 +995,7 @@
const acting = ref(false) const acting = ref(false)
const errorText = ref('') const errorText = ref('')
const activeTab = ref<DetailTabKey>('contract') const activeTab = ref<DetailTabKey>('contract')
const canViewQuotaCert = ref(false)
const detailBody = ref<HTMLElement>() const detailBody = ref<HTMLElement>()
const partiesExpanded = ref(false) const partiesExpanded = ref(false)
const approverPickerOpen = ref(false) const approverPickerOpen = ref(false)
@@ -1056,14 +1073,34 @@
} }
} }
const detailTabs: Array<{ key: DetailTabKey; label: string }> = [ const detailTabs = computed(() => {
{ key: 'contract', label: '合同信息' }, const tabs: Array<{ key: DetailTabKey; label: string }> = [
{ key: 'plans', label: '运输计划' }, { key: 'contract', label: '合同信息' },
{ key: 'payment', label: '回款记录' }, { key: 'plans', label: '运输计划' }
{ key: 'invoice', label: '开票记录' }, ]
{ key: 'approval', label: '审批流程' }, if (canViewQuotaCert.value) tabs.push({ key: 'quota', label: '客户证件与额度' })
{ key: 'operation', 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 unreceivedAmount = computed(() => {
const amount = Number(contract.value?.amount) || 0 const amount = Number(contract.value?.amount) || 0
@@ -1573,7 +1610,7 @@
} }
loading.value = true loading.value = true
try { try {
const res = await getContract(id) const [res] = await Promise.all([getContract(id), refreshQuotaCertPermission()])
if (!isCurrentLoad(requestSeq, id)) return if (!isCurrentLoad(requestSeq, id)) return
const data = ((res as any)?.data ?? res) as ContractVO const data = ((res as any)?.data ?? res) as ContractVO
if (String(data?.id || '') !== id) throw new Error('合同不存在或无权查看') if (String(data?.id || '') !== id) throw new Error('合同不存在或无权查看')
@@ -60,12 +60,12 @@
>编辑</button >编辑</button
> >
<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" class="mp-wv-btn"
type="button" type="button"
@click="confirmRow = row" @click="confirmRow = row"
> >
{{ row.openingConfirmed ? '复核' : '期初确认' }} 复核
</button> </button>
</div> </div>
</article> </article>
@@ -8,37 +8,21 @@
:close-on-click-overlay="!saving" :close-on-click-overlay="!saving"
@update:show="onShow" @update:show="onShow"
> >
<van-nav-bar <van-nav-bar title="复核证件" left-text="关闭" @click-left="!saving && emit('close')" />
:title="row?.openingConfirmed ? '复核证件' : '期初核对'"
left-text="关闭"
@click-left="!saving && emit('close')"
/>
<div v-if="row" class="mp-cert-confirm-sheet__body mp-cert-form"> <div v-if="row" class="mp-cert-confirm-sheet__body mp-cert-form">
<p class="plan-empty-hint">{{ row.certNo }} · {{ row.openingConfirmed ? '复核已启用的证件' : '核对历史用量后启用' }}</p> <p class="plan-empty-hint">{{ row.certNo }} · 复核已启用的证件</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>
<van-field v-model="opening.reason" label="核对说明" label-align="top" type="textarea" rows="2" :disabled="saving" /> <van-field v-model="opening.reason" label="核对说明" label-align="top" type="textarea" rows="2" :disabled="saving" />
</div> </div>
<footer> <footer>
<button class="mp-wv-btn" type="button" :disabled="saving" @click="emit('close')">取消</button> <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"> <button class="mp-wv-btn mp-wv-btn--primary" type="button" :disabled="saving" @click="submit">
{{ saving ? '正在提交…' : '确认启用' }} {{ saving ? '正在提交…' : '确认复核' }}
</button> </button>
</footer> </footer>
</van-popup> </van-popup>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ElMessage } from 'element-plus' 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 { quotaCertApi, type CertType, type QuotaCert } from '@/api/pl/quota-cert'
import { randomOperationId } from '@/utils/random-id' import { randomOperationId } from '@/utils/random-id'
import './cert-mobile.scss' import './cert-mobile.scss'
@@ -76,19 +60,15 @@
opening.reason = opening.reason.trim() opening.reason = opening.reason.trim()
saving.value = true saving.value = true
try { try {
if (props.row.openingConfirmed) { await quotaCertApi.verify(kind, props.row.id, {
await quotaCertApi.verify(kind, props.row.id, { operationId: opening.operationId,
operationId: opening.operationId, reason: opening.reason
reason: opening.reason })
}) ElMessage.success('证件已复核')
} else {
await quotaCertApi.confirm(kind, props.row.id, { ...opening })
}
ElMessage.success(props.row.openingConfirmed ? '证件已复核' : '期初已确认')
emit('saved') emit('saved')
emit('close') emit('close')
} catch { } catch {
ElMessage.error('核对没有提交,请重试') ElMessage.error('复核没有提交,请重试')
} finally { } finally {
saving.value = false saving.value = false
} }
@@ -1,144 +1,7 @@
<template> <template>
<main class="mp-h5-list-content mp-plan-cert-list"> <CustomerTab :keyword="keyword" />
<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>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { fetchGetMenuCodes } from '@/api/auth' import CustomerTab from './CustomerTab.vue'
import { quotaStatisticsApi, type ContractStatisticsSummary } from '@/api/pl/quota-cert' defineProps<{ keyword: string }>()
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()
</script> </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 return
} }
const certRecord = (cert || {}) as Record<string, any> const certRecord = (cert || {}) as Record<string, any>
let name = let name = ''
String(certRecord.customerName || '').trim() || try {
systemCustomer.value.customerName || const detail = (await fetchCustomerDetail({ id })) as Record<string, any>
customerOptions.value.find((item) => item.id === id)?.customerName || const data = detail?.data || detail
'' name = String(data?.customerName || '').trim()
} catch {
name = ''
}
// 系统客户以 CRM 为准;禁止用证面收货单位等缓存名顶替。
if (!name) { if (!name) {
try { name =
const detail = (await fetchCustomerDetail({ id })) as Record<string, any> String(certRecord.customerName || '').trim() ||
const data = detail?.data || detail customerOptions.value.find((item) => item.id === id)?.customerName ||
name = String(data?.customerName || '').trim() || id id
} catch {
name = id
}
} }
systemCustomer.value = { id, customerName: name } systemCustomer.value = { id, customerName: name }
form.value.customerId = id form.value.customerId = id
@@ -0,0 +1,83 @@
<template>
<MpWebviewShell @ready="ready = true">
<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 CustomerCertQuotaTab from '@/views/m/crm/contract/CustomerCertQuotaTab.vue'
const route = useRoute()
const router = useRouter()
const ready = ref(false)
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>
@@ -44,7 +44,16 @@ export function certFaceGroups(
: display(cert, field[0]), : display(cert, field[0]),
wide: longFaceKeys.has(field[0]) || field[0] === 'vehicleNumbersJson' 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
}
)
}
return { title: group.title, fields } return { title: group.title, fields }
}) })
groups[groups.length - 1].fields.push({ groups[groups.length - 1].fields.push({
+1 -1
View File
@@ -183,7 +183,7 @@
const committed = ref('') const committed = ref('')
const CERT_TABS = [ const CERT_TABS = [
{ name: 'archive', title: '证件建档', perm: 'mini:pl:quota-cert:list' }, { 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: 'usage', title: '证件用量', perm: 'mini:pl:certificate-usage:list' },
{ name: 'plan', title: '计划段次用证', perm: 'mini:pl:plan-cert:segment' }, { name: 'plan', title: '计划段次用证', perm: 'mini:pl:plan-cert:segment' },
{ name: 'ready', title: '证书齐备', perm: 'mini:pl:cert-ready:list' } { name: 'ready', title: '证书齐备', perm: 'mini:pl:cert-ready:list' }
+117 -2
View File
@@ -248,7 +248,37 @@
</section> </section>
<section id="plan-sec-cert" class="mp-wv-section mp-h5-anchor"> <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"> <div class="plan-cert-actions">
<van-button block plain type="primary" @click="quotaOpen = true">查看用证</van-button> <van-button block plain type="primary" @click="quotaOpen = true">查看用证</van-button>
<van-button <van-button
@@ -536,10 +566,40 @@
const errorText = ref('') const errorText = ref('')
const plan = ref<Plan | null>(null) const plan = ref<Plan | null>(null)
const quotaOpen = ref(false) 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(() => const needsLegalUseDocument = computed(() =>
(plan.value?.products || []).some((row) => resolveProductHazardFlags(row).isExplosive) (plan.value?.products || []).some((row) => resolveProductHazardFlags(row).isExplosive)
) )
const quotaTitle = computed(() => `计划用证 · ${plan.value?.planNo || ''}`) 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 deliveryIndex = ref(0)
const moreOpen = ref(false) const moreOpen = ref(false)
const voidOpen = ref(false) const voidOpen = ref(false)
@@ -568,7 +628,7 @@
{ key: 'base', label: '基础信息', anchor: 'plan-sec-base' }, { key: 'base', label: '基础信息', anchor: 'plan-sec-base' },
{ key: 'transport', label: '运输信息', anchor: 'plan-sec-transport' }, { key: 'transport', label: '运输信息', anchor: 'plan-sec-transport' },
{ key: 'load', label: '装送货', anchor: 'plan-sec-load' }, { 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: 'freight', label: '运费', anchor: 'plan-sec-freight' },
{ key: 'remark', label: '备注', anchor: 'plan-sec-remark' } { key: 'remark', label: '备注', anchor: 'plan-sec-remark' }
] ]
@@ -751,6 +811,7 @@
loadVersion++ loadVersion++
actionVersion++ actionVersion++
plan.value = null plan.value = null
linkedCertRows.value = []
readDetail.value = null readDetail.value = null
partialErrors.value = [] partialErrors.value = []
errorText.value = '' errorText.value = ''
@@ -807,9 +868,12 @@
readDetail.value = detail readDetail.value = detail
partialErrors.value = [...detail.errors, ...(arrangeFailed ? ['安排信息'] : [])] partialErrors.value = [...detail.errors, ...(arrangeFailed ? ['安排信息'] : [])]
plan.value = data plan.value = data
await loadLinkedCerts(id)
if (!current()) return
} catch { } catch {
if (!current()) return if (!current()) return
plan.value = null plan.value = null
linkedCertRows.value = []
resetArrangeActions() resetArrangeActions()
errorText.value = '计划详情加载失败或记录不存在,请重试' errorText.value = '计划详情加载失败或记录不存在,请重试'
} finally { } finally {
@@ -1516,6 +1580,57 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; 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 { .mp-plan-quota-sheet {
+8 -6
View File
@@ -50,6 +50,14 @@
class="list-table" class="list-table"
@selection-change="onSelectionChange" @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 <el-table-column
v-for="column in columns" v-for="column in columns"
:key="column.prop" :key="column.prop"
@@ -93,12 +101,6 @@
<span v-else>{{ row[column.prop] || '—' }}</span> <span v-else>{{ row[column.prop] || '—' }}</span>
</template> </template>
</el-table-column> </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"> <el-table-column label="操作" min-width="260" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<template v-if="listMode === 'fulfill'"> <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"> <script setup lang="ts">
import StatisticsPanel from '../quota-statistics/StatisticsPanel.vue' import CustomerQuotaPanel from './CustomerQuotaPanel.vue'
const props = withDefaults( const props = withDefaults(
defineProps<{ contractId?: string; embedded?: boolean; active?: boolean }>(), defineProps<{ customerId?: string; embedded?: boolean; active?: boolean }>(),
{ active: true } { active: true }
) )
</script> </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,296 @@
<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, visibleDefs, resetToDefault } =
usePersistedListColumns('pl-customer-quota-columns-v1', columns)
watchVisibleColumns(visibleColumns)
const tableWrapRef = ref<HTMLElement>()
const pagerRef = ref<HTMLElement>()
const tableRef = ref()
const { tableHeight, expansionStyle } = useListTableViewportHeight({
wrapRef: tableWrapRef,
pagerRef,
tableRef
})
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)
}
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 }
}
}
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 {
padding: 8px 12px 16px;
}
</style>
+43 -56
View File
@@ -93,11 +93,10 @@
><el-icon><Edit /></el-icon>编辑</el-button ><el-icon><Edit /></el-icon>编辑</el-button
> >
<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" size="small"
@click="openConfirm(row)" @click="openConfirm(row)"
><el-icon><Check /></el-icon ><el-icon><Check /></el-icon>复核</el-button
>{{ row.openingConfirmed ? '复核' : '期初确认' }}</el-button
> >
<el-button size="small" @click="view(row)" <el-button size="small" @click="view(row)"
><el-icon><View /></el-icon>查看</el-button ><el-icon><View /></el-icon>查看</el-button
@@ -591,35 +590,12 @@
</SystemViewDialog> </SystemViewDialog>
<el-dialog <el-dialog
v-model="confirmVisible" v-model="confirmVisible"
:title="confirmRow?.openingConfirmed ? '复核证件' : '期初核对'" title="复核证件"
width="640px" width="640px"
align-center align-center
class="dialog-form-layout quota-cert-confirm-dialog" class="dialog-form-layout quota-cert-confirm-dialog"
> >
<el-form label-position="top"> <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-form-item label="核对说明"
><el-input v-model="opening.reason" type="textarea" ><el-input v-model="opening.reason" type="textarea"
/></el-form-item> /></el-form-item>
@@ -628,7 +604,7 @@
><el-button @click="confirmVisible = false" ><el-button @click="confirmVisible = false"
><el-icon><Close /></el-icon>取消</el-button ><el-icon><Close /></el-icon>取消</el-button
><el-button type="primary" :loading="saving" @click="confirm" ><el-button type="primary" :loading="saving" @click="confirm"
><el-icon><Check /></el-icon>确认启用</el-button ><el-icon><Check /></el-icon>确认复核</el-button
></template ></template
> >
</el-dialog> </el-dialog>
@@ -666,7 +642,7 @@
type ContractOption, type ContractOption,
type ScopeProduct type ScopeProduct
} from '@/api/pl/quota-cert' } 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 AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import CertContractBinder from './CertContractBinder.vue' import CertContractBinder from './CertContractBinder.vue'
import { import {
@@ -761,28 +737,32 @@
async function ensureCustomerOption(id?: string | null, nameHint = '') { async function ensureCustomerOption(id?: string | null, nameHint = '') {
if (!id) return if (!id) return
if (customerOptions.value.some((c) => c.id === id)) return let name = ''
if (nameHint) {
customerOptions.value = [{ id, customerName: nameHint }, ...customerOptions.value]
return
}
try { try {
const result = await fetchCustomerOptions({ keyword: '', limit: 200 }) const detail = (await fetchCustomerDetail({ id })) as Record<string, any>
const list = Array.isArray(result) const data = detail?.data || detail
? result name = String(data?.customerName || data?.name || '').trim()
: (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]
}
} catch { } 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() { async function loadCategories() {
@@ -1048,8 +1028,17 @@
value: statusLabels[detail.value.status], value: statusLabels[detail.value.status],
tagType: tagType(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) => 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({ groups[groups.length - 1].fields.push({
label: '系统备注', label: '系统备注',
@@ -1424,7 +1413,7 @@
applyCertForm(targetKind, cert) applyCertForm(targetKind, cert)
await ensureCustomerOption( await ensureCustomerOption(
form.value.customerId, form.value.customerId,
formKind.value === 'purchase' ? form.value.buyerName : form.value.consigneeName String((cert as QuotaCert).customerName || '').trim()
) )
if (targetKind === 'transport') purchases.value = purchaseOptions() if (targetKind === 'transport') purchases.value = purchaseOptions()
} catch (error) { } catch (error) {
@@ -1805,12 +1794,10 @@
saving.value = true saving.value = true
try { try {
const targetKind = rowCertType(confirmRow.value) const targetKind = rowCertType(confirmRow.value)
if (confirmRow.value.openingConfirmed) await quotaCertApi.verify(targetKind, confirmRow.value.id, {
await quotaCertApi.verify(targetKind, confirmRow.value.id, { operationId: opening.operationId,
operationId: opening.operationId, reason: opening.reason
reason: opening.reason })
})
else await quotaCertApi.confirm(targetKind, confirmRow.value.id, opening)
confirmVisible.value = false confirmVisible.value = false
await load() await load()
} finally { } finally {