Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
168e909914 | ||
|
|
a327ccf69a | ||
|
|
cd94ebc56d | ||
|
|
aad74505c0 |
No files matched your search
@@ -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)
|
||||
|
||||
# 查询合同产品明细
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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="作废时间")
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
@@ -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="作废时间")
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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, '已安排计划请通过调整运输在同一事务重算用证')
|
||||
# 草稿允许缺少条件确认,但真实来源身份及选证权限必须有效。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/新规则。
|
||||
@@ -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",
|
||||
|
||||
Generated
+1469
-19
File diff suppressed because it is too large.
Load diff
@@ -9,4 +9,5 @@ allowBuilds:
|
||||
core-js: false
|
||||
es5-ext: false
|
||||
esbuild: true
|
||||
protobufjs: false
|
||||
vue-demi: true
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Vendored
+10
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user