Compare commits

...
Author SHA1 Message Date
wuzilongandCursor 168e909914 fix(web-admin): 恢复 Excel 查看中 Ctrl/Cmd+C 复制选区
filterOnly 默认关闭 WorksheetCopy;打开复制权限并增加选区 TSV 快捷键兜底。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 23:34:37 +08:00
wuzilongandCursor a327ccf69a fix(web-admin): 修复 Excel 查看因工作表权限未 protect 误报加载失败
工作表 setPoint 须先 protect;表格已渲染成功后权限步骤抛错不再判定为整体加载失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 23:31:26 +08:00
wuzilongandCursor cd94ebc56d feat(pl): 购买证运输证改为客户品类额度并支持齐备完善。
按客户与证用品类自动选证,未配置生效时间拦住安排;齐备仅放行安排,归档前须完善挂证,并补充迁移与测试造数脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 23:31:01 +08:00
wuzilongandCursor aad74505c0 feat(web-admin): 列表 Excel 查看改为 Univer 只读在线表格
点击工具栏打开可筛选、复制的只读弹层,替换原行列点选复制模式,且不写回业务数据。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 23:25:18 +08:00
41 changed files with 4850 additions and 413 deletions

No files matched your search

+6
View File
@@ -2206,6 +2206,8 @@ def create_plan(
create_by=user.id,
create_dept_id=user.dept_id,
)
from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_version
stamp_plan_rule_version(db, user, plan)
db.add(plan)
# 添加产品明细
@@ -2377,6 +2379,8 @@ def create_plan_full(
create_by=user.id,
create_dept_id=user.dept_id,
)
from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_version
stamp_plan_rule_version(db, user, plan)
db.add(plan)
db.flush()
@@ -2527,6 +2531,8 @@ def create_from_contract(
create_by=user.id,
create_dept_id=user.dept_id,
)
from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_version
stamp_plan_rule_version(db, user, plan)
db.add(plan)
# 查询合同产品明细
+94
View File
@@ -137,6 +137,100 @@ def mark_cert_ready(plan_id: str, db: Session = Depends(get_db), user: DbUserCon
return _write(db, lambda: mark_ready(db, user, plan_id))
@cert_ready_router.get('/plans/cert-ready/pending-fulfill')
def list_pending_fulfill(keyword: str = '', 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)):
from app.services.pl_quota_waiver import list_pending_fulfill_plans
return _wire(list_pending_fulfill_plans(db, user, page, page_size, keyword))
@cert_ready_router.post('/plans/{plan_id}/fulfill-quota-certs')
def fulfill_quota_certs(plan_id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
from app.services.pl_quota_waiver import fulfill_plan_certs
return _write(db, lambda: fulfill_plan_certs(db, user, plan_id))
class FulfillBatchBody(InputBody):
plan_ids: list = Field(default_factory=list, alias='planIds')
@cert_ready_router.post('/plans/fulfill-quota-certs/batch')
def fulfill_quota_certs_batch(body: FulfillBatchBody, db: Session = Depends(get_db),
user: DbUserContext = Depends(current_user)):
from app.services.pl_quota_waiver import fulfill_plans_batch
ids = [str(x) for x in (body.plan_ids or []) if x]
if not ids:
raise HTTPException(400, '请选择要完善的计划')
return _write(db, lambda: fulfill_plans_batch(db, user, ids))
@router.get('/rule-config')
def get_rule_config(db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
from app.services.pl_cert_quota_rule_config_service import read_config
return _wire(read_config(db, user.tenant_no))
class RuleConfigBody(InputBody):
effective_at: Optional[str] = Field(None, alias='effectiveAt')
@router.put('/rule-config', dependencies=[Depends(require_admin_or_super_admin)])
@oper_log('运输证件规则切点', business_type=2)
def put_rule_config(body: RuleConfigBody, db: Session = Depends(get_db),
user: DbUserContext = Depends(current_user)):
from datetime import datetime as dt
from app.services.pl_cert_quota_rule_config_service import save_effective_at
value = None
if body.effective_at:
text = body.effective_at.replace('T', ' ').strip()
try:
value = dt.strptime(text[:19], '%Y-%m-%d %H:%M:%S')
except ValueError:
try:
value = dt.strptime(text[:16], '%Y-%m-%d %H:%M')
except ValueError as exc:
raise HTTPException(400, '生效时间格式无效') from exc
return _write(db, lambda: save_effective_at(db, user, value))
@router.get('/categories')
def list_categories(keyword: Optional[str] = None, status: Optional[str] = None,
db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
from app.services import pl_cert_category_service as categories
return _wire(categories.list_categories(db, user, keyword, status))
@router.get('/categories/{category_id}')
def get_category(category_id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
from app.services import pl_cert_category_service as categories
return _wire(categories.get_category(db, user, category_id))
@router.post('/categories', dependencies=[Depends(require_menu_code('pl:quota-cert:edit'))])
@oper_log('证用品类', business_type=1)
def create_category(body: dict = Body(...), db: Session = Depends(get_db),
user: DbUserContext = Depends(current_user)):
from app.services import pl_cert_category_service as categories
return _write(db, lambda: categories.save_category(db, user, body))
@router.put('/categories/{category_id}', dependencies=[Depends(require_menu_code('pl:quota-cert:edit'))])
@oper_log('证用品类', business_type=2, path_param='category_id')
def update_category(category_id: str, body: dict = Body(...), db: Session = Depends(get_db),
user: DbUserContext = Depends(current_user)):
from app.services import pl_cert_category_service as categories
return _write(db, lambda: categories.save_category(db, user, body, category_id))
@router.delete('/categories/{category_id}', dependencies=[Depends(require_menu_code('pl:quota-cert:edit'))])
@oper_log('证用品类', business_type=3, path_param='category_id')
def delete_category(category_id: str, db: Session = Depends(get_db),
user: DbUserContext = Depends(current_user)):
from app.services import pl_cert_category_service as categories
return _write(db, lambda: categories.delete_category(db, user, category_id) or {'ok': True})
@router.get('/plans/{plan_id}/usage')
def plan_quota(plan_id: str, arrange_check: bool = Query(False, alias='arrangeCheck'),
db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
+2
View File
@@ -725,6 +725,8 @@ def arrange_transport(
raise HTTPException(status_code=404, detail="计划不存在")
assert_pl_row_in_scope(db, plan, PlPlan, user, tenant_no)
assert_plan_can_arrange(plan)
from app.services.pl_cert_quota_rule_config_service import assert_cert_quota_rule_configured
assert_cert_quota_rule_configured(db, tenant_no)
assert_no_pending_stop_products(db, tenant_no, plan_id)
+3 -1
View File
@@ -366,10 +366,12 @@ class PlPlan(Base):
cert_configuration = Column(JSON, nullable=True, comment="用证来源配对、候选选择与逐证条件确认;不作为放行布尔状态")
hazard_cert_ready_by = Column(String(64), nullable=True, comment="危化品证书确认人")
hazard_cert_ready_time = Column(DateTime, nullable=True, comment="危化品证书确认时间")
quota_cert_waived = Column(SmallInteger, nullable=False, default=0, comment="证书齐备:1 时安排和归档不校验、不扣购买证与运输证额度")
quota_cert_waived = Column(SmallInteger, nullable=False, default=0, comment="证书齐备:1 时安排和归档不校验、不扣购买证与运输证额度(新规则仅放行安排)")
quota_cert_waived_by = Column(String(64), nullable=True, comment="证书齐备确认人ID")
quota_cert_waived_by_name = Column(String(100), nullable=True, comment="证书齐备确认人姓名")
quota_cert_waived_time = Column(DateTime, nullable=True, comment="证书齐备确认时间")
cert_quota_rule_version = Column(String(32), nullable=True, comment="证件额度规则版本 legacy|customer_category;创建时打标")
quota_cert_pending_fulfill = Column(SmallInteger, nullable=False, default=0, comment="新规则证书齐备待完善 0-否 1-是")
erp_entered = Column(SmallInteger, nullable=False, default=0, comment="是否已录入ERP 0-否 1-是")
cancel_reason = Column(String(500), nullable=True, comment="作废原因")
cancel_time = Column(DateTime, nullable=True, comment="作废时间")
+62
View File
@@ -34,6 +34,8 @@ class PlPurchaseCert(Base):
seller_legal_representative = Column(String(100), nullable=True, comment='销售方法定代表人')
seller_phone = Column(String(100), nullable=True, comment='销售方电话')
purchase_purpose = Column(String(1000), nullable=True, comment='用途')
customer_id = Column(String(64), nullable=True, comment='系统客户ID;选证必填')
category_id = Column(String(64), nullable=True, comment='证用品类ID;选证必填')
tenant_no = Column(String(64), nullable=False, comment='租户编号,由服务端当前用户上下文设置')
order_num = Column(Integer, nullable=True, comment='公共排序号')
remark = Column(String(1000), nullable=True, comment='系统备注,与证面备注分开')
@@ -80,6 +82,7 @@ class PlTransportCert(Base):
route_text = Column(Text, nullable=True, comment='运输路线完整原文')
consignee_name = Column(String(255), nullable=True, comment='收货单位名称或姓名')
applicant_name = Column(String(255), nullable=True, comment='申请单位名称或姓名')
customer_id = Column(String(64), nullable=True, comment='系统客户ID;选证必填')
tenant_no = Column(String(64), nullable=False, comment='租户编号,由服务端当前用户上下文设置')
order_num = Column(Integer, nullable=True, comment='公共排序号')
remark = Column(String(1000), nullable=True, comment='系统备注,与证面备注分开')
@@ -323,3 +326,62 @@ class PlStopLegalDocument(Base):
__table_args__ = (
UniqueConstraint('tenant_no', 'plan_id', 'stop_type', 'stop_product_id', name='uk_stop_legal_document'),
)
class PlCertCategory(Base):
"""证用品类(与 CRM 产品分类独立)。"""
__tablename__ = 'pl_cert_category'
id = Column(String(64), primary_key=True, comment='主键UUID')
category_name = Column(String(128), nullable=False, comment='证用品类名称,如硫酸')
category_code = Column(String(64), nullable=True, comment='品类编码,可选')
status = Column(String(1), nullable=False, default='1', comment='状态 1启用 0停用')
tenant_no = Column(String(64), nullable=False, comment='租户编号')
order_num = Column(Integer, nullable=True, comment='排序号')
remark = Column(String(1000), nullable=True, comment='备注')
create_by = Column(String(64), nullable=True, comment='创建人')
create_dept_id = Column(String(64), nullable=False, default='', comment='创建部门')
create_time = Column(DateTime, nullable=False, default=datetime.now, comment='创建时间')
update_by = Column(String(64), nullable=True, comment='更新人')
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment='更新时间')
del_flag = Column(SmallInteger, nullable=False, default=0, comment='软删除标记:0正常,1删除')
class PlCertCategoryProduct(Base):
"""证用品类关联系统产品。"""
__tablename__ = 'pl_cert_category_product'
id = Column(String(64), primary_key=True, comment='主键UUID')
category_id = Column(String(64), nullable=False, comment='证用品类ID')
product_id = Column(String(64), nullable=False, comment='CRM产品ID')
scope_status = Column(String(20), nullable=False, default='active', comment='active/inactive')
tenant_no = Column(String(64), nullable=False, comment='租户编号')
order_num = Column(Integer, nullable=True, comment='排序号')
remark = Column(String(1000), nullable=True, comment='备注')
create_by = Column(String(64), nullable=True, comment='创建人')
create_dept_id = Column(String(64), nullable=False, default='', comment='创建部门')
create_time = Column(DateTime, nullable=False, default=datetime.now, comment='创建时间')
update_by = Column(String(64), nullable=True, comment='更新人')
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment='更新时间')
del_flag = Column(SmallInteger, nullable=False, default=0, comment='软删除标记:0正常,1删除')
__table_args__ = (
UniqueConstraint('tenant_no', 'category_id', 'product_id', name='uk_cert_category_product'),
Index('idx_cert_cat_prod_product', 'tenant_no', 'product_id', 'del_flag', 'scope_status'),
)
class PlCertQuotaRuleConfig(Base):
"""客户品类额度规则切点;每租户一行。"""
__tablename__ = 'pl_cert_quota_rule_config'
id = Column(String(64), primary_key=True, comment='主键UUID')
effective_at = Column(DateTime, nullable=True, comment='客户品类额度生效时间;空表示未配置')
tenant_no = Column(String(64), nullable=False, comment='租户编号')
order_num = Column(Integer, nullable=True, comment='排序号')
remark = Column(String(1000), nullable=True, comment='备注')
create_by = Column(String(64), nullable=True, comment='创建人')
create_dept_id = Column(String(64), nullable=False, default='', comment='创建部门')
create_time = Column(DateTime, nullable=False, default=datetime.now, comment='创建时间')
update_by = Column(String(64), nullable=True, comment='更新人')
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment='更新时间')
del_flag = Column(SmallInteger, nullable=False, default=0, comment='软删除标记:0正常,1删除')
__table_args__ = (
UniqueConstraint('tenant_no', name='uk_cert_quota_rule_tenant'),
)
+3 -1
View File
@@ -413,10 +413,12 @@ class PlanRead(PlanBase, PlanResourceSnapshotMixin):
hazard_cert_ready: int = Field(default=0, alias="hazardCertReady", description="危化品证书是否已人工确认齐备")
hazard_cert_ready_by: Optional[str] = Field(default=None, alias="hazardCertReadyBy", description="危化品证书确认人")
hazard_cert_ready_time: Optional[datetime] = Field(default=None, alias="hazardCertReadyTime", description="危化品证书确认时间")
quota_cert_waived: int = Field(default=0, alias="quotaCertWaived", description="证书齐备 0-否 1-是;为 1 时不校验、不扣购买证与运输证额度")
quota_cert_waived: int = Field(default=0, alias="quotaCertWaived", description="证书齐备 0-否 1-是;新规则仅放行安排")
quota_cert_waived_by: Optional[str] = Field(default=None, alias="quotaCertWaivedBy", description="证书齐备确认人ID")
quota_cert_waived_by_name: Optional[str] = Field(default=None, alias="quotaCertWaivedByName", description="证书齐备确认人姓名")
quota_cert_waived_time: Optional[datetime] = Field(default=None, alias="quotaCertWaivedTime", description="证书齐备确认时间")
cert_quota_rule_version: Optional[str] = Field(default=None, alias="certQuotaRuleVersion", description="legacy|customer_category")
quota_cert_pending_fulfill: int = Field(default=0, alias="quotaCertPendingFulfill", description="新规则齐备待完善 0否 1是")
erp_entered: int = Field(default=0, alias="erpEntered", description="是否已录入ERP 0-否 1-是")
cancel_reason: Optional[str] = Field(default=None, alias="cancelReason", description="作废原因")
cancel_time: Optional[datetime] = Field(default=None, alias="cancelTime", description="作废时间")
+3
View File
@@ -48,6 +48,8 @@ class PurchaseCertUpdate(CertFields):
seller_legal_representative: Optional[str] = Field(None, max_length=100)
seller_phone: Optional[str] = Field(None, max_length=100)
purchase_purpose: Optional[str] = Field(None, max_length=1000)
customer_id: Optional[str] = Field(None, alias='customerId', max_length=64)
category_id: Optional[str] = Field(None, alias='categoryId', max_length=64)
class PurchaseCertCreate(PurchaseCertUpdate):
@@ -83,6 +85,7 @@ class TransportCertUpdate(CertFields):
route_text: Optional[str] = None
consignee_name: Optional[str] = Field(None, max_length=255)
applicant_name: Optional[str] = Field(None, max_length=255)
customer_id: Optional[str] = Field(None, alias='customerId', max_length=64)
purchase_refs: Optional[List[PurchaseReference]] = None
@@ -0,0 +1,174 @@
"""证用品类与产品关联;差量保存关联行。"""
from uuid import uuid4
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.models.pl_quota_models import PlCertCategory, PlCertCategoryProduct
def _audit(user):
return dict(
id=str(uuid4()),
tenant_no=user.tenant_no,
create_by=user.id,
create_dept_id=getattr(user, 'dept_id', None) or '',
update_by=user.id,
)
def _category_dict(row, products=None):
data = {
'id': row.id,
'categoryName': row.category_name,
'categoryCode': row.category_code,
'status': row.status,
'orderNum': row.order_num,
'remark': row.remark,
'createTime': row.create_time,
'updateTime': row.update_time,
}
if products is not None:
data['products'] = products
return data
def list_categories(db: Session, user, keyword=None, status=None):
query = db.query(PlCertCategory).filter_by(tenant_no=user.tenant_no, del_flag=0)
if keyword:
query = query.filter(PlCertCategory.category_name.contains(keyword, autoescape=True))
if status is not None and status != '':
query = query.filter(PlCertCategory.status == status)
rows = query.order_by(PlCertCategory.create_time.desc()).all()
return [_category_dict(r) for r in rows]
def get_category(db: Session, user, category_id: str, *, lock=False):
query = db.query(PlCertCategory).filter_by(id=category_id, tenant_no=user.tenant_no, del_flag=0)
if lock:
query = query.with_for_update().populate_existing()
row = query.first()
if not row:
raise HTTPException(404, '证用品类不存在')
links = (
db.query(PlCertCategoryProduct)
.filter_by(tenant_no=user.tenant_no, category_id=category_id, del_flag=0)
.order_by(PlCertCategoryProduct.create_time.asc())
.all()
)
products = [
{
'id': link.id,
'productId': link.product_id,
'scopeStatus': link.scope_status,
}
for link in links
]
return _category_dict(row, products)
def save_category(db: Session, user, data: dict, category_id=None):
"""data: categoryName, categoryCode?, status?, remark?, products?
products 遗漏则不改关联;显式 [] 清空(软删);带 id 就地改。
"""
name = (data.get('categoryName') or data.get('category_name') or '').strip()
if not name:
raise HTTPException(400, '品类名称不能为空')
if category_id:
row = db.query(PlCertCategory).filter_by(
id=category_id, tenant_no=user.tenant_no, del_flag=0,
).with_for_update().populate_existing().first()
if not row:
raise HTTPException(404, '证用品类不存在')
else:
row = PlCertCategory(**_audit(user))
db.add(row)
row.category_name = name
if 'categoryCode' in data or 'category_code' in data:
row.category_code = data.get('categoryCode', data.get('category_code'))
if 'status' in data and data['status'] is not None:
row.status = str(data['status'])
if 'remark' in data:
row.remark = data.get('remark')
if 'orderNum' in data or 'order_num' in data:
row.order_num = data.get('orderNum', data.get('order_num'))
row.update_by = user.id
db.flush()
if 'products' in data:
submitted = data['products']
if submitted is None:
raise HTTPException(400, '产品关联集合不可为 null;清空请提交 []')
existing = {
r.id: r
for r in db.query(PlCertCategoryProduct).filter_by(
tenant_no=user.tenant_no, category_id=row.id, del_flag=0,
).with_for_update().populate_existing().all()
}
seen = set()
product_ids = set()
for item in submitted:
pid = item.get('productId') or item.get('product_id')
if not pid:
raise HTTPException(400, '产品关联缺少 productId')
if pid in product_ids:
raise HTTPException(400, '同一品类不能重复关联同一产品')
product_ids.add(pid)
rid = item.get('id')
scope = item.get('scopeStatus') or item.get('scope_status') or 'active'
if rid:
link = existing.get(rid)
if not link:
raise HTTPException(400, '产品关联明细不属于当前品类')
if rid in seen:
raise HTTPException(400, '产品关联明细 ID 重复')
seen.add(rid)
link.product_id = pid
link.scope_status = scope
link.update_by = user.id
else:
link = PlCertCategoryProduct(
**_audit(user),
category_id=row.id,
product_id=pid,
scope_status=scope,
)
db.add(link)
for rid, link in existing.items():
if rid not in seen:
link.del_flag = 1
link.update_by = user.id
db.flush()
return get_category(db, user, row.id)
def delete_category(db: Session, user, category_id: str):
row = db.query(PlCertCategory).filter_by(
id=category_id, tenant_no=user.tenant_no, del_flag=0,
).with_for_update().populate_existing().first()
if not row:
raise HTTPException(404, '证用品类不存在')
from app.models.pl_quota_models import PlPurchaseCert
used = db.query(PlPurchaseCert.id).filter_by(
tenant_no=user.tenant_no, category_id=category_id, del_flag=0,
).first()
if used:
raise HTTPException(400, '品类已被购买证使用,不能删除')
row.del_flag = 1
row.update_by = user.id
for link in db.query(PlCertCategoryProduct).filter_by(
tenant_no=user.tenant_no, category_id=category_id, del_flag=0,
).with_for_update().all():
link.del_flag = 1
link.update_by = user.id
db.flush()
def category_ids_for_product(db: Session, tenant_no: str, product_id: str):
rows = (
db.query(PlCertCategoryProduct.category_id)
.filter_by(tenant_no=tenant_no, product_id=product_id, del_flag=0, scope_status='active')
.all()
)
return {r.category_id for r in rows}
@@ -0,0 +1,77 @@
"""客户品类额度规则切点:系统信息配置;未配置则拦住安排。"""
from datetime import datetime
from typing import Optional
from uuid import uuid4
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.models.pl_quota_models import PlCertQuotaRuleConfig
RULE_NOT_CONFIGURED = '请先在系统信息中配置客户品类额度生效时间后再安排运输'
VERSION_LEGACY = 'legacy'
VERSION_CUSTOMER_CATEGORY = 'customer_category'
def get_config(db: Session, tenant_no: str) -> Optional[PlCertQuotaRuleConfig]:
return (
db.query(PlCertQuotaRuleConfig)
.filter_by(tenant_no=tenant_no, del_flag=0)
.first()
)
def get_effective_at(db: Session, tenant_no: str):
row = get_config(db, tenant_no)
return row.effective_at if row else None
def read_config(db: Session, tenant_no: str) -> dict:
row = get_config(db, tenant_no)
return {
'effectiveAt': row.effective_at.isoformat(sep=' ', timespec='seconds') if row and row.effective_at else None,
'configured': bool(row and row.effective_at),
}
def save_effective_at(db: Session, user, effective_at: Optional[datetime]) -> dict:
row = get_config(db, user.tenant_no)
if row is None:
row = PlCertQuotaRuleConfig(
id=str(uuid4()),
tenant_no=user.tenant_no,
create_by=user.id,
create_dept_id=getattr(user, 'dept_id', None) or '',
update_by=user.id,
)
db.add(row)
row.effective_at = effective_at
row.update_by = user.id
db.flush()
return read_config(db, user.tenant_no)
def assert_cert_quota_rule_configured(db: Session, tenant_no: str) -> datetime:
effective_at = get_effective_at(db, tenant_no)
if effective_at is None:
raise HTTPException(409, RULE_NOT_CONFIGURED)
return effective_at
def resolve_rule_version_for_create(db: Session, tenant_no: str, create_time: Optional[datetime] = None) -> str:
"""计划创建时打标;未配置生效时间时仍标 legacy(安排会被拦住)。"""
effective_at = get_effective_at(db, tenant_no)
when = create_time or datetime.now()
if effective_at is not None and when >= effective_at:
return VERSION_CUSTOMER_CATEGORY
return VERSION_LEGACY
def stamp_plan_rule_version(db: Session, user, plan) -> str:
version = resolve_rule_version_for_create(db, user.tenant_no, getattr(plan, 'create_time', None) or datetime.now())
plan.cert_quota_rule_version = version
return version
def plan_uses_customer_category(plan) -> bool:
return (getattr(plan, 'cert_quota_rule_version', None) or VERSION_LEGACY) == VERSION_CUSTOMER_CATEGORY
+13 -1
View File
@@ -120,10 +120,22 @@ def apply_plan_quota(db, user, plan, action, payload=None):
evidenceUrls=getattr(payload, 'quota_evidence_urls', None),
sourceNote=getattr(payload, 'quota_source_note', None))
from app.services.pl_quota_waiver import assert_waived_sources, quota_cert_waived
if quota_cert_waived(plan) and action in ('reserve', 'resize', 'settle', 'actual_adjust'):
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
# 新规则:齐备仅放行安排/改派预占;归档与实际调整必须真证结转。
# 旧规则(legacy):齐备仍跳过安排与归档(祖父兼容)。
skip_actions = ('reserve', 'resize')
if not plan_uses_customer_category(plan):
skip_actions = ('reserve', 'resize', 'settle', 'actual_adjust')
if quota_cert_waived(plan) and action in skip_actions:
if action in ('reserve', 'resize'):
assert_waived_sources(db, user, plan, lock=True)
return {'operationId': ledger_id, 'usageIds': [], 'waived': True}
if (
plan_uses_customer_category(plan)
and action in ('settle', 'actual_adjust')
and int(getattr(plan, 'quota_cert_pending_fulfill', 0) or 0) == 1
):
raise HTTPException(409, '该计划仍处于证书齐备待完善,请先在证书齐备中完善并关闭后再归档')
request, config = build_usage_request(
db, user, plan.id, op, action, lock=True, check_conditions=explicit_configuration,
)
+31 -1
View File
@@ -63,6 +63,28 @@ def _refs(db, user, cert_id, lock=False):
return query.order_by(PlTransportCertPurchaseRef.create_time.desc()).all()
def _transport_derived_categories(db, user, transport_cert_id):
from app.models.pl_quota_models import PlPurchaseCert
purchase_ids = [
r.purchase_cert_id
for r in _refs(db, user, transport_cert_id)
if r.purchase_cert_id
]
if not purchase_ids:
return []
rows = (
db.query(PlPurchaseCert.category_id)
.filter(
PlPurchaseCert.tenant_no == user.tenant_no,
PlPurchaseCert.del_flag == 0,
PlPurchaseCert.id.in_(purchase_ids),
PlPurchaseCert.category_id.isnot(None),
)
.all()
)
return list(dict.fromkeys(r.category_id for r in rows if r.category_id))
def get_cert(db, user, cert_type, cert_id, current=False):
row = _get_row(db, user, cert_type, cert_id, lock=current)
result = _row_to_dict(row)
@@ -77,6 +99,7 @@ def get_cert(db, user, cert_type, cert_id, current=False):
if cert_type == 'transport':
result['purchaseRefs'] = [_row_to_dict(r) for r in _refs(db, user, cert_id, lock=current)]
result['referencesEditable'] = not _has_history(db, user, cert_type, cert_id, lock=current)
result['derivedCategoryIds'] = _transport_derived_categories(db, user, cert_id)
return result
@@ -94,7 +117,14 @@ def list_certs(db, user, cert_type, keyword=None, status=None):
))
if status:
query = query.filter(model.status == status)
return [get_cert(db, user, cert_type, r.id) for r in query.order_by(model.create_time.desc()).all()]
else:
# 默认不展示已核销、已作废;列头漏斗显式传 status 时可查看
query = query.filter(model.status.notin_(('closed', 'void')))
result = [get_cert(db, user, cert_type, r.id) for r in query.order_by(model.create_time.desc()).all()]
if cert_type == 'transport':
for item in result:
item['derivedCategoryIds'] = _transport_derived_categories(db, user, item['id'])
return result
def _audit(user):
@@ -0,0 +1,214 @@
"""新规则:按段次客户+品类整笔选证(不拆购买证)。"""
from datetime import date
from decimal import Decimal
from fastapi import HTTPException
from app.models.pl_quota_models import (
PlPurchaseCert,
PlTransportCert,
PlTransportCertPurchaseRef,
PlCertQtyLog,
)
from app.schemas.pl.quota_planning import ManualQuantity
from app.services.pl_cert_category_service import category_ids_for_product
from app.services.pl_quota_units import same_quantity_unit, standard_conversion_ratio
def _available(cert):
return Decimal(cert.total_qty) - Decimal(cert.used_qty) - Decimal(cert.reserved_qty)
def _in_force(cert, on_date: date) -> bool:
if cert.valid_from and on_date < cert.valid_from:
return False
if cert.valid_to and on_date > cert.valid_to:
return False
return True
def _has_opening(db, user, cert_type, cert_id) -> bool:
return (
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()
is not None
)
def _vehicle_ok(cert, vehicle_no: str) -> bool:
plates = cert.vehicle_numbers_json or []
if not isinstance(plates, list):
return False
plate = (vehicle_no or '').strip()
if not plate:
return False
return plate in {str(p).strip() for p in plates if p}
def _permit_qty_for(source_qty: Decimal, source_unit: str, cert_unit: str) -> Decimal:
if same_quantity_unit(source_unit, cert_unit):
return source_qty
ratio = standard_conversion_ratio(source_unit, cert_unit)
if ratio is None:
raise HTTPException(409, f'来源单位「{source_unit}」与证件单位「{cert_unit}」无法自动换算,请调整单位或证件')
return (source_qty * Decimal(str(ratio))).quantize(Decimal('0.000001'))
def _customer_id_from_source(source) -> str:
snap = source.snapshot or {}
cid = snap.get('customerId') or snap.get('customer_id')
if cid:
return str(cid)
raise HTTPException(409, '用证段次缺少客户,无法按客户选证')
def select_whole_cover_certs(db, user, sources, *, vehicle_no: str, on_date: date):
"""返回 purchase_cert_ids, transport_cert_ids, manual_allocations。
每个来源必须由一张购买证整笔覆盖;异地再配一张关联且车号/余量够的运输证。
"""
if not sources:
return [], [], []
purchase_ids = []
transport_ids = []
manuals = []
for source in sources:
customer_id = _customer_id_from_source(source)
categories = category_ids_for_product(db, user.tenant_no, source.product_id)
if not categories:
raise HTTPException(
409,
'产品未配置证用品类,请先在证用品类中关联系统产品后再安排运输',
)
candidates = (
db.query(PlPurchaseCert)
.filter(
PlPurchaseCert.tenant_no == user.tenant_no,
PlPurchaseCert.del_flag == 0,
PlPurchaseCert.status == 'active',
PlPurchaseCert.customer_id == customer_id,
PlPurchaseCert.category_id.in_(categories),
)
.order_by(PlPurchaseCert.valid_to.asc(), PlPurchaseCert.id.asc())
.all()
)
chosen_purchase = None
chosen_transport = None
purchase_permit_qty = None
transport_permit_qty = None
for purchase in candidates:
if not purchase.verified_by or not purchase.verified_time:
continue
if not _has_opening(db, user, 'purchase', purchase.id):
continue
if not _in_force(purchase, on_date):
continue
if purchase.opening_single_use_consumed:
continue
try:
need = _permit_qty_for(Decimal(source.quantity), source.unit, purchase.quantity_unit)
except HTTPException:
continue
if _available(purchase) < need:
continue
if not source.requires_transport:
chosen_purchase = purchase
purchase_permit_qty = need
break
refs = (
db.query(PlTransportCertPurchaseRef)
.filter_by(
tenant_no=user.tenant_no,
purchase_cert_id=purchase.id,
del_flag=0,
)
.all()
)
transport_cert_ids = [r.transport_cert_id for r in refs if r.transport_cert_id]
if not transport_cert_ids:
continue
transports = (
db.query(PlTransportCert)
.filter(
PlTransportCert.tenant_no == user.tenant_no,
PlTransportCert.del_flag == 0,
PlTransportCert.status == 'active',
PlTransportCert.customer_id == customer_id,
PlTransportCert.id.in_(transport_cert_ids),
)
.order_by(PlTransportCert.valid_to.asc(), PlTransportCert.id.asc())
.all()
)
for transport in transports:
if not transport.verified_by or not transport.verified_time:
continue
if not _has_opening(db, user, 'transport', transport.id):
continue
if not _in_force(transport, on_date):
continue
if transport.opening_single_use_consumed:
continue
if not _vehicle_ok(transport, vehicle_no):
continue
try:
t_need = _permit_qty_for(Decimal(source.quantity), source.unit, transport.quantity_unit)
except HTTPException:
continue
if _available(transport) < t_need:
continue
chosen_purchase = purchase
chosen_transport = transport
purchase_permit_qty = need
transport_permit_qty = t_need
break
if chosen_purchase and chosen_transport:
break
if chosen_purchase is None:
place = '同市' if not source.requires_transport else '异地'
raise HTTPException(
409,
f'{place}段找不到余量足够、有效期内且匹配品类/车号的购买证'
f'{"和运输证" if source.requires_transport else ""},请补充证件或标记证书齐备后再安排',
)
purchase_ids.append(chosen_purchase.id)
manuals.append(
ManualQuantity(
permit_kind='purchase',
source_id=source.source_id,
permit_id=chosen_purchase.id,
source_quantity=Decimal(source.quantity),
permit_quantity=purchase_permit_qty,
)
)
if chosen_transport is not None:
transport_ids.append(chosen_transport.id)
manuals.append(
ManualQuantity(
permit_kind='transport',
source_id=source.source_id,
permit_id=chosen_transport.id,
source_quantity=Decimal(source.quantity),
permit_quantity=transport_permit_qty,
)
)
def unique(values):
return list(dict.fromkeys(values))
return unique(purchase_ids), unique(transport_ids), manuals
+42 -15
View File
@@ -131,28 +131,52 @@ def build_usage_request(db, user, plan_id, operation, action, *, lock=False, che
sources, actuals = [], {}
else:
sources, actuals = extract_sources(db, user, plan_id, mapping, actual=action in ('settle', 'actual_adjust'), lock=lock, preview_vehicle=preview_vehicle, measurement_version=config.measurement_version)
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
customer_rule = plan_uses_customer_category(plan)
if action in ('reserve', 'resize') and not explicit and sources:
purchase_ids, transport_ids = resolve_contract_bound_certs(db, user, sources)
source_ids = {source.source_id for source in sources}
allowed = {('purchase', cert_id) for cert_id in purchase_ids} | {('transport', cert_id) for cert_id in transport_ids}
manual = [
row for row in config.manual_allocations
if row.source_id in source_ids and (row.permit_kind, row.permit_id) in allowed
]
config = config.model_copy(update={
'purchase_cert_ids': purchase_ids,
'transport_cert_ids': transport_ids,
'manual_allocations': manual,
'conditions': [],
})
if customer_rule:
# 新规则:禁止手工选证;按客户+品类整笔自动选证
from app.services.pl_quota_customer_select import select_whole_cover_certs
vehicle = (preview_vehicle or {}).get('licensePlate', plan.license_plate) or ''
on_date = max(date.today(), plan.plan_date or date.today())
purchase_ids, transport_ids, manuals = select_whole_cover_certs(
db, user, sources, vehicle_no=vehicle, on_date=on_date,
)
config = config.model_copy(update={
'purchase_cert_ids': purchase_ids,
'transport_cert_ids': transport_ids,
'manual_allocations': manuals,
'conditions': [],
})
else:
purchase_ids, transport_ids = resolve_contract_bound_certs(db, user, sources)
source_ids = {source.source_id for source in sources}
allowed = {('purchase', cert_id) for cert_id in purchase_ids} | {('transport', cert_id) for cert_id in transport_ids}
manual = [
row for row in config.manual_allocations
if row.source_id in source_ids and (row.permit_kind, row.permit_id) in allowed
]
config = config.model_copy(update={
'purchase_cert_ids': purchase_ids,
'transport_cert_ids': transport_ids,
'manual_allocations': manual,
'conditions': [],
})
elif action in ('reserve', 'resize') and explicit and customer_rule:
raise HTTPException(409, '新规则下安排仅支持自动选证,请勿手工指定证件;选不到请标记证书齐备')
keys = [('purchase', cid) for cid in config.purchase_cert_ids] + [('transport', cid) for cid in config.transport_cert_ids]
if len(set(keys)) != len(keys):
raise HTTPException(400, '证件选择重复')
if action in ('reserve', 'resize') or op.allow_supplement:
certs = {key: _get_row(db, user, *key, lock=False) for key in sorted(keys)}
if sources and not keys:
raise HTTPException(409, '请先在合同中绑定购买证和运输证后再安排运输')
if check_conditions and explicit:
msg = (
'找不到适用的购买证和运输证,请补充证件或标记证书齐备后再安排运输'
if customer_rule else
'请先在合同中绑定购买证和运输证后再安排运输'
)
raise HTTPException(409, msg)
if check_conditions and explicit and not customer_rule:
validate_conditions(config, certs, plan_fingerprint(sources))
executed = bool(plan_execution_lock_reason(db, user.tenant_no, plan.id))
request = UsageRequest(
@@ -201,8 +225,11 @@ def preview_plan(db, user, plan_id, config, preview_vehicle=None):
def save_configuration(db, user, plan_id, config):
from app.services.pl_plan_service import record_plan_change
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
config = config if isinstance(config, PlanQuotaConfiguration) else PlanQuotaConfiguration.model_validate(config)
plan = _plan(db, user, plan_id, lock=True)
if plan_uses_customer_category(plan):
raise HTTPException(409, '新规则下安排仅自动选证,不支持手工保存用证配置')
if plan.transport_status == 'arranged' or plan.archive_status == 'archived':
raise HTTPException(409, '已安排计划请通过调整运输在同一事务重算用证')
# 草稿允许缺少条件确认,但真实来源身份及选证权限必须有效。
+3 -1
View File
@@ -159,7 +159,9 @@ def extract_sources(db, user, plan_id, mapping=None, *, actual=False, lock=False
same_city = int(getattr(stop, 'same_city', 0) or 0) == 1
snapshot = {'source': _facts(row), 'related': [_facts(r) for r in related], 'stops': stop_facts,
'vehicle': plan.license_plate, 'trailer': plan.trailer_no, 'planType': plan.plan_type,
'sameCity': 1 if same_city else 0}
'sameCity': 1 if same_city else 0,
'customerId': getattr(stop, 'customer_id', None),
'customerName': getattr(stop, 'customer_name', None)}
if measurement_version == 'per_stop_v2':
snapshot['measurementVersion'] = measurement_version
if preview_vehicle is not None:
+104 -1
View File
@@ -290,13 +290,19 @@ def mark_ready(db, user, plan_id):
plan.quota_cert_waived_by = user.id
plan.quota_cert_waived_by_name = name[:100] if name else None
plan.quota_cert_waived_time = datetime.now()
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
if plan_uses_customer_category(plan):
plan.quota_cert_pending_fulfill = 1
change_content = "标记证书齐备:可安排运输;归档前须在证书齐备中完善并关闭"
else:
change_content = "标记证书齐备:安排和归档不再校验、不扣购买证与运输证额度"
plan.update_by = user.id
record_plan_change(
db,
tenant_no=user.tenant_no,
plan_id=plan.id,
change_type="quota_cert_waived",
change_content="标记证书齐备:安排和归档不再校验、不扣购买证与运输证额度",
change_content=change_content,
user_id=user.id,
user_name=plan.quota_cert_waived_by_name,
dept_id=user.dept_id or "",
@@ -304,3 +310,100 @@ def mark_ready(db, user, plan_id):
db.flush()
labels = _product_names(db, user, [plan.id])
return _row(plan, labels.get(plan.id, ""))
def list_pending_fulfill_plans(db, user, page=1, page_size=20, keyword=""):
"""新规则下已齐备、待完善挂证的计划。"""
from app.services.pl_cert_quota_rule_config_service import VERSION_CUSTOMER_CATEGORY
query = db.query(PlPlan).filter(
PlPlan.tenant_no == user.tenant_no,
PlPlan.del_flag == 0,
PlPlan.follow_status != "cancelled",
PlPlan.archive_status != "cancelled",
PlPlan.cert_quota_rule_version == VERSION_CUSTOMER_CATEGORY,
PlPlan.quota_cert_pending_fulfill == 1,
PlPlan.quota_cert_waived == 1,
)
query = apply_pl_scope(query, PlPlan, user, db, user.tenant_no)
if keyword:
query = query.filter(or_(
PlPlan.plan_no.contains(keyword, autoescape=True),
PlPlan.supplier_name.contains(keyword, autoescape=True),
PlPlan.customer_name.contains(keyword, autoescape=True),
))
total = query.count()
rows = (
query.order_by(PlPlan.plan_date.is_(None), PlPlan.plan_date.asc(), PlPlan.id.asc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
labels = _product_names(db, user, [row.id for row in rows])
return {"total": total, "list": [_row(plan, labels.get(plan.id, "")) for plan in rows]}
def fulfill_plan_certs(db, user, plan_id, *, operation_id=None):
"""完善并关闭:按安排同款自动选证并 reserve,成功后出队。"""
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
from app.services.pl_plan_service import record_plan_change
from app.services.pl_quota_business import apply_plan_quota
from app.services.pl_quota_planning import _plan
from types import SimpleNamespace
plan = _plan(db, user, plan_id, lock=True)
if not plan_uses_customer_category(plan):
raise HTTPException(409, '仅新规则计划支持完善并关闭')
if int(getattr(plan, 'quota_cert_pending_fulfill', 0) or 0) != 1:
raise HTTPException(409, '该计划不在证书齐备待完善列表中')
if plan.follow_status == 'cancelled' or plan.archive_status in ('cancelled', 'archived'):
raise HTTPException(409, '已作废或已归档的计划不能完善证件')
# 暂时清齐备跳过,以便真正 reserve;失败则恢复 pending
plan.quota_cert_waived = 0
payload = SimpleNamespace(
operation_id=operation_id or str(__import__('uuid').uuid4()),
quota_configuration=None,
quota_allow_supplement=False,
quota_manual_actuals=[],
quota_reason='fulfill_quota_certs',
quota_evidence_urls=None,
quota_source_note='证书齐备完善并关闭',
)
try:
action = 'resize' if plan.transport_status == 'arranged' else 'reserve'
apply_plan_quota(db, user, plan, action, payload)
except Exception:
plan.quota_cert_waived = 1
raise
plan.quota_cert_pending_fulfill = 0
plan.quota_cert_waived = 0
plan.update_by = user.id
record_plan_change(
db,
tenant_no=user.tenant_no,
plan_id=plan.id,
change_type='quota_cert_fulfilled',
change_content='证书齐备完善并关闭:已挂载购买证/运输证并完成额度预占',
user_id=user.id,
user_name=((getattr(user, 'name', None) or '')[:100] or None),
dept_id=user.dept_id or '',
)
db.flush()
labels = _product_names(db, user, [plan.id])
return _row(plan, labels.get(plan.id, ''))
def fulfill_plans_batch(db, user, plan_ids):
results = []
for plan_id in plan_ids:
try:
with db.begin_nested():
row = fulfill_plan_certs(db, user, plan_id)
results.append({'planId': plan_id, 'ok': True, 'plan': row})
except HTTPException as exc:
results.append({'planId': plan_id, 'ok': False, 'message': str(exc.detail)})
except Exception as exc:
results.append({'planId': plan_id, 'ok': False, 'message': str(exc)})
return {'results': results}
@@ -0,0 +1,302 @@
#!/usr/bin/env python3
"""回填购买证/运输证的客户与证用品类(客户+品类额度改造)。
对已有证件:
1. 按证面品名 product_name_on_cert 去重创建 pl_cert_category(已存在同名则复用)
2. 购买证 category_id ← 与证面品名同名的品类
3. 购买证 customer_id ← buyer_name 精确匹配 crm_customer.customer_name
4. 运输证 customer_id ← 优先非内部客户的收货方 consignee_name,否则 shipper_name
5. 可选:品类关联系统产品(仅 product_name 与品类名完全一致)
不自动:
- 模糊归并「硫酸」与「98% 工业硫酸」为同一品类(需人工合并)
- 模糊挂接相近产品名(默认只精确匹配;可用 --link-exact-products)
用法:
cd backend && source venv/bin/activate
PYTHONPATH=. python scripts/backfill_pl_cert_customer_category.py --dry-run
PYTHONPATH=. python scripts/backfill_pl_cert_customer_category.py --apply
PYTHONPATH=. python scripts/backfill_pl_cert_customer_category.py --apply --link-exact-products
环境:读取 backend/.env 的 DATABASE_URL(可用 ENV_FILE 覆盖路径)。
"""
from __future__ import annotations
import argparse
import os
import sys
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional, Tuple
from urllib.parse import unquote, urlparse
from uuid import uuid4
BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT))
INTERNAL_HINTS = ('山峡', '云栋')
def load_database_url() -> str:
env_file = os.environ.get('ENV_FILE') or str(BACKEND_ROOT / '.env')
text = Path(env_file).read_text(encoding='utf-8')
for line in text.splitlines():
line = line.strip()
if not line or line.startswith('#'):
continue
if line.startswith('DATABASE_URL='):
return line.split('=', 1)[1].strip().strip('"').strip("'")
raise SystemExit(f'DATABASE_URL not found in {env_file}')
def connect():
import pymysql
raw = load_database_url().replace('mysql+pymysql://', 'mysql://', 1)
p = urlparse(raw)
return pymysql.connect(
host=p.hostname or '127.0.0.1',
port=p.port or 3306,
user=unquote(p.username or ''),
password=unquote(p.password or ''),
database=(p.path or '/').lstrip('/'),
charset='utf8mb4',
autocommit=False,
)
def is_internal_name(name: Optional[str]) -> bool:
text = (name or '').strip()
return any(h in text for h in INTERNAL_HINTS)
def normalize_name(value: Optional[str]) -> str:
return (value or '').strip()
def main() -> int:
parser = argparse.ArgumentParser(description='Backfill cert customer_id / category_id')
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument('--dry-run', action='store_true', help='只打印计划,不写库')
mode.add_argument('--apply', action='store_true', help='写入数据库')
parser.add_argument('--tenant', default='shanxia2.0', help='租户编号')
parser.add_argument(
'--link-exact-products',
action='store_true',
help='把品类名与 CRM 产品名完全一致的产品写入 pl_cert_category_product',
)
args = parser.parse_args()
apply = bool(args.apply)
tenant = args.tenant
now = datetime.now().replace(microsecond=0)
conn = connect()
cur = conn.cursor()
try:
# --- load customers ---
cur.execute(
"SELECT id, customer_name FROM crm_customer WHERE tenant_no=%s AND del_flag=0",
(tenant,),
)
customer_by_name: Dict[str, str] = {}
dup_names = set()
for cid, cname in cur.fetchall():
key = normalize_name(cname)
if not key:
continue
if key in customer_by_name and customer_by_name[key] != cid:
dup_names.add(key)
else:
customer_by_name[key] = cid
for key in dup_names:
customer_by_name.pop(key, None)
# --- existing categories ---
cur.execute(
"SELECT id, category_name FROM pl_cert_category WHERE tenant_no=%s AND del_flag=0",
(tenant,),
)
category_by_name = {normalize_name(n): i for i, n in cur.fetchall()}
# --- distinct cert face names ---
cur.execute(
"""
SELECT DISTINCT product_name_on_cert FROM (
SELECT product_name_on_cert FROM pl_purchase_cert
WHERE tenant_no=%s AND del_flag=0
UNION
SELECT product_name_on_cert FROM pl_transport_cert
WHERE tenant_no=%s AND del_flag=0
) t WHERE product_name_on_cert IS NOT NULL AND TRIM(product_name_on_cert) <> ''
""",
(tenant, tenant),
)
face_names = [normalize_name(r[0]) for r in cur.fetchall() if normalize_name(r[0])]
print(f'tenant={tenant} apply={apply} face_names={face_names}')
print(f'crm_customers={len(customer_by_name)} ambiguous_names={sorted(dup_names)[:10]}')
created_categories = []
for name in face_names:
if name in category_by_name:
continue
cid = str(uuid4())
created_categories.append((cid, name))
category_by_name[name] = cid
print(f' [category+] {name} -> {cid}')
if apply:
cur.execute(
"""
INSERT INTO pl_cert_category
(id, category_name, status, tenant_no, create_dept_id, create_time, update_time, del_flag)
VALUES (%s,%s,'1',%s,'',%s,%s,0)
""",
(cid, name, tenant, now, now),
)
# --- purchase backfill ---
cur.execute(
"""
SELECT id, cert_no, product_name_on_cert, buyer_name, customer_id, category_id, status
FROM pl_purchase_cert WHERE tenant_no=%s AND del_flag=0
""",
(tenant,),
)
purchase_rows = cur.fetchall()
purchase_ok = purchase_miss_customer = purchase_miss_category = 0
for pid, cert_no, face, buyer, cust_id, cat_id, status in purchase_rows:
face_n = normalize_name(face)
buyer_n = normalize_name(buyer)
new_cat = category_by_name.get(face_n)
new_cust = customer_by_name.get(buyer_n)
updates = {}
if not cat_id and new_cat:
updates['category_id'] = new_cat
if not cust_id and new_cust:
updates['customer_id'] = new_cust
if updates:
print(
f' [purchase] {cert_no} status={status} face={face_n!r} buyer={buyer_n!r} -> {updates}'
)
if apply:
sets = ', '.join(f'{k}=%s' for k in updates)
cur.execute(
f"UPDATE pl_purchase_cert SET {sets}, update_time=%s WHERE id=%s",
(*updates.values(), now, pid),
)
purchase_ok += 1
if not (cust_id or updates.get('customer_id')):
purchase_miss_customer += 1
print(f' [purchase!] {cert_no} 无法匹配客户 buyer={buyer_n!r}')
if not (cat_id or updates.get('category_id')):
purchase_miss_category += 1
# --- transport backfill ---
cur.execute(
"""
SELECT id, cert_no, product_name_on_cert, shipper_name, consignee_name, customer_id, status
FROM pl_transport_cert WHERE tenant_no=%s AND del_flag=0
""",
(tenant,),
)
transport_ok = transport_miss = 0
for tid, cert_no, face, shipper, consignee, cust_id, status in cur.fetchall():
candidates = []
consignee_n = normalize_name(consignee)
shipper_n = normalize_name(shipper)
if consignee_n and not is_internal_name(consignee_n):
candidates.append(('consignee', consignee_n))
if shipper_n and not is_internal_name(shipper_n):
candidates.append(('shipper', shipper_n))
if not candidates:
if consignee_n:
candidates.append(('consignee', consignee_n))
elif shipper_n:
candidates.append(('shipper', shipper_n))
new_cust = None
picked = None
for role, name in candidates:
hit = customer_by_name.get(name)
if hit:
new_cust = hit
picked = (role, name)
break
if not cust_id and new_cust:
print(
f' [transport] {cert_no} status={status} via {picked} -> customer_id={new_cust}'
)
if apply:
cur.execute(
"UPDATE pl_transport_cert SET customer_id=%s, update_time=%s WHERE id=%s",
(new_cust, now, tid),
)
transport_ok += 1
elif not cust_id:
transport_miss += 1
print(
f' [transport!] {cert_no} 无法匹配客户 consignee={consignee_n!r} shipper={shipper_n!r}'
)
# --- optional exact product links ---
product_links = 0
if args.link_exact_products:
cur.execute(
"SELECT id, product_name FROM crm_product WHERE tenant_no=%s AND del_flag=0",
(tenant,),
)
products_by_name: Dict[str, list] = {}
for prid, pname in cur.fetchall():
key = normalize_name(pname)
products_by_name.setdefault(key, []).append(prid)
for cname, category_id in category_by_name.items():
ids = products_by_name.get(cname) or []
if len(ids) != 1:
if ids:
print(f' [product?] 品类 {cname!r} 精确匹配到 {len(ids)} 个产品,跳过')
continue
product_id = ids[0]
cur.execute(
"""
SELECT id FROM pl_cert_category_product
WHERE tenant_no=%s AND category_id=%s AND product_id=%s AND del_flag=0
""",
(tenant, category_id, product_id),
)
if cur.fetchone():
continue
print(f' [product+] 品类 {cname!r} <-> product {product_id}')
product_links += 1
if apply:
cur.execute(
"""
INSERT INTO pl_cert_category_product
(id, category_id, product_id, scope_status, tenant_no, create_dept_id,
create_time, update_time, del_flag)
VALUES (%s,%s,%s,'active',%s,'',%s,%s,0)
""",
(str(uuid4()), category_id, product_id, tenant, now, now),
)
print('--- summary ---')
print(f'categories_created={len(created_categories)}')
print(f'purchase_updated={purchase_ok} miss_customer={purchase_miss_customer} miss_category={purchase_miss_category}')
print(f'transport_updated={transport_ok} miss_customer={transport_miss}')
print(f'product_links={product_links}')
if apply:
conn.commit()
print('COMMITTED')
else:
conn.rollback()
print('DRY-RUN (rolled back)')
return 0
except Exception:
conn.rollback()
raise
finally:
cur.close()
conn.close()
if __name__ == '__main__':
raise SystemExit(main())
@@ -0,0 +1,343 @@
#!/usr/bin/env python3
"""为客户+品类证件额度联调造数(测试库)。
造:客户、报价(accepted)、合同(executing)+ 合同产品、
2 张购买证(10吨早到期 / 100吨晚到期)、2 张运输证(引用对应购买证)、期初 opening。
不造:品类↔系统产品关联(由用户在界面配置)。
用法:
cd backend && source venv/bin/activate
PYTHONPATH=. python scripts/seed_pl_customer_cert_test_data.py --dry-run
PYTHONPATH=. python scripts/seed_pl_customer_cert_test_data.py --apply
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import date, datetime, timedelta
from decimal import Decimal
from pathlib import Path
from urllib.parse import unquote, urlparse
from uuid import uuid4
BACKEND_ROOT = Path(__file__).resolve().parents[1]
TENANT = 'shanxia2.0'
# 固定前缀,便于识别/清理
TAG = 'PL-CERT-SEED-20261114'
CUSTOMER_NAME = 'PL证件额度测试客户'
PRODUCT_ID = '339E64E02E7C41F3A04AACCE71E699BE' # 98% 工业硫酸 is_toxic=1 on
OWNER_USER_ID = '02584218205246EEB5F2AC96DF465A9D'
OWNER_NAME = '方金强'
SELLER = '安庆市山峡化工有限公司'
VEHICLE = '皖A9TEST1'
def load_url():
env_file = os.environ.get('ENV_FILE') or str(BACKEND_ROOT / '.env')
for line in Path(env_file).read_text(encoding='utf-8').splitlines():
if line.startswith('DATABASE_URL='):
return line.split('=', 1)[1].strip().strip('"').strip("'")
raise SystemExit('DATABASE_URL missing')
def connect():
import pymysql
raw = load_url().replace('mysql+pymysql://', 'mysql://', 1)
p = urlparse(raw)
return pymysql.connect(
host=p.hostname, port=p.port or 3306,
user=unquote(p.username or ''), password=unquote(p.password or ''),
database=(p.path or '/').lstrip('/'), charset='utf8mb4', autocommit=False,
)
def uid(suffix: str) -> str:
# 稳定 ID,重复执行可先删再插
return f'{TAG}-{suffix}'
def main() -> int:
parser = argparse.ArgumentParser()
g = parser.add_mutually_exclusive_group(required=True)
g.add_argument('--dry-run', action='store_true')
g.add_argument('--apply', action='store_true')
args = parser.parse_args()
apply = args.apply
now = datetime.now().replace(microsecond=0)
today = date.today()
ids = {
'customer': uid('customer'),
'quote': uid('quote'),
'quote_product': uid('quote-prod'),
'contract': uid('contract'),
'contract_product': uid('contract-prod'),
'purchase_early': uid('purchase-10t'),
'purchase_late': uid('purchase-100t'),
'transport_early': uid('transport-10t'),
'transport_late': uid('transport-100t'),
'ref_early': uid('ref-10t'),
'ref_late': uid('ref-100t'),
'open_pe': uid('open-p-10t'),
'open_pl': uid('open-p-100t'),
'open_te': uid('open-t-10t'),
'open_tl': uid('open-t-100t'),
}
conn = connect()
cur = conn.cursor()
try:
# category for 98% 工业硫酸
cur.execute(
"SELECT id FROM pl_cert_category WHERE tenant_no=%s AND del_flag=0 AND category_name=%s",
(TENANT, '98% 工业硫酸'),
)
row = cur.fetchone()
if not row:
raise SystemExit('缺少品类「98% 工业硫酸」,请先跑 backfill 或手工创建')
category_id = row[0]
cur.execute(
"SELECT product_no, product_name, unit, COALESCE(price,1000) FROM crm_product WHERE id=%s AND del_flag=0",
(PRODUCT_ID,),
)
prod = cur.fetchone()
if not prod:
raise SystemExit(f'产品不存在: {PRODUCT_ID}')
product_no, product_name, unit, price = prod
price = Decimal(price)
qty = Decimal('100')
subtotal = (price * qty).quantize(Decimal('0.01'))
print('=== seed plan ===')
print(f'customer={CUSTOMER_NAME} id={ids["customer"]}')
print(f'product={product_name} ({PRODUCT_ID}) toxic unit={unit}')
print(f'category_id={category_id}')
print(f'vehicle={VEHICLE}')
print(f'purchase early 10 / late 100 ; transport pair each')
# cleanup previous seed with same ids
cleanup_tables = [
('pl_cert_qty_log', 'id', [ids['open_pe'], ids['open_pl'], ids['open_te'], ids['open_tl']]),
('pl_transport_cert_purchase_ref', 'id', [ids['ref_early'], ids['ref_late']]),
('pl_transport_cert', 'id', [ids['transport_early'], ids['transport_late']]),
('pl_purchase_cert', 'id', [ids['purchase_early'], ids['purchase_late']]),
('crm_contract_product', 'id', [ids['contract_product']]),
('crm_contract', 'id', [ids['contract']]),
('crm_quote_product', 'id', [ids['quote_product']]),
('crm_quote', 'id', [ids['quote']]),
('crm_customer', 'id', [ids['customer']]),
]
for table, col, values in cleanup_tables:
for vid in values:
cur.execute(f"DELETE FROM {table} WHERE {col}=%s", (vid,))
# customer
cur.execute(
"""
INSERT INTO crm_customer
(id, customer_no, customer_name, status, deal_status, owner_user_id, owner_user_name,
tenant_no, create_dept_id, create_by, create_time, update_time, del_flag)
VALUES (%s,%s,%s,'normal','undeal',%s,%s,%s,'',%s,%s,%s,0)
""",
(ids['customer'], f'C-{TAG}', CUSTOMER_NAME, OWNER_USER_ID, OWNER_NAME,
TENANT, OWNER_USER_ID, now, now),
)
print('+ customer')
# quote
quote_no = f'BJ-{TAG}'
cur.execute(
"""
INSERT INTO crm_quote
(id, quote_no, quote_title, quote_type, customer_id, customer_name,
owner_user_id, version, is_latest, total_amount, final_amount,
quote_date, expire_date, status, tenant_no, create_dept_id, create_by,
create_time, update_time, del_flag)
VALUES (%s,%s,%s,'sales',%s,%s,%s,1,1,%s,%s,%s,%s,'accepted',%s,'',%s,%s,%s,0)
""",
(ids['quote'], quote_no, f'{CUSTOMER_NAME}-硫酸报价', ids['customer'], CUSTOMER_NAME,
OWNER_USER_ID, subtotal, subtotal, today, today + timedelta(days=30),
TENANT, OWNER_USER_ID, now, now),
)
cur.execute(
"""
INSERT INTO crm_quote_product
(id, quote_id, product_id, product_name, product_no, unit, quantity, price, discount,
subtotal, sort, tenant_no, create_dept_id, create_by, create_time, update_time, del_flag)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,100,%s,1,%s,'',%s,%s,%s,0)
""",
(ids['quote_product'], ids['quote'], PRODUCT_ID, product_name, product_no, unit,
qty, price, subtotal, TENANT, OWNER_USER_ID, now, now),
)
print('+ quote', quote_no)
# contract
contract_no = f'HT-{TAG}'
cur.execute(
"""
INSERT INTO crm_contract
(id, contract_no, contract_name, customer_id, customer_name, quote_id,
contract_type, amount, currency, tax_rate, status, supplier_id,
party_a, party_b, start_date, end_date, sign_date,
tenant_no, create_dept_id, create_by, create_time, update_time, del_flag)
VALUES (%s,%s,%s,%s,%s,%s,'sale',%s,'CNY',13,'executing','1',%s,%s,%s,%s,%s,
%s,'',%s,%s,%s,0)
""",
(ids['contract'], contract_no, f'{CUSTOMER_NAME}-购销合同', ids['customer'], CUSTOMER_NAME,
ids['quote'], subtotal, SELLER, CUSTOMER_NAME, today, today + timedelta(days=365), today,
TENANT, OWNER_USER_ID, now, now),
)
cur.execute(
"""
INSERT INTO crm_contract_product
(id, contract_id, product_id, product_name, product_no, unit, quantity, price,
tax_rate, subtotal, delivery_status, tenant_no, create_dept_id, create_by,
create_time, update_time, del_flag)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,13,%s,'pending',%s,'',%s,%s,%s,0)
""",
(ids['contract_product'], ids['contract'], PRODUCT_ID, product_name, product_no, unit,
qty, price, subtotal, TENANT, OWNER_USER_ID, now, now),
)
print('+ contract', contract_no, 'product_row', ids['contract_product'])
def insert_purchase(pid, cert_no, total, valid_to, face='98% 工业硫酸'):
cur.execute(
"""
INSERT INTO pl_purchase_cert
(id, cert_no, issue_date, product_name_on_cert, total_qty, quantity_unit,
valid_from, valid_to, valid_use_text, entry_mode, status,
verified_by, verified_time, reserved_qty, used_qty, opening_single_use_consumed,
revision, buyer_name, seller_name, customer_id, category_id,
tenant_no, create_dept_id, create_by, create_time, update_time, del_flag)
VALUES (%s,%s,%s,%s,%s,'dun',%s,%s,'多次有效','manual','active',
%s,%s,0,0,0,1,%s,%s,%s,%s,%s,'',%s,%s,%s,0)
""",
(pid, cert_no, today, face, Decimal(total), today, valid_to,
OWNER_USER_ID, now, CUSTOMER_NAME, SELLER, ids['customer'], category_id,
TENANT, OWNER_USER_ID, now, now),
)
op = str(uuid4())
cur.execute(
"""
INSERT INTO pl_cert_qty_log
(id, operation_id, request_hash, cert_type, cert_id, event_type, quantity_unit,
total_delta, reserved_delta, used_delta,
before_total_qty, after_total_qty, before_reserved_qty, after_reserved_qty,
before_used_qty, after_used_qty, reason, tenant_no, create_dept_id, create_by,
create_time, update_time, del_flag)
VALUES (%s,%s,%s,'purchase',%s,'opening','dun',
0,0,0,%s,%s,0,0,0,0,'seed opening',%s,'',%s,%s,%s,0)
""",
(str(uuid4()) if pid.endswith('10t') else str(uuid4()), op, op, pid,
Decimal(total), Decimal(total), TENANT, OWNER_USER_ID, now, now),
)
# fix opening ids to stable ones
def insert_opening(log_id, cert_type, cert_id, total):
op = log_id
cur.execute(
"""
INSERT INTO pl_cert_qty_log
(id, operation_id, request_hash, cert_type, cert_id, event_type, quantity_unit,
total_delta, reserved_delta, used_delta,
before_total_qty, after_total_qty, before_reserved_qty, after_reserved_qty,
before_used_qty, after_used_qty, reason, tenant_no, create_dept_id, create_by,
create_time, update_time, del_flag)
VALUES (%s,%s,%s,%s,%s,'opening','dun',
0,0,0,%s,%s,0,0,0,0,'seed opening',%s,'',%s,%s,%s,0)
""",
(log_id, op, op, cert_type, cert_id, Decimal(total), Decimal(total),
TENANT, OWNER_USER_ID, now, now),
)
# rewrite purchase insert without nested opening
def insert_purchase2(pid, cert_no, total, valid_to, open_id):
cur.execute(
"""
INSERT INTO pl_purchase_cert
(id, cert_no, issue_date, product_name_on_cert, total_qty, quantity_unit,
valid_from, valid_to, valid_use_text, entry_mode, status,
verified_by, verified_time, reserved_qty, used_qty, opening_single_use_consumed,
revision, buyer_name, seller_name, customer_id, category_id,
tenant_no, create_dept_id, create_by, create_time, update_time, del_flag, remark)
VALUES (%s,%s,%s,'98%% 工业硫酸',%s,'dun',%s,%s,'多次有效','manual','active',
%s,%s,0,0,0,1,%s,%s,%s,%s,%s,'',%s,%s,%s,0,%s)
""",
(pid, cert_no, today, Decimal(total), today, valid_to,
OWNER_USER_ID, now, CUSTOMER_NAME, SELLER, ids['customer'], category_id,
TENANT, OWNER_USER_ID, now, now, TAG),
)
insert_opening(open_id, 'purchase', pid, total)
def insert_transport2(tid, cert_no, total, valid_to, open_id, purchase_id, ref_id):
plates = json.dumps([VEHICLE], ensure_ascii=False)
cur.execute(
"""
INSERT INTO pl_transport_cert
(id, cert_no, issue_date, product_name_on_cert, total_qty, quantity_unit,
valid_from, valid_to, valid_use_text, entry_mode, status,
verified_by, verified_time, reserved_qty, used_qty, opening_single_use_consumed,
revision, shipper_name, consignee_name, vehicle_numbers_json, customer_id,
tenant_no, create_dept_id, create_by, create_time, update_time, del_flag, remark)
VALUES (%s,%s,%s,'98%% 工业硫酸',%s,'dun',%s,%s,'多次有效','manual','active',
%s,%s,0,0,0,1,%s,%s,%s,%s,%s,'',%s,%s,%s,0,%s)
""",
(tid, cert_no, today, Decimal(total), today, valid_to,
OWNER_USER_ID, now, SELLER, CUSTOMER_NAME, plates, ids['customer'],
TENANT, OWNER_USER_ID, now, now, TAG),
)
insert_opening(open_id, 'transport', tid, total)
cur.execute(
"""
INSERT INTO pl_transport_cert_purchase_ref
(id, transport_cert_id, reference_type, reference_no, purchase_cert_id,
tenant_no, create_dept_id, create_by, create_time, update_time, del_flag)
VALUES (%s,%s,'purchase_permit',%s,%s,%s,'',%s,%s,%s,0)
""",
(ref_id, tid, cert_no.replace('Y-', 'G-'), purchase_id, TENANT, OWNER_USER_ID, now, now),
)
early_to = today + timedelta(days=30)
late_to = today + timedelta(days=180)
insert_purchase2(ids['purchase_early'], f'G-{TAG}-10T', 10, early_to, ids['open_pe'])
insert_purchase2(ids['purchase_late'], f'G-{TAG}-100T', 100, late_to, ids['open_pl'])
insert_transport2(
ids['transport_early'], f'Y-{TAG}-10T', 10, early_to, ids['open_te'],
ids['purchase_early'], ids['ref_early'],
)
insert_transport2(
ids['transport_late'], f'Y-{TAG}-100T', 100, late_to, ids['open_tl'],
ids['purchase_late'], ids['ref_late'],
)
print('+ purchase x2 + transport x2 + opening + refs')
print('--- ids ---')
for k, v in ids.items():
print(f'{k}: {v}')
print('--- how to test ---')
print(f'1. 证用品类「98% 工业硫酸」关联产品 {PRODUCT_ID}({product_name})')
print(f'2. 新建运输计划:客户={CUSTOMER_NAME},产品用上述合同行')
print(f'3. 安排车辆请用车号 {VEHICLE}(或把运输证车号改成你的车)')
print('4. 测规则A:计划量 50 吨时,应跳过 10 吨早到期证,选用 100 吨证')
if apply:
conn.commit()
print('COMMITTED')
else:
conn.rollback()
print('DRY-RUN rolled back')
return 0
except Exception:
conn.rollback()
raise
finally:
cur.close()
conn.close()
if __name__ == '__main__':
raise SystemExit(main())
@@ -0,0 +1,196 @@
"""客户+品类证件额度:切点、列表默认过滤、齐备语义、整笔选证。"""
from datetime import date, datetime
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
def test_assert_rule_not_configured_blocks_arrange():
from app.services import pl_cert_quota_rule_config_service as cfg
db = MagicMock()
db.query.return_value.filter_by.return_value.first.return_value = None
with pytest.raises(HTTPException) as exc:
cfg.assert_cert_quota_rule_configured(db, 't1')
assert exc.value.status_code == 409
assert '系统信息' in str(exc.value.detail)
def test_resolve_rule_version_by_effective_at():
from app.services import pl_cert_quota_rule_config_service as cfg
db = MagicMock()
row = SimpleNamespace(effective_at=datetime(2026, 11, 14, 0, 0, 0))
db.query.return_value.filter_by.return_value.first.return_value = row
assert cfg.resolve_rule_version_for_create(db, 't', datetime(2026, 11, 13)) == 'legacy'
assert cfg.resolve_rule_version_for_create(db, 't', datetime(2026, 11, 14)) == 'customer_category'
db.query.return_value.filter_by.return_value.first.return_value = None
assert cfg.resolve_rule_version_for_create(db, 't', datetime(2026, 11, 20)) == 'legacy'
def test_apply_plan_quota_new_rule_waive_skips_reserve_not_settle(monkeypatch):
from app.services import pl_quota_business as biz
plan = SimpleNamespace(
id='p1',
quota_cert_waived=1,
quota_cert_pending_fulfill=1,
cert_quota_rule_version='customer_category',
cert_configuration={},
)
called = {}
def fake_assert(*a, **k):
called['assert'] = True
monkeypatch.setattr('app.services.pl_quota_waiver.assert_waived_sources', fake_assert)
monkeypatch.setattr(
'app.services.pl_cert_quota_rule_config_service.plan_uses_customer_category',
lambda p: True,
)
result = biz.apply_plan_quota(MagicMock(), SimpleNamespace(id='u'), plan, 'reserve', None)
assert result.get('waived') is True
assert called.get('assert') is True
with pytest.raises(HTTPException) as exc:
biz.apply_plan_quota(MagicMock(), SimpleNamespace(id='u'), plan, 'settle', None)
assert '待完善' in str(exc.value.detail)
def test_list_certs_default_excludes_closed_void(monkeypatch):
from app.services import pl_quota_cert_service as svc
class Col:
def __init__(self, name):
self.name = name
class FakeModel:
__tablename__ = 'pl_purchase_cert'
tenant_no = SimpleNamespace()
del_flag = 0
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')])
@staticmethod
def id():
return 'id'
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')
assert any(f == 'STATUS_FILTER' for f in q.filters)
def test_whole_cover_skips_insufficient_earlier_purchase():
"""规则 A:早到期 10 吨不够则跳过,选用晚到期够量的证。"""
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('50'),
unit='吨',
fingerprint='x' * 64,
snapshot={'customerId': 'cust1'},
requires_transport=False,
)
early = SimpleNamespace(
id='buy-early', 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('10'), used_qty=Decimal('0'), reserved_qty=Decimal('0'),
quantity_unit='吨', opening_single_use_consumed=0,
)
late = SimpleNamespace(
id='buy-late', 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,
)
class PurchaseQuery:
def filter(self, *a, **k):
return self
def order_by(self, *a, **k):
return self
def all(self):
return [early, late]
class LogQuery:
def filter_by(self, **k):
return self
def first(self):
return SimpleNamespace(id='opening')
db = MagicMock()
def query(model):
name = getattr(model, '__name__', str(model))
if 'Purchase' in name or model is early.__class__:
return PurchaseQuery()
return LogQuery()
# route by model class name via side_effect
from app.models.pl_quota_models import PlPurchaseCert, PlCertQtyLog
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')
import app.services.pl_quota_customer_select as mod
orig_cat = mod.category_ids_for_product
mod.category_ids_for_product = lambda db, tenant, pid: {'cat1'}
try:
purchase_ids, transport_ids, manuals = sel.select_whole_cover_certs(
db, user, [source], vehicle_no='皖A12345', on_date=date(2026, 11, 14),
)
finally:
mod.category_ids_for_product = orig_cat
assert purchase_ids == ['buy-late']
assert transport_ids == []
assert len(manuals) == 1
assert manuals[0].permit_id == 'buy-late'
assert manuals[0].permit_quantity == Decimal('50')
@@ -0,0 +1,112 @@
-- 客户+品类证件额度:证用品类、规则切点、证件/计划扩展字段。
-- MySQL 5.7。可重复执行。
-- 回滚思路(勿自动执行):停用新规则配置;新表可 DROP;新增列可 ALTER DROP(上线后有数据时先备份)。
SET NAMES utf8mb4;
SET @schema_name = DATABASE();
-- ---------- 证用品类 ----------
CREATE TABLE IF NOT EXISTS `pl_cert_category` (
`id` varchar(64) NOT NULL COMMENT '主键UUID',
`category_name` varchar(128) NOT NULL COMMENT '证用品类名称,如硫酸',
`category_code` varchar(64) DEFAULT NULL COMMENT '品类编码,可选',
`status` char(1) NOT NULL DEFAULT '1' COMMENT '状态 1启用 0停用',
`tenant_no` varchar(64) NOT NULL COMMENT '租户编号',
`order_num` int DEFAULT NULL COMMENT '排序号',
`remark` varchar(1000) DEFAULT NULL COMMENT '备注',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建部门',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`del_flag` smallint NOT NULL DEFAULT 0 COMMENT '软删除 0正常 1删除',
PRIMARY KEY (`id`),
KEY `idx_cert_category_tenant` (`tenant_no`, `del_flag`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='证用品类';
CREATE TABLE IF NOT EXISTS `pl_cert_category_product` (
`id` varchar(64) NOT NULL COMMENT '主键UUID',
`category_id` varchar(64) NOT NULL COMMENT '证用品类ID',
`product_id` varchar(64) NOT NULL COMMENT 'CRM产品ID',
`scope_status` varchar(20) NOT NULL DEFAULT 'active' COMMENT 'active/inactive',
`tenant_no` varchar(64) NOT NULL COMMENT '租户编号',
`order_num` int DEFAULT NULL COMMENT '排序号',
`remark` varchar(1000) DEFAULT NULL COMMENT '备注',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建部门',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`del_flag` smallint NOT NULL DEFAULT 0 COMMENT '软删除 0正常 1删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_cert_category_product` (`tenant_no`, `category_id`, `product_id`),
KEY `idx_cert_cat_prod_product` (`tenant_no`, `product_id`, `del_flag`, `scope_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='证用品类关联系统产品';
-- ---------- 规则切点配置(每租户一行) ----------
CREATE TABLE IF NOT EXISTS `pl_cert_quota_rule_config` (
`id` varchar(64) NOT NULL COMMENT '主键UUID',
`effective_at` datetime DEFAULT NULL COMMENT '客户品类额度生效时间;空表示未配置,安排将被拦住',
`tenant_no` varchar(64) NOT NULL COMMENT '租户编号',
`order_num` int DEFAULT NULL COMMENT '排序号',
`remark` varchar(1000) DEFAULT NULL COMMENT '备注',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建部门',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`del_flag` smallint NOT NULL DEFAULT 0 COMMENT '软删除 0正常 1删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_cert_quota_rule_tenant` (`tenant_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='运输证件额度规则切点';
-- ---------- 购买证扩展 ----------
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_purchase_cert' AND COLUMN_NAME='customer_id');
SET @sql = IF(@col=0,
'ALTER TABLE pl_purchase_cert ADD COLUMN customer_id varchar(64) DEFAULT NULL COMMENT ''系统客户ID;选证必填'' AFTER purchase_purpose',
'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_purchase_cert' AND COLUMN_NAME='category_id');
SET @sql = IF(@col=0,
'ALTER TABLE pl_purchase_cert ADD COLUMN category_id varchar(64) DEFAULT NULL COMMENT ''证用品类ID;选证必填'' AFTER customer_id',
'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @idx = (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_purchase_cert' AND INDEX_NAME='idx_purchase_cert_customer');
SET @sql = IF(@idx=0,
'ALTER TABLE pl_purchase_cert ADD KEY idx_purchase_cert_customer (tenant_no, customer_id, category_id, status, del_flag)',
'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
-- ---------- 运输证扩展 ----------
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_transport_cert' AND COLUMN_NAME='customer_id');
SET @sql = IF(@col=0,
'ALTER TABLE pl_transport_cert ADD COLUMN customer_id varchar(64) DEFAULT NULL COMMENT ''系统客户ID;选证必填'' AFTER applicant_name',
'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @idx = (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_transport_cert' AND INDEX_NAME='idx_transport_cert_customer');
SET @sql = IF(@idx=0,
'ALTER TABLE pl_transport_cert ADD KEY idx_transport_cert_customer (tenant_no, customer_id, status, del_flag)',
'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
-- ---------- 计划规则版本 / 待完善 ----------
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_plan' AND COLUMN_NAME='cert_quota_rule_version');
SET @sql = IF(@col=0,
'ALTER TABLE pl_plan ADD COLUMN cert_quota_rule_version varchar(32) DEFAULT NULL COMMENT ''legacy|customer_category;创建时打标'' AFTER quota_cert_waived_time',
'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_plan' AND COLUMN_NAME='quota_cert_pending_fulfill');
SET @sql = IF(@col=0,
'ALTER TABLE pl_plan ADD COLUMN quota_cert_pending_fulfill tinyint NOT NULL DEFAULT 0 COMMENT ''新规则证书齐备待完善 0否 1是'' AFTER cert_quota_rule_version',
'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
@@ -0,0 +1,5 @@
-- 证用品类、运输证件规则菜单说明(路由已挂 web-admin;若侧栏走库表菜单,按租户补插)。
-- 证用品类:可挂在「运输证件管理」下,path 对齐 /pl/cert-upload/cert-category,权限复用 pl:quota-cert:list / edit。
-- 运输证件规则:可挂在「系统信息」下,path 对齐 /system/info/cert-quota-rule;保存接口要求管理员。
-- 本文件仅文档占位,不强制插入,避免破坏现有菜单树。
SELECT 1;
@@ -0,0 +1,112 @@
-- 客户+品类证件额度:证用品类、规则切点、证件/计划扩展字段。
-- MySQL 5.7。可重复执行。
-- 回滚思路(勿自动执行):停用新规则配置;新表可 DROP;新增列可 ALTER DROP(上线后有数据时先备份)。
SET NAMES utf8mb4;
SET @schema_name = DATABASE();
-- ---------- 证用品类 ----------
CREATE TABLE IF NOT EXISTS `pl_cert_category` (
`id` varchar(64) NOT NULL COMMENT '主键UUID',
`category_name` varchar(128) NOT NULL COMMENT '证用品类名称,如硫酸',
`category_code` varchar(64) DEFAULT NULL COMMENT '品类编码,可选',
`status` char(1) NOT NULL DEFAULT '1' COMMENT '状态 1启用 0停用',
`tenant_no` varchar(64) NOT NULL COMMENT '租户编号',
`order_num` int DEFAULT NULL COMMENT '排序号',
`remark` varchar(1000) DEFAULT NULL COMMENT '备注',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建部门',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`del_flag` smallint NOT NULL DEFAULT 0 COMMENT '软删除 0正常 1删除',
PRIMARY KEY (`id`),
KEY `idx_cert_category_tenant` (`tenant_no`, `del_flag`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='证用品类';
CREATE TABLE IF NOT EXISTS `pl_cert_category_product` (
`id` varchar(64) NOT NULL COMMENT '主键UUID',
`category_id` varchar(64) NOT NULL COMMENT '证用品类ID',
`product_id` varchar(64) NOT NULL COMMENT 'CRM产品ID',
`scope_status` varchar(20) NOT NULL DEFAULT 'active' COMMENT 'active/inactive',
`tenant_no` varchar(64) NOT NULL COMMENT '租户编号',
`order_num` int DEFAULT NULL COMMENT '排序号',
`remark` varchar(1000) DEFAULT NULL COMMENT '备注',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建部门',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`del_flag` smallint NOT NULL DEFAULT 0 COMMENT '软删除 0正常 1删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_cert_category_product` (`tenant_no`, `category_id`, `product_id`),
KEY `idx_cert_cat_prod_product` (`tenant_no`, `product_id`, `del_flag`, `scope_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='证用品类关联系统产品';
-- ---------- 规则切点配置(每租户一行) ----------
CREATE TABLE IF NOT EXISTS `pl_cert_quota_rule_config` (
`id` varchar(64) NOT NULL COMMENT '主键UUID',
`effective_at` datetime DEFAULT NULL COMMENT '客户品类额度生效时间;空表示未配置,安排将被拦住',
`tenant_no` varchar(64) NOT NULL COMMENT '租户编号',
`order_num` int DEFAULT NULL COMMENT '排序号',
`remark` varchar(1000) DEFAULT NULL COMMENT '备注',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建部门',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`del_flag` smallint NOT NULL DEFAULT 0 COMMENT '软删除 0正常 1删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_cert_quota_rule_tenant` (`tenant_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='运输证件额度规则切点';
-- ---------- 购买证扩展 ----------
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_purchase_cert' AND COLUMN_NAME='customer_id');
SET @sql = IF(@col=0,
'ALTER TABLE pl_purchase_cert ADD COLUMN customer_id varchar(64) DEFAULT NULL COMMENT ''系统客户ID;选证必填'' AFTER purchase_purpose',
'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_purchase_cert' AND COLUMN_NAME='category_id');
SET @sql = IF(@col=0,
'ALTER TABLE pl_purchase_cert ADD COLUMN category_id varchar(64) DEFAULT NULL COMMENT ''证用品类ID;选证必填'' AFTER customer_id',
'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @idx = (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_purchase_cert' AND INDEX_NAME='idx_purchase_cert_customer');
SET @sql = IF(@idx=0,
'ALTER TABLE pl_purchase_cert ADD KEY idx_purchase_cert_customer (tenant_no, customer_id, category_id, status, del_flag)',
'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
-- ---------- 运输证扩展 ----------
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_transport_cert' AND COLUMN_NAME='customer_id');
SET @sql = IF(@col=0,
'ALTER TABLE pl_transport_cert ADD COLUMN customer_id varchar(64) DEFAULT NULL COMMENT ''系统客户ID;选证必填'' AFTER applicant_name',
'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @idx = (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_transport_cert' AND INDEX_NAME='idx_transport_cert_customer');
SET @sql = IF(@idx=0,
'ALTER TABLE pl_transport_cert ADD KEY idx_transport_cert_customer (tenant_no, customer_id, status, del_flag)',
'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
-- ---------- 计划规则版本 / 待完善 ----------
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_plan' AND COLUMN_NAME='cert_quota_rule_version');
SET @sql = IF(@col=0,
'ALTER TABLE pl_plan ADD COLUMN cert_quota_rule_version varchar(32) DEFAULT NULL COMMENT ''legacy|customer_category;创建时打标'' AFTER quota_cert_waived_time',
'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_plan' AND COLUMN_NAME='quota_cert_pending_fulfill');
SET @sql = IF(@col=0,
'ALTER TABLE pl_plan ADD COLUMN quota_cert_pending_fulfill tinyint NOT NULL DEFAULT 0 COMMENT ''新规则证书齐备待完善 0否 1是'' AFTER cert_quota_rule_version',
'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
@@ -0,0 +1,227 @@
# 客户+品类证件额度 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `.agents/skills/subagent-driven-development/SKILL.md`(推荐)或 `.agents/skills/executing-plans/SKILL.md` 按任务推进。步骤用 checkbox(`- [ ]`)跟踪。
> **设计权威:** `docs/superpowers/specs/2026-11-14-pl-customer-cert-quota-design.md`
> **硬性闸门:** 每期全部 checkbox 完成并验证后,必须停下来等用户回复「开始第N期」再继续;未经确认不得进入下一期或 commit(除非用户当轮明确要求提交)。
**Goal:** 将运输计划购买证/运输证从合同产品绑定改为客户+证用品类自动选证,并落地可配置切点与齐备完善流程。
**Architecture:** 保留 `pl_plan_cert_usage` 账本;新建证用品类与规则配置表;证件增加 `customer_id`/`category_id`;新规则下用证来源按段次外部客户提取,选证改为整笔贪心(到期早且余量够优先);`quota_cert_waived` 仅放行安排,归档与完善走真证 reserve/settle。
**Tech Stack:** Python 3.9 / FastAPI / SQLAlchemy / Pydantic v2;Vue 3 / Element Plus / pnpm;MySQL 5.7;pytest。
---
## 文件地图(将创建或重点修改)
| 区域 | 路径 |
| --- | --- |
| SQL | `docs/sql/2026-11-14-pl-customer-cert-quota.sql`(及 migrations 副本若仓库惯例需要) |
| 模型 | `backend/app/models/pl_quota_models.py`、`pl_models.py` |
| 选证/账本 | `backend/app/services/pl_quota_sources.py`、`pl_quota_planning.py`、`pl_quota_allocation.py`、`pl_quota_usage_service.py`、`pl_quota_business.py`、`pl_quota_waiver.py`、新建 `pl_quota_customer_select.py`(建议)、`pl_cert_category_service.py`、`pl_cert_quota_rule_config_service.py` |
| API | `backend/app/api/pl/quota_cert.py`、`transport.py`、`plan.py`;系统配置 API(新建或挂 system) |
| 测试 | `backend/tests/test_pl_quota_*.py` 扩展;新建 `test_pl_customer_cert_quota.py` |
| Web | `web-admin/src/views/pl/` 品类页、quota-cert、证书齐备、系统信息 Tab、计划详情/查看组件;去掉安排手工用证入口 |
| 路由/菜单 | `web-admin/src/router/modules/sqxp.ts`、`pl.ts`;菜单 SQL 若需要 |
---
## 一期:主数据 + 切点配置 + 列表默认过滤
### Task 1.1:DDL
**Files:**
- Create: `docs/sql/2026-11-14-pl-customer-cert-quota.sql`
- [ ] **Step 1:** 编写可重复执行的 MySQL 5.7 迁移,包含:
- `pl_cert_category`
- `pl_cert_category_product`
- `pl_cert_quota_rule_config`(tenant_no + effective_at + 审计)
- `pl_purchase_cert.customer_id`、`category_id`(可空,兼容历史)
- `pl_transport_cert.customer_id`(可空)
- `pl_plan.cert_quota_rule_version` VARCHAR(可空/默认 legacy)
- `pl_plan.quota_cert_pending_fulfill` TINYINT 默认 0(或设计等价字段)
- [ ] **Step 2:** 字段注释与 AGENTS 字段序一致;索引含 tenant_no + 常用筛选。
- [ ] **Step 3:** 在设计实现备注中列出回滚思路(DROP/列删除仅文档说明,不自动执行破坏性回滚)。
### Task 1.2:模型与品类 CRUD API
**Files:**
- Modify: `backend/app/models/pl_quota_models.py`、`pl_models.py`
- Create: `backend/app/services/pl_cert_category_service.py`
- Modify: `backend/app/api/pl/quota_cert.py` 或新建 `quota_category.py` 并注册到 `api/__init__.py`、`main.py`
- Create: schemas under `backend/app/schemas/pl/`
- [ ] **Step 1:** 增加 SQLAlchemy 模型与 Pydantic schema(camelCase alias)。
- [ ] **Step 2:** 品类 CRUD + 关联产品差量保存(遵守 child-data-save-rules)。
- [ ] **Step 3:** pytest:创建品类、关联两产品、停用关联、租户隔离。
- [ ] **Step 4:** 证件 create/update 接受 `customerId`/`categoryId`;运输证品类由引用推导的只读接口字段。
### Task 1.3:规则配置 API + 安排门禁(未配置则拦)
**Files:**
- Create: `backend/app/services/pl_cert_quota_rule_config_service.py`
- Modify: `backend/app/api/pl/transport.py`(arrange 入口最前)
- Modify: 计划创建服务(写入 `cert_quota_rule_version`)
- [ ] **Step 1:** GET/PUT 当前租户 `effective_at`。
- [ ] **Step 2:** `assert_cert_quota_rule_configured`:`effective_at is None` → HTTP 409 固定中文文案。
- [ ] **Step 3:** 计划创建时按设计 §7 打 `cert_quota_rule_version`。
- [ ] **Step 4:** pytest:未配置安排失败;配置后创建计划版本正确。
### Task 1.4:证件列表默认过滤
**Files:**
- Modify: `backend/app/services/pl_quota_cert_service.py` list 默认
- Modify: `web-admin/src/views/pl/quota-cert/` 状态漏斗
- [ ] **Step 1:** 列表 API 默认 `status not in (closed, void)`;显式传 status 时以参数为准。
- [ ] **Step 2:** 前端列头漏斗可选到已核销/作废;默认不选。
- [ ] **Step 3:** 回归测试或前端约定注释。
### Task 1.5:Web 品类页 + 系统信息 Tab + 证件表单字段
**Files:**
- Create: `web-admin/src/views/pl/cert-category/index.vue`(或等价路径)
- Modify: `web-admin/src/router/modules/pl.ts`、`sqxp.ts`
- Modify: 证件建档表单(含 H5 `m/pl/plan-cert` 若同表单模型)
- [ ] **Step 1:** 品类列表/编辑/关联产品(Fresh Mint,选 P 模式并在说明中写明)。
- [ ] **Step 2:** 系统信息下 Tab「运输证件规则」编辑生效时间。
- [ ] **Step 3:** 购买证/运输证表单增加客户、品类(运输证展示派生品类)。
- [ ] **Step 4:** 菜单 SQL/权限码文档化(若需插入菜单,写 SQL 片段)。
### Task 1.6:计划详情展示位(可读)
**Files:**
- Modify: `web-admin/src/components/business/pl-plan-view-tabs/` 或等价详情
- Modify: 计划详情 API 若需返回 usage 证件摘要
- [ ] **Step 1:** 详情区展示已关联购买证/运输证(无数据时占位「暂无」)。
- [ ] **Step 2:** 同市来源可只显示购买证。
### 一期完成门禁
- [ ] 本地执行相关 pytest,记录命令与结果。
- [ ] `pnpm build:typecheck`(若改了 web-admin)或说明未跑原因。
- [ ] **停止并等待用户:「开始第二期」**
---
## 二期:安排/归档新选证 + 祖父条款
### Task 2.1:用证来源按段客户(D6/D7/D8/D9)
**Files:**
- Modify: `backend/app/services/pl_quota_sources.py`
- [ ] **Step 1:** 外部装/送段均进入来源(非仅单侧计量吞掉对侧额度需求——按设计 D7);内部山峡/云栋过滤保留。
- [ ] **Step 2:** `same_city` → `requires_transport=False` 保留。
- [ ] **Step 3:** 来源快照写入 `customer_id`/`customer_name`。
- [ ] **Step 4:** pytest 覆盖双外部段、单侧内部、同市。
### Task 2.2:整笔贪心选证模块
**Files:**
- Create: `backend/app/services/pl_quota_customer_select.py`
- Modify: `pl_quota_allocation.py` 或在 `execute_usage` 分支按 rule_version 调用
- [ ] **Step 1:** 实现设计 §5 伪代码;单测:10 吨跳过、100 吨命中;无运输证跳过该购买证;同市无运输证成功。
- [ ] **Step 2:** 新规则路径禁止拆分到多购买证。
- [ ] **Step 3:** legacy 计划不走此模块(祖父)。
### Task 2.3:替换合同绑证为客户品类候选
**Files:**
- Modify: `pl_quota_planning.py`、`pl_quota_business.py`、`pl_quota_usage_service.py`
- Modify: `backend/app/api/pl/transport.py`、前端安排提交
- [ ] **Step 1:** `customer_category` 版本:`build_usage_request` 不再 `resolve_contract_bound_certs`。
- [ ] **Step 2:** 安排失败文案引导证书齐备;移除/隐藏手工 `quotaConfiguration` 安排入口。
- [ ] **Step 3:** 归档 settle 仍走账本;新规则余额不足文案更新为客户/品类语境。
- [ ] **Step 4:** legacy:已安排归档不因缺少客户品类绑证失败(单测)。
### Task 2.4:合同绑证 UI 收敛
**Files:**
- Modify: `web-admin/src/views/pl/contract-quota/` 等
- [ ] **Step 1:** 新规则生效后隐藏或只读提示,避免双轨操作。
### 二期完成门禁
- [ ] 相关 pytest 全绿并记录。
- [ ] **停止并等待用户:「开始第三期」**
---
## 三期:证书齐备 2.0
### Task 3.1:waiver 语义
**Files:**
- Modify: `backend/app/services/pl_quota_waiver.py`、`pl_quota_business.py`
- Modify: `backend/app/api/pl/plan.py` archive
- [ ] **Step 1:** 标记齐备 → `quota_cert_pending_fulfill=1`,安排可跳过 reserve。
- [ ] **Step 2:** `settle`/`archive`:若 pending 或 waived 未完善 → 409,不得跳过额度。
- [ ] **Step 3:** pytest:齐备可安排、不可归档;完善后可归档。
### Task 3.2:完善并关闭 API
**Files:**
- Modify: `backend/app/api/pl/plan.py` 或 `quota_cert.py`
- Create service:对单计划执行与安排相同的选证 + reserve
- [ ] **Step 1:** `POST .../fulfill-quota-certs`(单条)与 batch 接口。
- [ ] **Step 2:** 成功:写 usage、清 pending、出队;失败:保留 pending + 原因。
- [ ] **Step 3:** 并发单测或文档化行锁策略。
### Task 3.3:证书齐备 Web
**Files:**
- Modify/Create: 证书齐备列表页(现有 menu 路由对齐)
- [ ] **Step 1:** 仅列出新规则待完善计划。
- [ ] **Step 2:** 上传购买证/运输证入口。
- [ ] **Step 3:** 操作列完善并关闭 + 批量;成功刷新列表。
- [ ] **Step 4:** 归档弹窗对接「未完善」错误引导。
### Task 3.4:详情展示真实挂证
- [ ] **Step 1:** 完善或安排成功后详情可见证号/品类/占用等摘要。
### 三期完成门禁
- [ ] 全链路 pytest + 必要前端 typecheck。
- [ ] 更新本 plan 所有 checkbox;在设计文档顶部标注「已实现待验收」或按用户要求提交。
- [ ] **停止并等待用户验收;非用户要求不要 push。**
---
## 数据迁移作业(可穿插在一期后、切点打开前)
- [ ] 提供只读 SQL/脚本:按 `product_name_on_cert` 分组列出候选品类名。
- [ ] 文档说明:用户手工建品类并关联产品、回填证件 `customer_id`/`category_id` 后再配置 `effective_at`。
## Spec 覆盖自检
| 设计条款 | 任务 |
| --- | --- |
| 品类主数据 | 1.1–1.2, 1.5 |
| 客户绑证/解析保留 | 1.2, 1.5 |
| effective_at + 未配置拦安排 | 1.3, 1.5 |
| 列表过滤 closed/void | 1.4 |
| 详情展示 | 1.6, 3.4 |
| 选证 A + 双段扣证 | 2.1–2.3 |
| 无手工选证 | 2.3 |
| legacy 祖父 | 2.3 |
| 齐备只放行安排 | 3.1–3.3 |
| 合法使用证明不动 | 无任务改动(回归时确认) |
## 执行说明(给 Agent)
1. 先完整阅读设计文档与本计划,再读 `backend/AGENTS.md`、将改动的端 AGENTS。
2. 使用 personal-dev 任务分级;每 Task 优先补/改 pytest 再写实现。
3. 一期未完成不得开始二期逻辑依赖(可先写二期测试草稿但不合并行为)。
4. 每期结束用简体中文汇报:改了什么、验证命令与结果、未跑项、请用户确认下一期。
@@ -0,0 +1,229 @@
# 运输计划购买证/运输证:客户+品类额度改造设计
> 状态:实现中(2026-11-14 本会话已落地核心后端 + 关键 Web;待执行 SQL 与业务验收)
> 关联计划:`docs/superpowers/plans/2026-11-14-pl-customer-cert-quota.md`
> 现状参考:`openspec/changes/pl-contract-cert-quota-control/`(合同绑证模型,本需求起新规则不再作为选证来源)
## 1. 目标与非目标
### 目标
将易制毒购买证/运输证的适用范围从「合同 + 合同产品」改为「客户 + 证用品类」,安排仅自动选证(整笔不拆证),证书齐备只放行安排、归档必须真证挂载,并用可配置生效时间做历史祖父,降低上线冲击。
### 非目标
- 不改变易制爆「合法使用证明」现状。
- 不推倒 `pl_plan_cert_usage` / `pl_cert_qty_log` 核销账本,只改候选来源与门禁。
- 不复用 CRM `crm_product_category` 作为证用品类。
- 安排界面不做手工选证。
- 产品行 `purchase_cert_url` / `transport_cert_url` 附件能力可保留作展示/打包,但**不再**作为安排/归档额度门禁。
## 2. 已拍板决策一览
| 编号 | 决策 |
| --- | --- |
| D1 | 证绑定客户,不绑定合同/合同产品 |
| D2 | 独立证用品类;购买证绑一个品类;品类 → 多个系统产品 |
| D3 | 运输证绑客户 + 引用购买证;品类 = 引用购买证品类并集去重 |
| D4 | 同客户下所有合同可用该客户证件(仍受品类/有效期/车号/余量约束) |
| D5 | 选证规则 A:单证须整笔盖住本次需求,不够则跳过换下一张到期更晚但够量的;按 `valid_to` 升序 |
| D6 | 按段次外部客户选证:装货段用供应商客户,送货段用收货客户 |
| D7 | 装、送凡外部且需用证的段,各扣各的(可双套) |
| D8 | 安庆山峡、安徽云栋豁免购买证/运输证 |
| D9 | 同市(段次 `same_city=1`)只要求购买证 |
| D10 | 安排只自动选证;选不到只能「证书齐备」;无手工选证 |
| D11 | 证书齐备:可安排;归档不可绕过 |
| D12 | 齐备列表:上传证 → 单条/批量「完善并关闭」(同安排选证)→ 成功出队 |
| D13 | 计划详情展示已关联购买证/运输证 |
| D14 | 解析后自动绑定系统客户:保留 |
| D15 | 证件列表默认不展示 `closed`/`void`,列头漏斗可筛 |
| D16 | 切点:【系统信息】配置 `effective_at`;未配置则拦住安排;计划创建时打规则版本 |
| D17 | 上线前创建计划:不因新规则挡归档;旧齐备不进待完善 |
| D18 | 品类↔产品映射由人工配置;可从证面品名辅助归纳品类 |
## 3. 现状要点(改造锚点)
| 能力 | 路径 | 改造动作 |
| --- | --- | --- |
| 合同绑证选证 | `pl_quota_planning.resolve_contract_bound_certs` 等 | 新规则停用为选证源 |
| 用证来源 | `pl_quota_sources.extract_sources` | 按段客户;双侧外部都进来源(对齐 D7);内部客户过滤保留 |
| 分配 | `pl_quota_allocation.allocate_plan` | 改为整笔贪心(D5),不再为新规则做跨证拆分 |
| 安排挂钩 | `pl/transport.py` → `apply_plan_quota(reserve\|resize)` | 切点校验 + 新候选 |
| 归档挂钩 | `pl/plan.py` → `settle_plan_quota` | 齐备不可跳过 settle(新规则) |
| 齐备 | `pl_quota_waiver` | 安排可 waive;归档不可;完善写账本 |
| 证件主数据 | `pl_purchase_cert` / `pl_transport_cert` | 增加客户 ID、品类 ID 等 |
## 4. 数据模型
### 4.1 新建:证用品类
**表 `pl_cert_category`**
- 标准业务字段序:id → 业务字段 → tenant_no、order_num、remark、审计、del_flag
- 业务字段建议:`category_name`(如硫酸)、`category_code`(可选)、`status`(启用/停用)
**表 `pl_cert_category_product`**
- `category_id`、`product_id`(CRM 产品)、`scope_status`(active/inactive)
- 租户内 `(category_id, product_id)` 有效行唯一
### 4.2 证件主数据扩展
**`pl_purchase_cert`**
- `customer_id`(系统客户,必填后方可 active 参与选证;解析自动匹配保留)
- `category_id`(证用品类,必填后方可参与选证)
- 保留 `product_name_on_cert` 证面原文
**`pl_transport_cert`**
- `customer_id`(系统客户)
- 品类不落冗余主字段亦可:选证时由 `pl_transport_cert_purchase_ref` → 购买证 `category_id` 并集计算;若需列表筛选可冗余缓存并在引用变更时刷新
**`pl_transport_cert_purchase_ref`**
- 继续作为运输证 ↔ 购买证引用;选证要求引用的购买证参与本段配对
### 4.3 合同绑证表
- `pl_contract_cert_link` / `pl_contract_cert_product`:**新规则选证不再读取**
- 表可保留只读/历史;前端合同绑证入口对新规则租户隐藏或只读提示「已改为客户+品类」
### 4.4 计划与切点
**系统配置(新建表,推荐 `pl_cert_quota_rule_config` 或租户参数表)**
- `tenant_no`
- `effective_at`(DATETIME,可空)
- 审计字段
- 每租户一行(或最新有效一行)
**`pl_plan` 增加**
- `cert_quota_rule_version`:`legacy` | `customer_category`(创建时写入;空/null 视为 legacy)
- 可选保留 `quota_cert_waived*`;语义见 §6
- 建议增加完善标记,例如:
- `quota_cert_pending_fulfill`:1 = 已齐备待完善(新规则)
- 或用「waived=1 且无 settled/reserved 新规则用量」推断;实现时优先显式字段以免歧义
### 4.5 用量账本
- 继续使用 `pl_plan_cert_usage` / `pl_plan_cert_usage_item` / `pl_cert_qty_log`
- item 上原「合同绑定」维度改为可空或改为记录 `customer_id` + `category_id` 快照;以最小迁移为准,保证对账可追溯
## 5. 选证算法(新规则)
对每个用证来源(段 × 易制毒产品行,外部客户,非豁免):
```
需求量 Q,单位 U,客户 C,产品 P,是否同市 S,车号 V,业务日 D
1. 解析品类:P 落在哪些 active 的 pl_cert_category_product → 得到 category 集合
2. 候选购买证:
- customer_id=C, category_id∈集合, status=active 且已复核
- D ∈ [valid_from, valid_to]
- available = total - reserved - used(及一次有效等现有占用规则)≥ Q(同单位;异单位按现有换算/拦截策略)
3. 按 valid_to ASC, id ASC 排序
4. 依次尝试购买证 B:
- 若 S(同市):选中 B,结束本源
- 若异地:在「引用了 B」的运输证中找 T:
- customer_id=C(或与业务确认是否允许仅购买证客户一致)
- 车号覆盖 V,有效期与 active/复核,available ≥ Q
- 可按 T.valid_to ASC 取第一张
- 找不到 T 则跳过 B,试下一张购买证
5. 若无一整笔覆盖 → 本源失败 → 整单安排失败(或完善失败)
```
多产品/多段:每个来源独立跑上述算法;详情与账本按来源挂多证。
**禁止**:把 Q 拆到多张购买证(D5)。
## 6. 证书齐备 2.0
| 动作 | 新规则行为 |
| --- | --- |
| 标记齐备 | 允许在证件不足时安排;释放已有预占(若有);置待完善;**不**写 usage 预占 |
| 安排 | `apply_plan_quota` 对 waived+pending 跳过 reserve(与今类似,但仅安排路径) |
| 归档 | **禁止**因 waived 跳过 settle;若仍 pending 完善 → 409 提示先去证书齐备完善 |
| 完善并关闭 | 按 §5 自动选证并 `reserve`(计划已安排)或等价补记账;成功清除 pending,出列表 |
| 批量完善 | 对勾选行逐单执行;部分成功部分失败,返回明细 |
| 列表范围 | 仅 `cert_quota_rule_version=customer_category` 且待完善的计划;legacy 齐备不进列表 |
界面:证书齐备页支持上传购买证/运输证(解析绑客户保留)+ 操作列完善并关闭 + 批量。
## 7. 切点与门禁
```
arrange / resize:
if effective_at is null:
409「请先在系统信息中配置客户品类额度生效时间」
resolve plan.cert_quota_rule_version(已打标用标;未打标且 create_time>=effective_at 视为新规则并补标——仅创建路径应打标,安排时以已存字段为准)
if version == customer_category:
走 §5;失败可引导证书齐备
else:
祖父:不按新客户品类规则拦截归档相关;安排若仍发生,建议对 legacy 保持「不强制新选证」或只读旧行为——
【定稿】上线前创建的计划:已安排不挡归档;若 legacy 计划再次调整运输,实现时默认也不强制新规则选证(避免历史单改派被卡死),在设计实现备注中写清并单测覆盖。
```
**系统信息 Tab**
- 路由挂在现有「系统信息」下(`web-admin/src/router/modules/sqxp.ts` 的 SystemInfo 子级)
- 配置 `effective_at`;保存权限建议管理员
- 文案说明:未配置将无法安排运输;修改只影响之后新建且按创建打标的计划
**计划创建**
```
if effective_at and create_time >= effective_at:
cert_quota_rule_version = customer_category
else:
cert_quota_rule_version = legacy
```
## 8. 前端范围
| 页面/组件 | 变更 |
| --- | --- |
| 证用品类管理 | 新建列表+关联产品 |
| 购买证/运输证建档 | 客户、品类;运输证引用;列表默认排除 closed/void |
| 合同绑证 UI | 新规则下隐藏或只读说明 |
| 安排 | 去掉手工用证配置;失败文案引导齐备 |
| 计划详情/查看 | 展示关联购买证/运输证 |
| 证书齐备 | 列表、上传、完善并关闭、批量 |
| 系统信息 | 新 Tab 配置生效时间 |
| 归档 | 待完善不可归档的提示 |
H5 `m/pl/plan-cert`:评估与 Web 对齐的上传/建档;完善列表以 Web 为主时可第二期补齐,但不可破坏现有 H5 建档。
## 9. 历史数据迁移
1. **证件**:从现有证面 `product_name_on_cert` 归纳候选品类名,脚本/SQL 可生成品类草稿;**品类↔产品**由用户在界面手工完成后再开切点。
2. **客户**:尽量用现有解析/名称匹配回填 `customer_id`;匹配不上保持空,active 选证前必须补齐。
3. **计划**:不批量改历史 `quota_cert_waived`;不强制历史单进待完善。
4. **切点**:业务准备好后在系统信息填写 `effective_at`(可等于「今天 0 点」或切换瞬间)。
## 10. 分期
| 期 | 内容 | 可验收结果 |
| --- | --- | --- |
| 一期 | 品类主数据、证件绑客户/品类、列表默认过滤、系统信息 effective_at、详情展示位(可先读账本) | 未配时间则安排被拦;可维护品类与证件 |
| 二期 | 新选证算法接入安排/归档;legacy 祖父;停用合同选证源 | 新计划按客户品类自动占证/结转 |
| 三期 | 齐备 2.0 列表与完善并关闭;归档强制已完善 | 齐备可安排、完善后可归档 |
## 11. 验收要点
- 同市只扣购买证;异地购买证+运输证;山峡/云栋不扣。
- 10 吨早到期 + 100 吨晚到期、需求 >10 → 只用 100 吨那套。
- 硫酸+盐酸两产品 → 两套(或多段)独立挂证。
- 未配置 effective_at → 安排 409。
- legacy 已安排 → 可归档不被新规则卡死。
- 新规则齐备 → 可安排、不可归档;完善成功后可归档且账本有用量。
- 证件列表默认无已核销/作废。
- 合法使用证明行为与改前一致。
## 12. 风险
- D7 双侧扣证可能使额度消耗快于客户直觉,需在界面展示「本段客户/用证」。
- 改 `effective_at` 若未打标会漂;必须创建打标。
- 完善批量并发抢同一证余量:逐单事务 + 行锁(沿用现有证件锁)。
- 分配器从可拆分改为整笔:旧单测需分流 legacy/新规则。
+6
View File
@@ -61,6 +61,9 @@
"@tailwindcss/vite": "^4.1.14",
"@tinymce/tinymce-vue": "^6.3.0",
"@turbodocx/html-to-docx": "^1.22.2",
"@univerjs/preset-sheets-core": "1.4.0",
"@univerjs/preset-sheets-filter": "1.4.0",
"@univerjs/presets": "1.4.0",
"@vue-flow/core": "^1.48.2",
"@vue/reactivity": "^3.5.21",
"@vueuse/core": "^13.9.0",
@@ -86,6 +89,9 @@
"pinia-plugin-persistedstate": "^4.3.0",
"pinyin-pro": "^3.28.1",
"qrcode.vue": "^3.6.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"rxjs": "7.8.2",
"sortablejs": "^1.15.7",
"tailwindcss": "^4.1.14",
"tinymce": "^8.5.1",
+1469 -19
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -9,4 +9,5 @@ allowBuilds:
core-js: false
es5-ext: false
esbuild: true
protobufjs: false
vue-demi: true
+1 -1
View File
@@ -111,7 +111,7 @@
const panelTitleClassList = [...panelTitleIconByText.map(([, cls]) => cls), 'auto-title-list']
const panelTitleBarClass = 'title-bar-heading'
const panelTitleBarTextRules = [/^(购买证与运输证|合法使用证明|计划用证|证件建档|合同用证|证件用量|计划段次用证|证书齐备|合同证件与额度)$/]
const panelTitleBarTextRules = [/^(购买证与运输证|合法使用证明|计划用证|证件建档|证用品类|合同用证|证件用量|计划段次用证|证书齐备|合同证件与额度|运输证件规则)$/]
const panelTitleHeadingSelector =
'.page-container .panel-top h2, .page-container .panel-top h3, .page-container .storage-content-header h3, .page-container .storage-aside__head h2, .page-container .user-dept-aside__head h2, .console-container .panel-top h2'
+45 -1
View File
@@ -44,6 +44,8 @@ export interface CertBody {
routeText?: string | null
consigneeName?: string | null
applicantName?: string | null
customerId?: string | null
categoryId?: string | null
contractBindings?: CertificateContractBinding[]
purchaseRefs?: PurchaseReference[]
}
@@ -61,6 +63,7 @@ export interface QuotaCert extends Omit<CertBody, 'status'> {
usagePolicy: 'once' | 'multiple' | 'needs_confirmation'
createTime?: string
certType?: CertType
derivedCategoryIds?: string[]
}
export interface ScopeProduct {
id?: string
@@ -393,7 +396,48 @@ export const certReadyApi = {
.get(`${base}/plans/cert-ready`, { params })
.then(pickBxData<{ total: number; list: CertReadyPlan[] }>),
mark: (id: string) =>
request.post(`${base}/plans/${id}/cert-ready`).then(pickBxData<CertReadyPlan>)
request.post(`${base}/plans/${id}/cert-ready`).then(pickBxData<CertReadyPlan>),
listPendingFulfill: (params: { keyword?: string; page: number; pageSize: number }) =>
request
.get(`${base}/plans/cert-ready/pending-fulfill`, { params })
.then(pickBxData<{ total: number; list: CertReadyPlan[] }>),
fulfill: (id: string) =>
request.post(`${base}/plans/${id}/fulfill-quota-certs`).then(pickBxData<CertReadyPlan>),
fulfillBatch: (planIds: string[]) =>
request
.post(`${base}/plans/fulfill-quota-certs/batch`, { planIds })
.then(pickBxData<{ results: Array<{ planId: string; ok: boolean; message?: string }> }>)
}
export const certCategoryApi = {
list: (params?: { keyword?: string; status?: string }) =>
request.get(`${base}/categories`, { params }).then(pickBxData<CertCategory[]>),
get: (id: string) => request.get(`${base}/categories/${id}`).then(pickBxData<CertCategory>),
create: (body: Record<string, unknown>) =>
request.post(`${base}/categories`, body).then(pickBxData<CertCategory>),
update: (id: string, body: Record<string, unknown>) =>
request.put(`${base}/categories/${id}`, body).then(pickBxData<CertCategory>),
remove: (id: string) =>
request.delete(`${base}/categories/${id}`).then(pickBxData<{ ok?: boolean }>)
}
export interface CertCategory {
id: string
categoryName: string
categoryCode?: string | null
status: string
products?: Array<{ id?: string; productId: string; scopeStatus?: string }>
}
export const certQuotaRuleApi = {
get: () =>
request
.get(`${base}/rule-config`)
.then(pickBxData<{ effectiveAt: string | null; configured: boolean }>),
save: (effectiveAt: string | null) =>
request
.put(`${base}/rule-config`, { effectiveAt })
.then(pickBxData<{ effectiveAt: string | null; configured: boolean }>)
}
export const planQuotaApi = {
-13
View File
@@ -1900,19 +1900,6 @@ body {
}
}
.list-table.is-excel-copy {
th.is-excel-col,
td.is-excel-rowhead {
background: var(--mint-primary-bg) !important;
color: var(--mint-primary-dark);
}
td.is-excel-cell {
background: rgba(16, 185, 129, 0.16) !important;
box-shadow: inset 0 0 0 1px var(--mint-primary-dark);
}
}
/* 筛选区 label:13px 次要色(对齐 zkxw filter-form 实践;filter-hint 仍为 11px) */
.page-container .filter-block .el-form-item__label,
.console-container .filter-block .el-form-item__label,
@@ -0,0 +1,370 @@
<!-- 列表 Excel 只读查看:Univer OSS,可筛选/复制,不可编辑或写回 -->
<template>
<el-dialog
v-model="innerVisible"
title="Excel 查看"
width="96%"
top="2vh"
class="dialog-form-layout list-excel-viewer-dialog"
align-center
destroy-on-close
append-to-body
:close-on-click-modal="false"
style="backdrop-filter: none"
@opened="onOpened"
@closed="onClosed"
>
<div v-loading="booting" class="list-excel-viewer-body">
<p class="list-excel-viewer-hint">只读模式:可筛选、复制;不可编辑或写回系统。</p>
<div ref="containerRef" class="list-excel-viewer-host" />
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, shallowRef } from 'vue'
import { ElMessage } from 'element-plus'
import type { ListExcelSnapshot } from '@/utils/list-excel-copy'
import { excelCell, snapshotToUniverCellData } from '@/utils/list-excel-copy'
defineOptions({ name: 'ListExcelViewerDialog' })
const props = defineProps<{
modelValue: boolean
snapshot: ListExcelSnapshot | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const innerVisible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const containerRef = ref<HTMLElement | null>(null)
const booting = ref(false)
const univerRef = shallowRef<{ dispose: () => void } | null>(null)
const univerApiRef = shallowRef<UniverAPI | null>(null)
type UniverAPI = {
createWorkbook: (data: Record<string, unknown>) => unknown
getActiveWorkbook: () => any
Enum: {
WorkbookPermissionPoint: Record<string, string>
WorksheetPermissionPoint: Record<string, string>
}
}
function disposeUniver() {
unbindCopyHotkey()
try {
univerRef.value?.dispose()
} catch {
// ignore dispose race
}
univerRef.value = null
univerApiRef.value = null
}
function selectionToTsv(univerAPI: UniverAPI): string {
const workbook = univerAPI.getActiveWorkbook?.()
const range = workbook?.getActiveRange?.() || workbook?.getActiveSheet?.()?.getActiveRange?.()
if (!range) return ''
const values: unknown[][] =
range.getDisplayValues?.() || range.getValues?.() || range.getRawValues?.() || []
if (!Array.isArray(values) || !values.length) {
const single = range.getDisplayValue?.() ?? range.getValue?.()
if (single == null || single === '') return ''
return excelCell(String(single))
}
return values
.map((row) =>
(Array.isArray(row) ? row : [row])
.map((cell) => excelCell(cell == null ? '' : String(cell)))
.join('\t')
)
.join('\r\n')
}
async function copySelectionFallback(univerAPI: UniverAPI) {
const text = selectionToTsv(univerAPI)
if (!text) return false
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制,到 Excel 中直接粘贴')
return true
} catch {
const area = document.createElement('textarea')
area.value = text
area.setAttribute('readonly', 'true')
area.style.position = 'fixed'
area.style.left = '-9999px'
document.body.appendChild(area)
area.select()
const ok = document.execCommand('copy')
area.remove()
if (ok) {
ElMessage.success('已复制,到 Excel 中直接粘贴')
return true
}
ElMessage.warning('复制失败,请允许剪贴板权限后重试')
return false
}
}
function isTextEntryTarget(target: EventTarget | null): boolean {
if (target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) return true
if (target instanceof HTMLInputElement) {
const type = (target.getAttribute('type') || 'text').toLowerCase()
return type !== 'checkbox' && type !== 'radio'
}
return target instanceof HTMLElement && target.isContentEditable
}
function onCopyHotkey(event: KeyboardEvent) {
if (!innerVisible.value || !univerApiRef.value) return
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'c') return
if (isTextEntryTarget(event.target)) return
const host = containerRef.value
const target = event.target as Node | null
// 弹层内任意位置(含 Univer canvas)都接住复制
const dialog = host?.closest('.el-dialog')
if (dialog && target && !dialog.contains(target) && target !== document.body) return
event.preventDefault()
event.stopPropagation()
void copySelectionFallback(univerApiRef.value)
}
function bindCopyHotkey() {
document.addEventListener('keydown', onCopyHotkey, true)
}
function unbindCopyHotkey() {
document.removeEventListener('keydown', onCopyHotkey, true)
}
/**
* 工作表 setPoint 必须先 protect(),否则会抛
* "Cannot set … because worksheet protection does not exist"。
* filterOnly 默认关掉 WorksheetCopy,必须再显式打开,否则 Ctrl/Cmd+C 无效。
*/
async function applyReadonlyPermissions(univerAPI: UniverAPI) {
const workbook = univerAPI.getActiveWorkbook()
if (!workbook) return
// 本地关闭编辑(不依赖 Authz / protect)
if (typeof workbook.setEditable === 'function') {
workbook.setEditable(false)
}
try {
const workbookPermission = workbook.getWorkbookPermission?.()
if (workbookPermission?.setMode) {
await workbookPermission.setMode('viewer')
}
const copyPoint = univerAPI.Enum?.WorkbookPermissionPoint?.CopyContent
if (workbookPermission?.setPoint && copyPoint) {
await workbookPermission.setPoint(copyPoint, true)
}
} catch (error) {
console.warn('[ListExcelViewer] workbook permission skipped', error)
}
const sheet = workbook.getActiveSheet?.()
if (!sheet?.getWorksheetPermission) return
try {
const sheetPermission = sheet.getWorksheetPermission()
// filterOnly = View + Sort + Filter;须先 protect
if (!sheetPermission.isProtected?.()) {
await sheetPermission.protect?.({ name: 'list-excel-readonly' })
}
if (sheetPermission.setMode) {
await sheetPermission.setMode('filterOnly')
}
const sheetCopy = univerAPI.Enum?.WorksheetPermissionPoint?.Copy
if (sheetPermission.setPoint && sheetCopy) {
await sheetPermission.setPoint(sheetCopy, true)
}
} catch (error) {
console.warn('[ListExcelViewer] worksheet filterOnly skipped', error)
}
}
function mountFilter(univerAPI: UniverAPI, snapshot: ListExcelSnapshot) {
const sheet = univerAPI.getActiveWorkbook()?.getActiveSheet?.()
if (!sheet?.getRange) return
const colCount = Math.max(snapshot.headers.length, 1)
const rowCount = Math.max(snapshot.rows.length + 1, 1)
const range = sheet.getRange(0, 0, rowCount, colCount)
try {
sheet.getFilter?.()?.remove?.()
} catch {
// no existing filter
}
range.createFilter?.()
}
async function loadUniverModules() {
const [
{ UniverSheetsCorePreset },
coreZhMod,
{ UniverSheetsFilterPreset },
filterZhMod,
{ createUniver, LocaleType, mergeLocales }
] = await Promise.all([
import('@univerjs/preset-sheets-core'),
import('@univerjs/preset-sheets-core/locales/zh-CN'),
import('@univerjs/preset-sheets-filter'),
import('@univerjs/preset-sheets-filter/locales/zh-CN'),
import('@univerjs/presets'),
import('@univerjs/preset-sheets-core/lib/index.css'),
import('@univerjs/preset-sheets-filter/lib/index.css')
])
return {
UniverSheetsCorePreset,
UniverPresetSheetsCoreZhCN: coreZhMod.default,
UniverSheetsFilterPreset,
UniverPresetSheetsFilterZhCN: filterZhMod.default,
createUniver,
LocaleType,
mergeLocales
}
}
async function bootUniver() {
disposeUniver()
const snapshot = props.snapshot
const container = containerRef.value
if (!snapshot || !container) return
if (!snapshot.headers.length) {
ElMessage.warning('当前列表没有可查看的数据列')
return
}
booting.value = true
try {
const {
UniverSheetsCorePreset,
UniverPresetSheetsCoreZhCN,
UniverSheetsFilterPreset,
UniverPresetSheetsFilterZhCN,
createUniver,
LocaleType,
mergeLocales
} = await loadUniverModules()
const cellData = snapshotToUniverCellData(snapshot)
const colCount = snapshot.headers.length
const rowCount = snapshot.rows.length + 1
const sheetId = 'sheet1'
const { univer, univerAPI } = createUniver({
locale: LocaleType.ZH_CN,
locales: {
[LocaleType.ZH_CN]: mergeLocales(UniverPresetSheetsCoreZhCN, UniverPresetSheetsFilterZhCN)
},
presets: [
UniverSheetsCorePreset({
container,
toolbar: true,
formulaBar: false,
footer: false,
header: true,
contextMenu: true
}),
UniverSheetsFilterPreset()
]
})
univerRef.value = univer
const api = univerAPI as unknown as UniverAPI
univerApiRef.value = api
api.createWorkbook({
id: 'list-excel-viewer',
name: '列表数据',
sheetOrder: [sheetId],
sheets: {
[sheetId]: {
id: sheetId,
name: '当前页',
rowCount: Math.max(rowCount + 20, 40),
columnCount: Math.max(colCount + 5, 10),
cellData
}
}
})
// 权限/筛选失败不应让整页判定为加载失败(表格数据已就绪)
try {
await applyReadonlyPermissions(api)
} catch (error) {
console.warn('[ListExcelViewer] readonly setup skipped', error)
}
try {
mountFilter(api, snapshot)
} catch (error) {
console.warn('[ListExcelViewer] filter setup skipped', error)
}
bindCopyHotkey()
} catch (error) {
console.error(error)
ElMessage.error('Excel 查看加载失败,请稍后重试')
disposeUniver()
} finally {
booting.value = false
}
}
async function onOpened() {
await nextTick()
await bootUniver()
}
function onClosed() {
disposeUniver()
}
onBeforeUnmount(() => {
disposeUniver()
})
</script>
<style scoped lang="scss">
.list-excel-viewer-body {
display: flex;
flex-direction: column;
height: calc(100vh - 160px);
min-height: 420px;
gap: 8px;
}
.list-excel-viewer-hint {
margin: 0;
font-size: 12px;
line-height: 1.4;
color: var(--mint-text-muted);
}
.list-excel-viewer-host {
flex: 1;
min-height: 0;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: 8px;
overflow: hidden;
}
</style>
<style lang="scss">
.list-excel-viewer-dialog.el-dialog {
max-height: calc(100vh - 40px);
margin-bottom: 0;
.el-dialog__body {
padding-top: 8px;
padding-bottom: 16px;
}
}
</style>
@@ -1,6 +1,7 @@
<!--
主列表薄封装:在 el-table 上启用列宽拖拽,并按 table-key 持久化到 localStorage。
用法:将 <el-table class="list-table" …> 换成 <ListTable table-key="crm-customer" class="list-table" …>
Excel 查看:工具栏打开只读 Univer 弹层,本组件提供 getExcelSnapshot()。
-->
<template>
<el-table
@@ -29,15 +30,11 @@
useAttrs,
watch
} from 'vue'
import { ElMessage } from 'element-plus'
import {
buildExcelTsv,
excelCell,
buildExcelSnapshot,
isCopyableColumn,
isRowSelectorColumn,
resolveCopyAxes,
toggleAxis,
type ListExcelColumn
type ListExcelColumn,
type ListExcelSnapshot
} from '@/utils/list-excel-copy'
import {
isFixedNarrowListColumnKey,
@@ -55,7 +52,7 @@
persistColumnWidth?: boolean
/** 是否开启 border(列宽拖拽依赖);默认 true */
border?: boolean
/** 是否允许 Excel 行列复制,默认 true */
/** 是否允许 Excel 只读查看,默认 true */
excelCopy?: boolean
}>(),
{
@@ -71,17 +68,11 @@
computed(() => props.tableKey)
)
const excelMode = ref(false)
const selectedCols = ref(new Set<number>())
const selectedRows = ref(new Set<number>())
let colAnchor: number | null = null
let rowAnchor: number | null = null
const resolvedBorder = computed(() => props.border !== false)
const tableClass = computed(() => {
const extra = attrs.class
return ['list-table', { 'is-excel-copy': excelMode.value }, extra]
return ['list-table', extra]
})
const tableAttrs = computed(() => {
@@ -157,14 +148,6 @@
}))
}
function copyableColumns(): ListExcelColumn[] {
return readColumns().filter((col) => col.id && isCopyableColumn(col))
}
function indexColumn(): ListExcelColumn | undefined {
return readColumns().find((col) => col.id && isRowSelectorColumn(col))
}
function tableRoot(): HTMLElement | null {
const el = tableRef.value?.$el as HTMLElement | undefined
return el ?? null
@@ -176,254 +159,45 @@
return Array.from(root.querySelectorAll('.el-table__body-wrapper tbody > tr.el-table__row'))
}
function columnFromCell(cell: HTMLElement): ListExcelColumn | undefined {
/** 当前可见列 + 当前页行文本,供 Excel 只读弹层使用 */
function getExcelSnapshot(): ListExcelSnapshot {
if (!props.excelCopy) return { headers: [], rows: [] }
const columns = readColumns()
const matched = columns.find((col) => col.id && cell.classList.contains(col.id))
if (matched) return matched
const row = cell.parentElement
if (!row) return undefined
const cells = Array.from(row.children).filter(
(el): el is HTMLTableCellElement => el instanceof HTMLTableCellElement
const copyable = columns.filter((col) => col.id && isCopyableColumn(col))
const rows = bodyRows()
const rowValues = rows.map((row) =>
copyable.map((col) => {
const td = row.querySelector(`td.${CSS.escape(col.id)}`)
return td?.textContent ?? ''
})
)
const visualIndex = cells.indexOf(cell as HTMLTableCellElement)
return visualIndex >= 0 ? columns[visualIndex] : undefined
// rowValues 已按可复制列对齐,传入过滤后的 columns
return buildExcelSnapshot({ columns: copyable, rowValues })
}
function paintExcelSelection() {
function bindSnapshotApi() {
const root = tableRoot()
if (!root) return
root.querySelectorAll('.is-excel-col, .is-excel-rowhead, .is-excel-cell').forEach((el) => {
el.classList.remove('is-excel-col', 'is-excel-rowhead', 'is-excel-cell')
})
if (!excelMode.value) return
const columns = copyableColumns()
const rows = bodyRows()
const axes = resolveCopyAxes({
selectedCols: [...selectedCols.value],
selectedRows: [...selectedRows.value],
columnCount: columns.length,
rowCount: rows.length
})
const indexCol = indexColumn()
selectedCols.value.forEach((index) => {
const col = columns[index]
if (!col) return
root.querySelectorAll(`th.${CSS.escape(col.id)}`).forEach((el) => {
el.classList.add('is-excel-col')
})
})
const bodies = root.querySelectorAll(
'.el-table__body-wrapper tbody, .el-table__fixed-body-wrapper tbody'
)
if (indexCol) {
selectedRows.value.forEach((index) => {
bodies.forEach((tbody) => {
const row = tbody.querySelectorAll(':scope > tr.el-table__row')[index]
row?.querySelector(`td.${CSS.escape(indexCol.id)}`)?.classList.add('is-excel-rowhead')
})
})
}
if (!axes) return
axes.rows.forEach((rowIndex) => {
axes.cols.forEach((colIndex) => {
const col = columns[colIndex]
if (!col) return
bodies.forEach((tbody) => {
const row = tbody.querySelectorAll(':scope > tr.el-table__row')[rowIndex]
row?.querySelector(`td.${CSS.escape(col.id)}`)?.classList.add('is-excel-cell')
})
})
})
;(root as HTMLElement & { __sxGetExcelSnapshot?: () => ListExcelSnapshot }).__sxGetExcelSnapshot =
getExcelSnapshot
}
function clearExcelSelection() {
selectedCols.value = new Set()
selectedRows.value = new Set()
colAnchor = null
rowAnchor = null
paintExcelSelection()
}
function setExcelMode(next: boolean) {
// excelCopy 已关闭时拒绝进入;仍允许关掉,否则 prop 变 false 后无法退出。
if (!props.excelCopy && next) return
excelMode.value = next
if (!next) clearExcelSelection()
else paintExcelSelection()
tableRoot()?.dispatchEvent(
new CustomEvent('sx-list-excel-state', {
bubbles: true,
detail: { active: excelMode.value }
})
)
}
function clickedRowIndex(row: HTMLTableRowElement): number {
const rows = bodyRows()
const direct = rows.indexOf(row)
if (direct >= 0) return direct
const tbody = row.parentElement
if (!tbody) return -1
const index = Array.from(tbody.querySelectorAll(':scope > tr.el-table__row')).indexOf(row)
if (index < 0 || index >= rows.length) return -1
return index
}
function keepCopyFocus() {
function unbindSnapshotApi() {
const root = tableRoot()
if (!root) return
const panel = root.closest('.panel')
const active = document.activeElement
if (panel && active instanceof Node && panel.contains(active)) return
if (!root.hasAttribute('tabindex')) root.setAttribute('tabindex', '-1')
root.focus({ preventScroll: true })
}
function onTableClick(event: MouseEvent) {
if (!excelMode.value || !props.excelCopy) return
const target = event.target as HTMLElement | null
if (!target) return
const header = target.closest('th')
const cell = target.closest('td')
if (header) {
event.preventDefault()
event.stopPropagation()
const columns = copyableColumns()
const index = columns.findIndex((col) => header.classList.contains(col.id))
if (index < 0) {
keepCopyFocus()
return
}
const result = toggleAxis(selectedCols.value, index, event.shiftKey, colAnchor)
selectedCols.value = result.selected
colAnchor = result.anchor
paintExcelSelection()
keepCopyFocus()
return
}
if (!cell) return
event.preventDefault()
event.stopPropagation()
keepCopyFocus()
const row = cell.closest('tr')
const column = columnFromCell(cell)
// 序号格和数据格都选整行;勾选列、展开列、操作列不选行。
if (!row || !column || !(isRowSelectorColumn(column) || isCopyableColumn(column))) return
const rowIndex = clickedRowIndex(row)
if (rowIndex < 0) return
const result = toggleAxis(selectedRows.value, rowIndex, event.shiftKey, rowAnchor)
selectedRows.value = result.selected
rowAnchor = result.anchor
paintExcelSelection()
}
function onTableDblClick(event: MouseEvent) {
if (!excelMode.value || !props.excelCopy) return
const target = event.target as HTMLElement | null
if (!target?.closest('td, th')) return
event.preventDefault()
event.stopPropagation()
}
function currentTsv(): string {
const columns = copyableColumns()
const rows = bodyRows()
const axes = resolveCopyAxes({
selectedCols: [...selectedCols.value],
selectedRows: [...selectedRows.value],
columnCount: columns.length,
rowCount: rows.length
})
if (!axes) return ''
const headers = axes.cols.map((index) => columns[index]?.label ?? '')
const matrix = axes.rows.map((rowIndex) =>
axes.cols.map((colIndex) => {
const col = columns[colIndex]
const td = col ? rows[rowIndex]?.querySelector(`td.${CSS.escape(col.id)}`) : null
return excelCell(td?.textContent ?? '')
})
)
return buildExcelTsv(headers, matrix)
}
async function copyExcelSelection() {
const text = currentTsv()
if (!text) return
try {
await navigator.clipboard.write([
new ClipboardItem({ 'text/plain': new Blob([text], { type: 'text/plain' }) })
])
ElMessage.success('已复制,到 Excel 中直接粘贴')
} catch {
const area = document.createElement('textarea')
area.value = text
area.setAttribute('readonly', 'true')
area.style.position = 'fixed'
area.style.left = '-9999px'
document.body.appendChild(area)
area.select()
const ok = document.execCommand('copy')
area.remove()
if (ok) ElMessage.success('已复制,到 Excel 中直接粘贴')
else ElMessage.warning('复制失败,请允许剪贴板权限后重试')
}
}
function isTextEntryTarget(target: EventTarget | null): boolean {
if (target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) return true
if (target instanceof HTMLInputElement) {
const type = (target.getAttribute('type') || 'text').toLowerCase()
return type !== 'checkbox' && type !== 'radio'
}
return target instanceof HTMLElement && target.isContentEditable
}
function onKeydown(event: KeyboardEvent) {
if (!excelMode.value || !props.excelCopy) return
if (isTextEntryTarget(event.target)) return
const root = tableRoot()
const panel = root?.closest('.panel')
const target = event.target as Node | null
if (panel && target && !panel.contains(target)) return
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'c') {
if (!currentTsv()) return
event.preventDefault()
void copyExcelSelection()
}
if (event.key === 'Escape') clearExcelSelection()
}
function onExcelToggle() {
setExcelMode(!excelMode.value)
}
function bindExcelEvents() {
const root = tableRoot()
root?.addEventListener('click', onTableClick, true)
root?.addEventListener('dblclick', onTableDblClick, true)
root?.addEventListener('sx-list-excel-toggle', onExcelToggle)
document.addEventListener('keydown', onKeydown)
}
function unbindExcelEvents() {
const root = tableRoot()
root?.removeEventListener('click', onTableClick, true)
root?.removeEventListener('dblclick', onTableDblClick, true)
root?.removeEventListener('sx-list-excel-toggle', onExcelToggle)
document.removeEventListener('keydown', onKeydown)
delete (root as HTMLElement & { __sxGetExcelSnapshot?: () => ListExcelSnapshot })
.__sxGetExcelSnapshot
}
onMounted(() => {
scheduleApply()
// 首屏列尚未完全注册时再补一次
mountApplyTimer = setTimeout(() => scheduleApply(), 100)
bindExcelEvents()
bindSnapshotApi()
})
onBeforeUnmount(() => {
unbindExcelEvents()
unbindSnapshotApi()
clearApplyTimers()
})
onDeactivated(() => {
@@ -432,13 +206,13 @@
onActivated(() => {
scheduleApply()
bindSnapshotApi()
})
watch(
() => (attrs as any).data,
() => {
scheduleApply()
if (excelMode.value) clearExcelSelection()
},
{ deep: false }
)
@@ -448,7 +222,6 @@
() => scheduleApply()
)
// 列显隐导致列数量变化时写回(避免在 drag 过程中用 onUpdated 反复 apply)
watch(
() => {
const cols = tableRef.value?.store?.states?.columns
@@ -456,24 +229,6 @@
},
() => {
scheduleApply()
if (excelMode.value) clearExcelSelection()
}
)
watch(
() =>
readColumns()
.map((col) => col.id)
.join('|'),
() => {
if (excelMode.value) clearExcelSelection()
}
)
watch(
() => props.excelCopy,
(enabled) => {
if (!enabled) setExcelMode(false)
}
)
@@ -495,13 +250,19 @@
resetWidths()
}
}
if (prop === 'getExcelSnapshot') return getExcelSnapshot
const table = tableRef.value
if (!table) return undefined
const value = table[prop as keyof typeof table]
return typeof value === 'function' ? value.bind(table) : value
},
has(_target, prop) {
if (prop === 'elTableRef' || prop === 'applyColumnWidths' || prop === 'resetColumnWidths') {
if (
prop === 'elTableRef' ||
prop === 'applyColumnWidths' ||
prop === 'resetColumnWidths' ||
prop === 'getExcelSnapshot'
) {
return true
}
const table = tableRef.value
@@ -1,17 +1,11 @@
<!-- 列表工具栏右侧:刷新 + 列显隐(勾选持久化由 usePersistedListColumns 负责) -->
<!-- 列表工具栏右侧:Excel 查看 + 刷新 + 列显隐(勾选持久化由 usePersistedListColumns 负责) -->
<template>
<div ref="rootEl" class="list-toolbar-end">
<el-tooltip
v-if="showExcelCopy"
:content="excelActive ? '退出 Excel 复制' : 'Excel 复制'"
placement="top"
>
<el-tooltip v-if="showExcelCopy" content="Excel 查看" placement="top">
<el-button
class="list-toolbar-end-btn"
:class="{ 'is-excel-active': excelActive }"
:aria-pressed="excelActive"
aria-label="Excel 复制"
@click="toggleExcelCopy"
aria-label="Excel 查看"
@click="openExcelViewer"
>
<svg class="list-excel-icon" viewBox="0 0 16 16" aria-hidden="true">
<rect
@@ -89,13 +83,25 @@
</el-scrollbar>
</div>
</el-popover>
<ListExcelViewerDialog
v-if="excelSnapshot"
v-model="excelViewerVisible"
:snapshot="excelSnapshot"
/>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, defineAsyncComponent, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh, Setting } from '@element-plus/icons-vue'
import type { ListColumnDef } from '@/composables/usePersistedListColumns'
import type { ListExcelSnapshot } from '@/utils/list-excel-copy'
const ListExcelViewerDialog = defineAsyncComponent(
() => import('@/components/business/list-excel-viewer-dialog/index.vue')
)
defineOptions({ name: 'ListToolbarEndActions' })
@@ -154,46 +160,28 @@
emit('resetColumns')
}
const excelActive = ref(false)
const rootEl = ref<HTMLElement | null>(null)
let panelEl: HTMLElement | null = null
const excelViewerVisible = ref(false)
const excelSnapshot = ref<ListExcelSnapshot | null>(null)
function controlledTable(): HTMLElement | null {
type ExcelTableEl = HTMLElement & { __sxGetExcelSnapshot?: () => ListExcelSnapshot }
function controlledTable(): ExcelTableEl | null {
const panel = rootEl.value?.closest('.panel')
const table = panel?.querySelector('[data-list-excel-copy="on"]')
return table instanceof HTMLElement ? table : null
return table instanceof HTMLElement ? (table as ExcelTableEl) : null
}
function toggleExcelCopy() {
controlledTable()?.dispatchEvent(new CustomEvent('sx-list-excel-toggle'))
}
function onExcelState(event: Event) {
const custom = event as CustomEvent<{ active?: boolean }>
if (event.target !== controlledTable()) return
excelActive.value = !!custom.detail?.active
}
function attachExcelStateListener() {
const panel = rootEl.value?.closest('.panel')
if (!(panel instanceof HTMLElement)) return false
panel.addEventListener('sx-list-excel-state', onExcelState, true)
panelEl = panel
return true
}
onMounted(() => {
if (!attachExcelStateListener()) {
nextTick(() => {
attachExcelStateListener()
})
function openExcelViewer() {
const table = controlledTable()
const snapshot = table?.__sxGetExcelSnapshot?.()
if (!snapshot || !snapshot.headers.length) {
ElMessage.warning('当前列表没有可查看的数据列')
return
}
})
onBeforeUnmount(() => {
panelEl?.removeEventListener('sx-list-excel-state', onExcelState, true)
panelEl = null
})
excelSnapshot.value = snapshot
excelViewerVisible.value = true
}
</script>
<style scoped lang="scss">
@@ -227,26 +215,6 @@
border: none !important;
box-shadow: none;
}
&.is-excel-active,
&.is-excel-active:hover,
&.is-excel-active:focus-visible,
&.is-excel-active:active {
color: #fff !important;
background: var(--mint-primary) !important;
border: none !important;
box-shadow:
0 0 0 2px #fff,
0 0 0 4px var(--mint-primary) !important;
}
&.is-excel-active:hover,
&.is-excel-active:focus-visible {
background: var(--mint-primary-dark) !important;
box-shadow:
0 0 0 2px #fff,
0 0 0 4px var(--mint-primary-dark) !important;
}
}
.list-excel-icon {
@@ -254,8 +222,4 @@
height: 16px;
display: block;
}
.is-excel-active .list-excel-icon rect {
fill: rgba(255, 255, 255, 0.22);
}
</style>
@@ -71,6 +71,23 @@
<p v-else-if="!loading" class="pl-plan-form-empty-hint">暂无车辆信息</p>
</section>
<section class="pl-plan-form-section pl-plan-form-section--plain">
<h3 class="pl-plan-form-section-title">关联购买证 / 运输证</h3>
<p v-if="plan?.quotaCertPendingFulfill" class="pl-plan-form-empty-hint">
证书齐备(待完善):请到「证书齐备 → 待完善并关闭」挂证
</p>
<div v-else-if="linkedCertRows.length" class="pl-plan-linked-certs">
<div v-for="item in linkedCertRows" :key="item.key" class="pl-plan-linked-cert-row">
<el-tag size="small" :type="item.kind === 'purchase' ? 'success' : 'warning'">
{{ item.kind === 'purchase' ? '购买证' : '运输证' }}
</el-tag>
<span>{{ item.certNo || item.certId }}</span>
<span class="text-slate-500">预占 {{ item.reservedQty }} / 已用 {{ item.usedQty }}</span>
</div>
</div>
<p v-else class="pl-plan-form-empty-hint">暂无关联证件</p>
</section>
<section class="pl-plan-form-section pl-plan-form-section--plain">
<h3 class="pl-plan-form-section-title">装送货信息</h3>
<PlPlanStopsSplitShell
@@ -1633,11 +1650,41 @@
})
)
const linkedCertRows = ref<
Array<{
key: string
kind: string
certId: string
certNo?: string
reservedQty: string | number
usedQty: string | number
}>
>([])
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,
reservedQty: u.reservedQty ?? u.reserved_qty ?? '—',
usedQty: u.usedQty ?? u.used_qty ?? '—'
}))
} catch {
linkedCertRows.value = []
}
}
async function runLoadDetail(planId: string) {
await loadDetail(planId, {
initialPlan: props.initialPlan?.id === planId ? props.initialPlan : null,
prefetchedArrange: props.prefetchedArrange
})
await loadLinkedCerts(planId)
}
watch(
+10
View File
@@ -39,6 +39,16 @@ declare module '@turbodocx/html-to-docx/dist/html-to-docx.browser.esm.js' {
export default HTMLtoDOCX
}
declare module '@univerjs/preset-sheets-core/locales/zh-CN' {
const locale: Record<string, string>
export default locale
}
declare module '@univerjs/preset-sheets-filter/locales/zh-CN' {
const locale: Record<string, string>
export default locale
}
declare module 'qrcode.vue' {
export type Level = 'L' | 'M' | 'Q' | 'H'
export type RenderAs = 'canvas' | 'svg'
+7
View File
@@ -68,6 +68,13 @@ export const plRoutes: AppRouteRecord[] = [
component: '/pl/quota-cert/index',
meta: { title: '证件建档', keepAlive: false, menuCode: 'pl:quota-cert:list' }
},
{
id: 'PL_CERT_CATEGORY',
name: 'PlCertCategory',
path: 'cert-category',
component: '/pl/cert-category/index',
meta: { title: '证用品类', keepAlive: false, menuCode: 'pl:quota-cert:list' }
},
{
id: 'PL_CONTRACT_QUOTA',
name: 'PlContractQuota',
+11
View File
@@ -727,6 +727,17 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
menuCode: 'system:dict:list'
}
},
{
id: 'SYSM_CERT_QUOTA_RULE',
name: 'CertQuotaRule',
path: 'cert-quota-rule',
component: '/system/cert-quota-rule/index',
meta: {
title: '运输证件规则',
keepAlive: false,
menuCode: 'system:tenant:list'
}
},
{
id: '106',
name: 'Menu',
@@ -1,11 +1,13 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
buildExcelSnapshot,
buildExcelTsv,
excelCell,
isCopyableColumn,
isRowSelectorColumn,
resolveCopyAxes,
snapshotToUniverCellData,
toggleAxis
} from './list-excel-copy'
@@ -135,3 +137,45 @@ describe('buildExcelTsv', () => {
assert.equal(tsv, '联系人\t状态\r\n张三\t合作中\r\n李四\t跟进中')
})
})
describe('buildExcelSnapshot', () => {
it('keeps copyable columns, cleans cells, and drops 操作', () => {
const snapshot = buildExcelSnapshot({
columns: [
{ id: 'name', label: ' 联系人\n' },
{ id: 'act', label: '操作' },
{ id: 'status', label: '状态' }
],
rowValues: [
['张三\t甲', '编辑', '合作中'],
['李四', '删除', '跟进中']
]
})
assert.deepEqual(snapshot.headers, ['联系人', '状态'])
assert.deepEqual(snapshot.rows, [
['张三 甲', '合作中'],
['李四', '跟进中']
])
})
it('returns empty snapshot when no copyable columns', () => {
const snapshot = buildExcelSnapshot({
columns: [{ id: 'a', label: '操作' }],
rowValues: [['x']]
})
assert.deepEqual(snapshot, { headers: [], rows: [] })
})
})
describe('snapshotToUniverCellData', () => {
it('puts headers on row 0 and data from row 1', () => {
const cellData = snapshotToUniverCellData({
headers: ['A', 'B'],
rows: [['1', '2']]
})
assert.deepEqual(cellData[0][0], { v: 'A', t: 1 })
assert.deepEqual(cellData[0][1], { v: 'B', t: 1 })
assert.deepEqual(cellData[1][0], { v: '1', t: 1 })
assert.deepEqual(cellData[1][1], { v: '2', t: 1 })
})
})
+48
View File
@@ -68,6 +68,54 @@ export function buildExcelTsv(headers: string[], rows: string[][]): string {
return lines.join('\r\n')
}
/** 灌入只读在线表格的快照:表头 + 当前页文本矩阵 */
export type ListExcelSnapshot = {
headers: string[]
rows: string[][]
}
/**
* 从可复制列与行文本构建快照。headers/rows 均经 excelCell 清洗。
* rowValues 与 columns 按下标对齐;操作列等会被跳过。
* 无可复制列时返回 { headers: [], rows: [] }。
*/
export function buildExcelSnapshot(input: {
columns: ListExcelColumn[]
/** 与 columns 按下标对齐的行文本;通常来自表格 DOM textContent */
rowValues: string[][]
}): ListExcelSnapshot {
const indices: number[] = []
const headers: string[] = []
input.columns.forEach((col, index) => {
if (!col.id || !isCopyableColumn(col)) return
indices.push(index)
headers.push(excelCell(col.label))
})
if (!indices.length) return { headers: [], rows: [] }
const rows = input.rowValues.map((row) =>
indices.map((colIndex) => excelCell(row[colIndex] ?? ''))
)
return { headers, rows }
}
/** 将快照转为 Univer cellData(第 0 行为表头)。 */
export function snapshotToUniverCellData(snapshot: ListExcelSnapshot): Record<
number,
Record<number, { v: string; t: number }>
> {
const cellData: Record<number, Record<number, { v: string; t: number }>> = {}
const writeRow = (r: number, values: string[]) => {
const row: Record<number, { v: string; t: number }> = {}
values.forEach((value, c) => {
row[c] = { v: value, t: 1 }
})
cellData[r] = row
}
writeRow(0, snapshot.headers)
snapshot.rows.forEach((row, index) => writeRow(index + 1, row))
return cellData
}
function range(count: number): number[] {
return Array.from({ length: count }, (_, index) => index)
}
@@ -0,0 +1,190 @@
<!-- 证用品类 — P01 标准列表;与 CRM 分类独立 -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">证用品类</h2>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<ListQuickSearchInput
v-model="keyword"
placeholder="品类名称"
@search="search"
@clear="search"
/>
<el-button type="primary" @click="openEdit()">
<el-icon><Plus /></el-icon>新增品类
</el-button>
<el-button :loading="loading" @click="load">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<ListTable
v-loading="loading"
table-key="pl-cert-category"
class="list-table"
:data="rows"
stripe
row-key="id"
>
<el-table-column prop="categoryName" label="品类名称" min-width="160" />
<el-table-column prop="categoryCode" label="编码" min-width="120">
<template #default="{ row }">{{ row.categoryCode || '—' }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" min-width="100">
<template #default="{ row }">
<el-tag size="small" :type="row.status === '1' ? 'success' : 'info'">
{{ row.status === '1' ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" min-width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</ListTable>
</div>
</section>
<el-dialog
v-model="visible"
class="dialog-form-layout"
:title="editingId ? '编辑证用品类' : '新增证用品类'"
width="640px"
align-center
destroy-on-close
>
<el-form label-position="top">
<div class="form-grid-2">
<el-form-item label="品类名称" required>
<el-input v-model="form.categoryName" maxlength="128" />
</el-form-item>
<el-form-item label="编码">
<el-input v-model="form.categoryCode" maxlength="64" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status" class="w-full">
<el-option label="启用" value="1" />
<el-option label="停用" value="0" />
</el-select>
</el-form-item>
</div>
<el-form-item label="关联系统产品(产品 ID,逗号分隔,保存后按差量写入)">
<el-input
v-model="productIdsText"
type="textarea"
:rows="3"
placeholder="例如:uuid1,uuid2(上线后可改产品选择器)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" :loading="saving" @click="save">
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { certCategoryApi, type CertCategory } from '@/api/pl/quota-cert'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { Check, Close, Plus, Refresh } from '@element-plus/icons-vue'
const keyword = ref('')
const loading = ref(false)
const saving = ref(false)
const rows = ref<CertCategory[]>([])
const visible = ref(false)
const editingId = ref('')
const form = reactive({ categoryName: '', categoryCode: '', status: '1' })
const productIdsText = ref('')
async function load() {
loading.value = true
try {
rows.value = (await certCategoryApi.list({ keyword: keyword.value || undefined })) || []
} catch (error: any) {
ElMessage.error(error?.message || '加载失败')
} finally {
loading.value = false
}
}
function search() {
void load()
}
async function openEdit(row?: CertCategory) {
editingId.value = row?.id || ''
form.categoryName = row?.categoryName || ''
form.categoryCode = row?.categoryCode || ''
form.status = row?.status || '1'
productIdsText.value = ''
if (row?.id) {
const detail = await certCategoryApi.get(row.id)
productIdsText.value = (detail.products || [])
.filter((p) => p.scopeStatus !== 'inactive')
.map((p) => p.productId)
.join(',')
}
visible.value = true
}
async function save() {
if (!form.categoryName.trim()) {
ElMessage.warning('请填写品类名称')
return
}
const productIds = productIdsText.value
.split(/[,,\s]+/)
.map((s) => s.trim())
.filter(Boolean)
const body = {
categoryName: form.categoryName.trim(),
categoryCode: form.categoryCode || null,
status: form.status,
products: productIds.map((productId) => ({ productId, scopeStatus: 'active' }))
}
saving.value = true
try {
if (editingId.value) await certCategoryApi.update(editingId.value, body)
else await certCategoryApi.create(body)
ElMessage.success('已保存')
visible.value = false
await load()
} catch (error: any) {
ElMessage.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
async function remove(row: CertCategory) {
try {
await ElMessageBox.confirm(`确认删除品类「${row.categoryName}」?`, '删除', { type: 'warning' })
} catch {
return
}
try {
await certCategoryApi.remove(row.id)
ElMessage.success('已删除')
await load()
} catch (error: any) {
ElMessage.error(error?.message || '删除失败')
}
}
onMounted(() => {
void load()
})
</script>
+99 -16
View File
@@ -4,12 +4,25 @@
<div class="panel-top"><h2 class="title-bar-heading">证书齐备</h2></div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<el-radio-group v-model="listMode" class="mr-3" @change="onModeChange">
<el-radio-button value="mark">标记齐备</el-radio-button>
<el-radio-button value="fulfill">待完善并关闭</el-radio-button>
</el-radio-group>
<ListQuickSearchInput
v-model="keyword"
placeholder="计划编号"
@search="search"
@clear="search"
/>
<el-button
v-if="listMode === 'fulfill'"
type="primary"
:disabled="!selectedIds.length"
:loading="batching"
@click="fulfillBatch"
>
<el-icon><CircleCheck /></el-icon>批量完善并关闭
</el-button>
<el-button v-if="keyword || hasActiveFilters" @click="reset">
<el-icon><RefreshLeft /></el-icon>清除筛选
</el-button>
@@ -29,6 +42,7 @@
row-key="id"
stripe
class="list-table"
@selection-change="onSelectionChange"
>
<el-table-column
v-for="column in columns"
@@ -73,17 +87,34 @@
<span v-else>{{ row[column.prop] || '—' }}</span>
</template>
</el-table-column>
<el-table-column
v-if="listMode === 'fulfill'"
type="selection"
width="48"
:reserve-selection="false"
/>
<el-table-column label="操作" min-width="260" fixed="right">
<template #default="{ row }">
<el-button
v-if="!row.quotaCertWaived"
type="primary"
:loading="markingId === row.id"
@click="mark(row)"
>
<el-icon><CircleCheck /></el-icon>证书齐备
</el-button>
<span v-else>已确认</span>
<template v-if="listMode === 'fulfill'">
<el-button
type="primary"
:loading="markingId === row.id"
@click="fulfillOne(row)"
>
<el-icon><CircleCheck /></el-icon>完善并关闭
</el-button>
</template>
<template v-else>
<el-button
v-if="!row.quotaCertWaived"
type="primary"
:loading="markingId === row.id"
@click="mark(row)"
>
<el-icon><CircleCheck /></el-icon>证书齐备
</el-button>
<span v-else>已确认</span>
</template>
</template>
</el-table-column>
</ListTable>
@@ -124,6 +155,9 @@
const total = ref(0)
const loading = ref(false)
const markingId = ref('')
const batching = ref(false)
const listMode = ref<'mark' | 'fulfill'>('mark')
const selectedIds = ref<string[]>([])
const rows = ref<CertReadyPlan[]>([])
let epoch = 0
const defaultPlanDates = certReadyDefaultPlanDates()
@@ -203,13 +237,21 @@
async function load() {
const current = ++epoch
loading.value = true
selectedIds.value = []
try {
const value = await certReadyApi.list({
keyword: keyword.value || undefined,
page: page.value,
pageSize: 20,
...toQueryParams()
})
const value =
listMode.value === 'fulfill'
? await certReadyApi.listPendingFulfill({
keyword: keyword.value || undefined,
page: page.value,
pageSize: 20
})
: await certReadyApi.list({
keyword: keyword.value || undefined,
page: page.value,
pageSize: 20,
...toQueryParams()
})
if (current !== epoch) return
rows.value = value?.list || []
total.value = value?.total || 0
@@ -225,6 +267,11 @@
void load()
}
function onModeChange() {
page.value = 1
void load()
}
function reset() {
keyword.value = ''
if (hasActiveFilters.value) clearAllFilters()
@@ -234,7 +281,7 @@
async function mark(row: CertReadyPlan) {
try {
await ElMessageBox.confirm(
'确认后,这条运输计划在安排和归档时都不再校验购买证、运输证,也不扣减额度。确认人和时间会记在本行。',
'确认后可先安排运输;新规则下归档前仍须在「待完善并关闭」中挂上购买证/运输证。确认人和时间会记在本行。',
'证书齐备',
{ type: 'warning', confirmButtonText: '确认齐备', cancelButtonText: '取消' }
)
@@ -253,6 +300,42 @@
}
}
async function fulfillOne(row: CertReadyPlan) {
markingId.value = row.id
try {
await certReadyApi.fulfill(row.id)
ElMessage.success('已完善并关闭')
await load()
} catch (error) {
ElMessage.error(certReadyErrorText(error, '完善并关闭失败'))
} finally {
markingId.value = ''
}
}
function onSelectionChange(selection: CertReadyPlan[]) {
selectedIds.value = selection.map((row) => row.id)
}
async function fulfillBatch() {
if (!selectedIds.value.length) return
batching.value = true
try {
const res = await certReadyApi.fulfillBatch(selectedIds.value)
const failed = (res?.results || []).filter((r) => !r.ok)
if (failed.length) {
ElMessage.warning(`部分失败:${failed.map((f) => f.message || f.planId).join(';')}`)
} else {
ElMessage.success('批量完善完成')
}
await load()
} catch (error) {
ElMessage.error(certReadyErrorText(error, '批量完善失败'))
} finally {
batching.value = false
}
}
const tableWrapRef = ref<HTMLElement | null>(null)
const pagerRef = ref<HTMLElement | null>(null)
const tableRef = ref()
@@ -0,0 +1,78 @@
<!-- 运输证件规则切点 — 系统信息 Tab;P01 简表单 -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">运输证件规则</h2>
</div>
<div class="p-6 max-w-xl">
<p class="text-sm text-slate-500 mb-4">
配置「客户+品类」额度生效时间。未配置时无法安排运输。计划创建时按该时间打规则版本;修改后只影响之后新建的计划。
</p>
<el-form label-position="top" class="dialog-form-layout">
<el-form-item label="客户品类额度生效时间">
<el-date-picker
v-model="effectiveAt"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择生效时间"
class="w-full"
clearable
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="save">
<el-icon><Check /></el-icon>保存
</el-button>
<el-button :loading="loading" @click="load">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</el-form-item>
<p v-if="!configured" class="text-amber-600 text-sm">当前未配置:运输计划安排将被拦住。</p>
<p v-else class="text-emerald-700 text-sm">已配置生效时间:{{ effectiveAt }}</p>
</el-form>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { certQuotaRuleApi } from '@/api/pl/quota-cert'
import { Check, Refresh } from '@element-plus/icons-vue'
const loading = ref(false)
const saving = ref(false)
const effectiveAt = ref<string | null>(null)
const configured = ref(false)
async function load() {
loading.value = true
try {
const data = await certQuotaRuleApi.get()
effectiveAt.value = data?.effectiveAt || null
configured.value = Boolean(data?.configured)
} catch (error: any) {
ElMessage.error(error?.message || '加载失败')
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
try {
const data = await certQuotaRuleApi.save(effectiveAt.value)
effectiveAt.value = data?.effectiveAt || null
configured.value = Boolean(data?.configured)
ElMessage.success(configured.value ? '已保存生效时间' : '已清空生效时间(安排将被拦住)')
} catch (error: any) {
ElMessage.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(() => {
void load()
})
</script>