Compare commits

...
Author SHA1 Message Date
wuzilongandCursor f41e47601d fix(pl): 归档再次打开时按实际数量回填运费运输量。
resetFreightCalc 在产品已加载后会清空运输量且不再触发 watch,导致再次归档误报运输量须大于0。

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 14:46:46 +08:00
wuzilong d5f3a79ea5 fix(pl): 新规则安排不再被本地合同绑证缺口误拦。
H5 运输计划验收发现 customer_category 在服务端自动选证已通过时,前端仍用合同绑证空缺拦截安排;同步用证面板从占用回填证号并补专项单测。
2026-11-15 08:22:46 +08:00
wuzilong c5ae2d957c fix(pl): 对齐移动 H5 证书齐备 2.0 与合同用证提示。
H5 齐备页补齐待完善/完善并关闭,修正会误导归档可跳过额度的文案;合同用证增加新规则只读说明,并补相关单测。
2026-11-15 08:04:38 +08:00
wuzilong 9f855423d8 fix(pl): 客户品类规则预占不再依赖合同绑证 scope。
新规则自动选证后账本仍按合同绑证判定适用,导致预占报人工分配不适用;改为客户品类路径跳过 scope,明细合同绑证字段可空,并统一关联购买证文案。
2026-11-15 08:01:14 +08:00
wuzilongandCursor a6fc0aa0f3 fix(pl): 运输证解析回填客户,并将证面引用改名为关联购买证。
运输证按收货单位精确匹配系统客户;Web 与移动 H5 同步改名与回填逻辑。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 07:44:08 +08:00
wuzilongandCursor c460386583 fix(pl): 证件解析后回填系统客户与证用品类。
解析结果不再只写旧合同绑定器;按证面名称精确匹配并填入客户与品类下拉。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 07:38:55 +08:00
wuzilongandCursor e8cda33a3c fix(pl): 修复客户品类证件额度选证与回显问题。
优先消耗有到期日的证件,补齐品类产品选择与运输证适用品类回显,避免新规则安排与建档展示失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 07:13:42 +08:00
wuzilongandCursor b0c1bca683 fix(pl): 补齐客户品类证件额度三期收口项。
完善 H5 建档、归档待完善引导、齐备上传入口与新规则只读提示,并同步计划验收状态与单测。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 23:57:36 +08:00
wuzilongandCursor 208c93d192 fix(pl): 证件建档表单改为客户与品类绑定。
新增系统客户、证用品类字段并写入保存载荷;合同产品绑定收进旧规则折叠区,避免新规则仍被合同绑定挡住。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 23:38:48 +08:00
wuzilongandCursor 168e909914 fix(web-admin): 恢复 Excel 查看中 Ctrl/Cmd+C 复制选区
filterOnly 默认关闭 WorksheetCopy;打开复制权限并增加选区 TSV 快捷键兜底。

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

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

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

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

No files matched your search

+7 -1
View File
@@ -2206,6 +2206,8 @@ def create_plan(
create_by=user.id,
create_dept_id=user.dept_id,
)
from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_version
stamp_plan_rule_version(db, user, plan)
db.add(plan)
# 添加产品明细
@@ -2377,6 +2379,8 @@ def create_plan_full(
create_by=user.id,
create_dept_id=user.dept_id,
)
from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_version
stamp_plan_rule_version(db, user, plan)
db.add(plan)
db.flush()
@@ -2527,6 +2531,8 @@ def create_from_contract(
create_by=user.id,
create_dept_id=user.dept_id,
)
from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_version
stamp_plan_rule_version(db, user, plan)
db.add(plan)
# 查询合同产品明细
@@ -3801,7 +3807,7 @@ def archive_plan(
db.flush()
result = get_plan(id, db, user)
if not quota_settled:
result.quota_settlement_error = read_quota_settlement_error(db, tenant_no, plan.id)
result.quota_settlement_error = read_quota_settlement_error(db, tenant_no, plan.id, plan=plan)
return result
+119 -1
View File
@@ -137,6 +137,100 @@ def mark_cert_ready(plan_id: str, db: Session = Depends(get_db), user: DbUserCon
return _write(db, lambda: mark_ready(db, user, plan_id))
@cert_ready_router.get('/plans/cert-ready/pending-fulfill')
def list_pending_fulfill(keyword: str = '', page: int = Query(1, ge=1),
page_size: int = Query(20, alias='pageSize', ge=1, le=100),
db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
from app.services.pl_quota_waiver import list_pending_fulfill_plans
return _wire(list_pending_fulfill_plans(db, user, page, page_size, keyword))
@cert_ready_router.post('/plans/{plan_id}/fulfill-quota-certs')
def fulfill_quota_certs(plan_id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
from app.services.pl_quota_waiver import fulfill_plan_certs
return _write(db, lambda: fulfill_plan_certs(db, user, plan_id))
class FulfillBatchBody(InputBody):
plan_ids: list = Field(default_factory=list, alias='planIds')
@cert_ready_router.post('/plans/fulfill-quota-certs/batch')
def fulfill_quota_certs_batch(body: FulfillBatchBody, db: Session = Depends(get_db),
user: DbUserContext = Depends(current_user)):
from app.services.pl_quota_waiver import fulfill_plans_batch
ids = [str(x) for x in (body.plan_ids or []) if x]
if not ids:
raise HTTPException(400, '请选择要完善的计划')
return _write(db, lambda: fulfill_plans_batch(db, user, ids))
@router.get('/rule-config')
def get_rule_config(db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
from app.services.pl_cert_quota_rule_config_service import read_config
return _wire(read_config(db, user.tenant_no))
class RuleConfigBody(InputBody):
effective_at: Optional[str] = Field(None, alias='effectiveAt')
@router.put('/rule-config', dependencies=[Depends(require_admin_or_super_admin)])
@oper_log('运输证件规则切点', business_type=2)
def put_rule_config(body: RuleConfigBody, db: Session = Depends(get_db),
user: DbUserContext = Depends(current_user)):
from datetime import datetime as dt
from app.services.pl_cert_quota_rule_config_service import save_effective_at
value = None
if body.effective_at:
text = body.effective_at.replace('T', ' ').strip()
try:
value = dt.strptime(text[:19], '%Y-%m-%d %H:%M:%S')
except ValueError:
try:
value = dt.strptime(text[:16], '%Y-%m-%d %H:%M')
except ValueError as exc:
raise HTTPException(400, '生效时间格式无效') from exc
return _write(db, lambda: save_effective_at(db, user, value))
@router.get('/categories')
def list_categories(keyword: Optional[str] = None, status: Optional[str] = None,
db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
from app.services import pl_cert_category_service as categories
return _wire(categories.list_categories(db, user, keyword, status))
@router.get('/categories/{category_id}')
def get_category(category_id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
from app.services import pl_cert_category_service as categories
return _wire(categories.get_category(db, user, category_id))
@router.post('/categories', dependencies=[Depends(require_menu_code('pl:quota-cert:edit'))])
@oper_log('证用品类', business_type=1)
def create_category(body: dict = Body(...), db: Session = Depends(get_db),
user: DbUserContext = Depends(current_user)):
from app.services import pl_cert_category_service as categories
return _write(db, lambda: categories.save_category(db, user, body))
@router.put('/categories/{category_id}', dependencies=[Depends(require_menu_code('pl:quota-cert:edit'))])
@oper_log('证用品类', business_type=2, path_param='category_id')
def update_category(category_id: str, body: dict = Body(...), db: Session = Depends(get_db),
user: DbUserContext = Depends(current_user)):
from app.services import pl_cert_category_service as categories
return _write(db, lambda: categories.save_category(db, user, body, category_id))
@router.delete('/categories/{category_id}', dependencies=[Depends(require_menu_code('pl:quota-cert:edit'))])
@oper_log('证用品类', business_type=3, path_param='category_id')
def delete_category(category_id: str, db: Session = Depends(get_db),
user: DbUserContext = Depends(current_user)):
from app.services import pl_cert_category_service as categories
return _write(db, lambda: categories.delete_category(db, user, category_id) or {'ok': True})
@router.get('/plans/{plan_id}/usage')
def plan_quota(plan_id: str, arrange_check: bool = Query(False, alias='arrangeCheck'),
db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
@@ -226,6 +320,23 @@ def contract_statistics_list(keyword: str = Query('', max_length=200),
cert_count=cert_count, cert_count_min=cert_count_min, cert_count_max=cert_count_max))
@router.get('/statistics/customers')
def customer_statistics_list(keyword: str = Query('', max_length=200),
customer_no: str = Query('', alias='customerNo', max_length=200),
customer_name: str = Query('', alias='customerName', max_length=200),
cert_count_op: str = Query('', alias='certCountOp', max_length=20),
cert_count: str = Query('', alias='certCount', max_length=40),
cert_count_min: str = Query('', alias='certCountMin', max_length=40),
cert_count_max: str = Query('', alias='certCountMax', max_length=40),
page: int = Query(1, ge=1),
page_size: int = Query(20, alias='pageSize', ge=1, le=100),
db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
return _wire(statistics.list_customers(
db, user, keyword=keyword, page=page, page_size=page_size, customer_no=customer_no,
customer_name=customer_name, cert_count_op=cert_count_op, cert_count=cert_count,
cert_count_min=cert_count_min, cert_count_max=cert_count_max))
@router.get('/statistics/certificates')
def certificate_statistics_list(keyword: str = Query('', max_length=200),
cert_no: str = Query('', alias='certNo', max_length=200),
@@ -284,6 +395,12 @@ def contract_statistics(contract_id: str, db: Session = Depends(get_db),
return _wire(statistics.contract_statistics(db, user, contract_id))
@router.get('/customers/{customer_id}/statistics')
def customer_statistics(customer_id: str, db: Session = Depends(get_db),
user: DbUserContext = Depends(current_user)):
return _wire(statistics.customer_statistics(db, user, customer_id))
@router.get('/{cert_type}/{cert_id}/statistics')
def certificate_statistics(cert_type: CertType, cert_id: str, db: Session = Depends(get_db),
user: DbUserContext = Depends(current_user)):
@@ -292,8 +409,9 @@ def certificate_statistics(cert_type: CertType, cert_id: str, db: Session = Depe
@router.get('/{cert_type}')
def list_certs(cert_type: CertType, keyword: Optional[str] = None, status: Optional[str] = None,
customer_id: Optional[str] = Query(None, alias='customerId', max_length=64),
db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
return _wire(certs.list_certs(db, user, cert_type, keyword, status))
return _wire(certs.list_certs(db, user, cert_type, keyword, status, customer_id=customer_id))
@router.post('/{cert_type}/recognize', dependencies=[Depends(require_menu_code('pl:quota-cert:edit'))])
+2
View File
@@ -725,6 +725,8 @@ def arrange_transport(
raise HTTPException(status_code=404, detail="计划不存在")
assert_pl_row_in_scope(db, plan, PlPlan, user, tenant_no)
assert_plan_can_arrange(plan)
from app.services.pl_cert_quota_rule_config_service import assert_cert_quota_rule_configured
assert_cert_quota_rule_configured(db, tenant_no)
assert_no_pending_stop_products(db, tenant_no, plan_id)
+3 -1
View File
@@ -366,10 +366,12 @@ class PlPlan(Base):
cert_configuration = Column(JSON, nullable=True, comment="用证来源配对、候选选择与逐证条件确认;不作为放行布尔状态")
hazard_cert_ready_by = Column(String(64), nullable=True, comment="危化品证书确认人")
hazard_cert_ready_time = Column(DateTime, nullable=True, comment="危化品证书确认时间")
quota_cert_waived = Column(SmallInteger, nullable=False, default=0, comment="证书齐备:1 时安排和归档不校验、不扣购买证与运输证额度")
quota_cert_waived = Column(SmallInteger, nullable=False, default=0, comment="证书齐备:1 时安排和归档不校验、不扣购买证与运输证额度(新规则仅放行安排)")
quota_cert_waived_by = Column(String(64), nullable=True, comment="证书齐备确认人ID")
quota_cert_waived_by_name = Column(String(100), nullable=True, comment="证书齐备确认人姓名")
quota_cert_waived_time = Column(DateTime, nullable=True, comment="证书齐备确认时间")
cert_quota_rule_version = Column(String(32), nullable=True, comment="证件额度规则版本 legacy|customer_category;创建时打标")
quota_cert_pending_fulfill = Column(SmallInteger, nullable=False, default=0, comment="新规则证书齐备待完善 0-否 1-是")
erp_entered = Column(SmallInteger, nullable=False, default=0, comment="是否已录入ERP 0-否 1-是")
cancel_reason = Column(String(500), nullable=True, comment="作废原因")
cancel_time = Column(DateTime, nullable=True, comment="作废时间")
+65 -3
View File
@@ -34,6 +34,8 @@ class PlPurchaseCert(Base):
seller_legal_representative = Column(String(100), nullable=True, comment='销售方法定代表人')
seller_phone = Column(String(100), nullable=True, comment='销售方电话')
purchase_purpose = Column(String(1000), nullable=True, comment='用途')
customer_id = Column(String(64), nullable=True, comment='系统客户ID;选证必填')
category_id = Column(String(64), nullable=True, comment='证用品类ID;选证必填')
tenant_no = Column(String(64), nullable=False, comment='租户编号,由服务端当前用户上下文设置')
order_num = Column(Integer, nullable=True, comment='公共排序号')
remark = Column(String(1000), nullable=True, comment='系统备注,与证面备注分开')
@@ -80,6 +82,7 @@ class PlTransportCert(Base):
route_text = Column(Text, nullable=True, comment='运输路线完整原文')
consignee_name = Column(String(255), nullable=True, comment='收货单位名称或姓名')
applicant_name = Column(String(255), nullable=True, comment='申请单位名称或姓名')
customer_id = Column(String(64), nullable=True, comment='系统客户ID;选证必填')
tenant_no = Column(String(64), nullable=False, comment='租户编号,由服务端当前用户上下文设置')
order_num = Column(Integer, nullable=True, comment='公共排序号')
remark = Column(String(1000), nullable=True, comment='系统备注,与证面备注分开')
@@ -196,9 +199,9 @@ class PlPlanCertUsageItem(Base):
__tablename__ = "pl_plan_cert_usage_item"
id = Column(String(64), primary_key=True, comment='主键UUID')
usage_id = Column(String(64), nullable=False, comment='pl_plan_cert_usage.id')
contract_cert_link_id = Column(String(64), nullable=False, comment='合同与证件绑定ID,须与usage的证件相同')
contract_cert_product_id = Column(String(64), nullable=False, comment='本产品的适用范围明细ID:pl_contract_cert_product.id')
contract_scope_revision = Column(Integer, nullable=False, comment='本次确认的合同证件适用范围版本')
contract_cert_link_id = Column(String(64), nullable=True, comment='合同与证件绑定ID;客户品类规则可空')
contract_cert_product_id = Column(String(64), nullable=True, comment='合同证件产品范围明细ID;客户品类规则可空')
contract_scope_revision = Column(Integer, nullable=False, default=0, comment='合同证件适用范围版本;客户品类规则为0')
contract_id = Column(String(64), nullable=False, comment='本次履行的山峡合同ID')
contract_product_id = Column(String(64), nullable=False, comment='对应合同产品行ID')
plan_product_id = Column(String(64), nullable=False, comment='计划产品行ID')
@@ -323,3 +326,62 @@ class PlStopLegalDocument(Base):
__table_args__ = (
UniqueConstraint('tenant_no', 'plan_id', 'stop_type', 'stop_product_id', name='uk_stop_legal_document'),
)
class PlCertCategory(Base):
"""证用品类(与 CRM 产品分类独立)。"""
__tablename__ = 'pl_cert_category'
id = Column(String(64), primary_key=True, comment='主键UUID')
category_name = Column(String(128), nullable=False, comment='证用品类名称,如硫酸')
category_code = Column(String(64), nullable=True, comment='品类编码,可选')
status = Column(String(1), nullable=False, default='1', comment='状态 1启用 0停用')
tenant_no = Column(String(64), nullable=False, comment='租户编号')
order_num = Column(Integer, nullable=True, comment='排序号')
remark = Column(String(1000), nullable=True, comment='备注')
create_by = Column(String(64), nullable=True, comment='创建人')
create_dept_id = Column(String(64), nullable=False, default='', comment='创建部门')
create_time = Column(DateTime, nullable=False, default=datetime.now, comment='创建时间')
update_by = Column(String(64), nullable=True, comment='更新人')
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment='更新时间')
del_flag = Column(SmallInteger, nullable=False, default=0, comment='软删除标记:0正常,1删除')
class PlCertCategoryProduct(Base):
"""证用品类关联系统产品。"""
__tablename__ = 'pl_cert_category_product'
id = Column(String(64), primary_key=True, comment='主键UUID')
category_id = Column(String(64), nullable=False, comment='证用品类ID')
product_id = Column(String(64), nullable=False, comment='CRM产品ID')
scope_status = Column(String(20), nullable=False, default='active', comment='active/inactive')
tenant_no = Column(String(64), nullable=False, comment='租户编号')
order_num = Column(Integer, nullable=True, comment='排序号')
remark = Column(String(1000), nullable=True, comment='备注')
create_by = Column(String(64), nullable=True, comment='创建人')
create_dept_id = Column(String(64), nullable=False, default='', comment='创建部门')
create_time = Column(DateTime, nullable=False, default=datetime.now, comment='创建时间')
update_by = Column(String(64), nullable=True, comment='更新人')
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment='更新时间')
del_flag = Column(SmallInteger, nullable=False, default=0, comment='软删除标记:0正常,1删除')
__table_args__ = (
UniqueConstraint('tenant_no', 'category_id', 'product_id', name='uk_cert_category_product'),
Index('idx_cert_cat_prod_product', 'tenant_no', 'product_id', 'del_flag', 'scope_status'),
)
class PlCertQuotaRuleConfig(Base):
"""客户品类额度规则切点;每租户一行。"""
__tablename__ = 'pl_cert_quota_rule_config'
id = Column(String(64), primary_key=True, comment='主键UUID')
effective_at = Column(DateTime, nullable=True, comment='客户品类额度生效时间;空表示未配置')
tenant_no = Column(String(64), nullable=False, comment='租户编号')
order_num = Column(Integer, nullable=True, comment='排序号')
remark = Column(String(1000), nullable=True, comment='备注')
create_by = Column(String(64), nullable=True, comment='创建人')
create_dept_id = Column(String(64), nullable=False, default='', comment='创建部门')
create_time = Column(DateTime, nullable=False, default=datetime.now, comment='创建时间')
update_by = Column(String(64), nullable=True, comment='更新人')
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment='更新时间')
del_flag = Column(SmallInteger, nullable=False, default=0, comment='软删除标记:0正常,1删除')
__table_args__ = (
UniqueConstraint('tenant_no', name='uk_cert_quota_rule_tenant'),
)
+3 -1
View File
@@ -413,10 +413,12 @@ class PlanRead(PlanBase, PlanResourceSnapshotMixin):
hazard_cert_ready: int = Field(default=0, alias="hazardCertReady", description="危化品证书是否已人工确认齐备")
hazard_cert_ready_by: Optional[str] = Field(default=None, alias="hazardCertReadyBy", description="危化品证书确认人")
hazard_cert_ready_time: Optional[datetime] = Field(default=None, alias="hazardCertReadyTime", description="危化品证书确认时间")
quota_cert_waived: int = Field(default=0, alias="quotaCertWaived", description="证书齐备 0-否 1-是;为 1 时不校验、不扣购买证与运输证额度")
quota_cert_waived: int = Field(default=0, alias="quotaCertWaived", description="证书齐备 0-否 1-是;新规则仅放行安排")
quota_cert_waived_by: Optional[str] = Field(default=None, alias="quotaCertWaivedBy", description="证书齐备确认人ID")
quota_cert_waived_by_name: Optional[str] = Field(default=None, alias="quotaCertWaivedByName", description="证书齐备确认人姓名")
quota_cert_waived_time: Optional[datetime] = Field(default=None, alias="quotaCertWaivedTime", description="证书齐备确认时间")
cert_quota_rule_version: Optional[str] = Field(default=None, alias="certQuotaRuleVersion", description="legacy|customer_category")
quota_cert_pending_fulfill: int = Field(default=0, alias="quotaCertPendingFulfill", description="新规则齐备待完善 0否 1是")
erp_entered: int = Field(default=0, alias="erpEntered", description="是否已录入ERP 0-否 1-是")
cancel_reason: Optional[str] = Field(default=None, alias="cancelReason", description="作废原因")
cancel_time: Optional[datetime] = Field(default=None, alias="cancelTime", description="作废时间")
+3
View File
@@ -48,6 +48,8 @@ class PurchaseCertUpdate(CertFields):
seller_legal_representative: Optional[str] = Field(None, max_length=100)
seller_phone: Optional[str] = Field(None, max_length=100)
purchase_purpose: Optional[str] = Field(None, max_length=1000)
customer_id: Optional[str] = Field(None, alias='customerId', max_length=64)
category_id: Optional[str] = Field(None, alias='categoryId', max_length=64)
class PurchaseCertCreate(PurchaseCertUpdate):
@@ -83,6 +85,7 @@ class TransportCertUpdate(CertFields):
route_text: Optional[str] = None
consignee_name: Optional[str] = Field(None, max_length=255)
applicant_name: Optional[str] = Field(None, max_length=255)
customer_id: Optional[str] = Field(None, alias='customerId', max_length=64)
purchase_refs: Optional[List[PurchaseReference]] = None
@@ -0,0 +1,202 @@
"""证用品类与产品关联;差量保存关联行。"""
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()
)
names = _product_labels(db, user.tenant_no, [link.product_id for link in links])
products = [
{
'id': link.id,
'productId': link.product_id,
'scopeStatus': link.scope_status,
**names.get(link.product_id, {}),
}
for link in links
]
return _category_dict(row, products)
def _product_labels(db: Session, tenant_no: str, product_ids):
"""批量带出 CRM 产品编号/名称/型号,供品类编辑回显。"""
ids = [pid for pid in dict.fromkeys(product_ids or []) if pid]
if not ids:
return {}
from app.models.crm_models import CrmProduct
rows = (
db.query(CrmProduct)
.filter(
CrmProduct.tenant_no == tenant_no,
CrmProduct.del_flag == 0,
CrmProduct.id.in_(ids),
)
.all()
)
return {
row.id: {
'productNo': row.product_no,
'productName': row.product_name,
'model': row.model,
}
for row in rows
}
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
+29 -3
View File
@@ -120,10 +120,22 @@ def apply_plan_quota(db, user, plan, action, payload=None):
evidenceUrls=getattr(payload, 'quota_evidence_urls', None),
sourceNote=getattr(payload, 'quota_source_note', None))
from app.services.pl_quota_waiver import assert_waived_sources, quota_cert_waived
if quota_cert_waived(plan) and action in ('reserve', 'resize', 'settle', 'actual_adjust'):
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
# 新规则:齐备仅放行安排/改派预占;归档与实际调整必须真证结转。
# 旧规则(legacy):齐备仍跳过安排与归档(祖父兼容)。
skip_actions = ('reserve', 'resize')
if not plan_uses_customer_category(plan):
skip_actions = ('reserve', 'resize', 'settle', 'actual_adjust')
if quota_cert_waived(plan) and action in skip_actions:
if action in ('reserve', 'resize'):
assert_waived_sources(db, user, plan, lock=True)
return {'operationId': ledger_id, 'usageIds': [], 'waived': True}
if (
plan_uses_customer_category(plan)
and action in ('settle', 'actual_adjust')
and int(getattr(plan, 'quota_cert_pending_fulfill', 0) or 0) == 1
):
raise HTTPException(409, '该计划仍处于证书齐备待完善,请先在证书齐备中完善并关闭后再归档')
request, config = build_usage_request(
db, user, plan.id, op, action, lock=True, check_conditions=explicit_configuration,
)
@@ -159,26 +171,40 @@ def settle_plan_quota(db, user, plan, payload, *, adjustment=False):
except ArchiveQuotaBlocked as exc:
raise HTTPException(409, str(exc)) from exc
except (HTTPException, ValueError, AllocationIncomplete) as exc:
message = str(getattr(exc, 'detail', exc))
# 无用量行(如证书齐备仅放行安排)时仍保留原因,供同请求读回。
setattr(plan, '_quota_settlement_error', message)
rows = db.query(PlPlanCertUsage).filter_by(tenant_no=user.tenant_no, plan_id=plan.id, del_flag=0).all()
for row in rows:
if not adjustment:
row.usage_status = 'exception'
row.confirmation_snapshot = {**(row.confirmation_snapshot or {}), 'executed': True,
'settlementError': str(getattr(exc, 'detail', exc))}
'settlementError': message}
row.update_by = user.id
if not adjustment:
plan.archive_status = 'exception'
return False
def read_quota_settlement_error(db, tenant_no, plan_id):
def read_quota_settlement_error(db, tenant_no, plan_id, plan=None):
"""结转失败时返回已经写在用量快照上的原因。"""
from app.models.pl_models import PlPlan
from app.models.pl_quota_models import PlPlanCertUsage
if plan is not None:
cached = getattr(plan, '_quota_settlement_error', None)
if isinstance(cached, str) and cached.strip():
return cached.strip()
rows = db.query(PlPlanCertUsage).filter_by(tenant_no=tenant_no, plan_id=plan_id, del_flag=0).all()
for row in rows:
message = (row.confirmation_snapshot or {}).get('settlementError')
if isinstance(message, str) and message.strip():
return message.strip()
target = plan
if target is None:
target = db.query(PlPlan).filter_by(id=plan_id, tenant_no=tenant_no, del_flag=0).first()
if target is not None and int(getattr(target, 'quota_cert_pending_fulfill', 0) or 0) == 1:
return '该计划仍处于证书齐备待完善,请先在证书齐备中完善并关闭后再归档'
return '实际数量已保存,证件没有扣减。请把实际数量改小,或先补充购买证和运输证,再重新归档。'
@@ -129,7 +129,7 @@ def save_cert(db, user, cert_type, data, cert_id=None):
raise HTTPException(409, '证件合同范围已变更,请重试')
if cert_type == 'transport' and any(('purchase', pid) not in keys for pid in
_reference_ids(db, user, effective_id, refs, current=True)):
raise HTTPException(409, '证面引用已变更,请重试')
raise HTTPException(409, '关联购买证已变更,请重试')
prepared_face = certs.prepare_cert(db, user, cert_type, face, cert_id,
locked=locked, effective_id=effective_id)
# 用最终证面状态校验作废证不可扩大,不修改持久对象。
+230 -11
View File
@@ -9,6 +9,7 @@ from fastapi import HTTPException
from pydantic import BaseModel
from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError
from app.models.crm_models import CrmCustomer
from app.models.pl_quota_models import (
PlPurchaseCert, PlTransportCert, PlTransportCertPurchaseRef,
PlCertQtyLog, PlPlanCertUsage, PlContractCertLink, PlCertOperation,
@@ -47,6 +48,39 @@ def usage_policy(valid_use_text):
return {'一次有效': 'once', '多次有效': 'multiple'}.get((valid_use_text or '').strip(), 'needs_confirmation')
_ONCE_USAGE_STATUSES = ('reserved', 'in_transit', 'settled', 'exception')
def is_single_use_consumed(db, user, cert_type, cert, *, exclude_plan_id=None):
"""一次有效证是否已不可再用:期初已使用,或其它计划已占用/已执行。
exclude_plan_id:当前计划自身的用量不计入占用(安排/结转同一计划可续用)。
多次有效或待确认原文返回 False,由调用方另行校验 policy。
"""
if usage_policy(getattr(cert, 'valid_use_text', None)) != 'once':
return False
if getattr(cert, 'opening_single_use_consumed', 0):
return True
previous = (
db.query(PlPlanCertUsage)
.filter_by(
tenant_no=user.tenant_no,
cert_type=cert_type,
cert_id=cert.id,
del_flag=0,
)
.all()
)
for usage in previous:
if exclude_plan_id is not None and usage.plan_id == exclude_plan_id:
continue
if usage.usage_status in _ONCE_USAGE_STATUSES:
return True
if (usage.confirmation_snapshot or {}).get('executed'):
return True
return False
def _assert_known_usage_policy(row):
if usage_policy(row.valid_use_text) == 'needs_confirmation':
raise HTTPException(400, '有效次数原文待确认,明确一次或多次有效后才能启用')
@@ -63,8 +97,50 @@ 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 _customer_display_name(db, user, customer_id):
"""按证件绑定的系统客户 ID 取 CRM 名称;与证面收货/购买单位无关。"""
cid = str(customer_id or '').strip()
if not cid:
return None
row = (
db.query(CrmCustomer.customer_name)
.filter(
CrmCustomer.id == cid,
CrmCustomer.tenant_no == user.tenant_no,
CrmCustomer.del_flag == 0,
)
.first()
)
return (row[0] if row else None) or None
def get_cert(db, user, cert_type, cert_id, current=False):
row = _get_row(db, user, cert_type, cert_id, lock=current)
# 存量未期初确认的证件在读取时自动补期初0并启用(void/closed 除外)。
if not current:
row = ensure_zero_opening(db, user, cert_type, cert_id, row=row)
result = _row_to_dict(row)
result['usagePolicy'] = usage_policy(row.valid_use_text)
opening_query = db.query(PlCertQtyLog.id).filter_by(
@@ -74,16 +150,23 @@ def get_cert(db, user, cert_type, cert_id, current=False):
opening_query = opening_query.with_for_update()
result['openingConfirmed'] = opening_query.first() is not None
result['availableQty'] = row.total_qty - row.used_qty - row.reserved_qty
result['singleUseConsumed'] = is_single_use_consumed(db, user, cert_type, row)
result['customerName'] = _customer_display_name(db, user, getattr(row, 'customer_id', None))
if cert_type == '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['referencesEditable'] = not _has_usage_beyond_zero_opening(
db, user, cert_type, cert_id, lock=current
)
result['derivedCategoryIds'] = _transport_derived_categories(db, user, cert_id)
return result
def list_certs(db, user, cert_type, keyword=None, status=None):
def list_certs(db, user, cert_type, keyword=None, status=None, customer_id=None):
model = _model(cert_type)
query = db.query(model).filter(model.tenant_no == user.tenant_no, model.del_flag == 0)
query = apply_pl_scope(query, model, user, db, user.tenant_no)
if customer_id:
query = query.filter(model.customer_id == customer_id)
if keyword:
sale_name = model.seller_name if cert_type == 'purchase' else model.shipper_name
buy_name = model.buyer_name if cert_type == 'purchase' else model.consignee_name
@@ -94,7 +177,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):
@@ -139,8 +229,11 @@ def _refs_keep_verified(pending, removed):
def _has_history(db, user, cert_type, cert_id, lock=True):
"""任意额度流水或计划占用均视为已有历史(含自动期初),用于锁证号/核定量等身份。"""
for model in (PlCertQtyLog, PlPlanCertUsage):
query = db.query(model.id).filter_by(tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id)
query = db.query(model.id).filter_by(
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, del_flag=0,
)
if lock:
query = query.with_for_update()
if query.first() is not None:
@@ -148,6 +241,35 @@ def _has_history(db, user, cert_type, cert_id, lock=True):
return False
def _has_usage_beyond_zero_opening(db, user, cert_type, cert_id, lock=True):
"""真实用量/占用才锁运输证引用;期初已用=0 的 opening 仍可补全关联购买证。"""
usage_q = db.query(PlPlanCertUsage.id).filter_by(
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, del_flag=0,
)
if lock:
usage_q = usage_q.with_for_update()
if usage_q.first() is not None:
return True
log_q = db.query(PlCertQtyLog).filter_by(
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, del_flag=0,
)
if lock:
log_q = log_q.with_for_update()
for log in log_q.all():
snap = log.event_snapshot or {}
# 仅建档自动期初(已用0)不锁引用;手工期初或其它流水仍锁。
if (
log.event_type == 'opening'
and snap.get('auto')
and (log.used_delta or 0) == 0
and (log.reserved_delta or 0) == 0
and (log.total_delta or 0) == 0
):
continue
return True
return False
def _validate_refs(db, user, cert_id, submitted, historical, purchases, current=False):
if submitted is None:
raise HTTPException(400, '引用集合不可为null;清空请提交[]')
@@ -181,13 +303,13 @@ def _validate_refs(db, user, cert_id, submitted, historical, purchases, current=
if purchase_cert_id:
purchase = purchases.get(purchase_cert_id)
if purchase is None:
raise HTTPException(409, '证面引用已变更,请刷新后重新保存')
raise HTTPException(409, '关联购买证已变更,请刷新后重新保存')
if historical and _historical_ref_locked(previous, item):
raise HTTPException(400, '已有历史用量,不能改变证面引用身份,请联系管理员')
raise HTTPException(400, '已有历史用量,不能改变关联购买证身份,请联系管理员')
pending.append((previous, item))
removed = [r for rid,r in old.items() if rid not in ids]
if historical and removed:
raise HTTPException(400, '已有历史用量,不能移除证面引用,请联系管理员')
raise HTTPException(400, '已有历史用量,不能移除关联购买证,请联系管理员')
return pending, removed
@@ -211,7 +333,7 @@ def prepare_cert(db, user, cert_type, data, cert_id=None, *, locked=None, effect
purchases = {pid: (locked.get(('purchase', pid)) if locked is not None else
_get_row(db, user, 'purchase', pid, lock=True)) for pid in sorted(purchase_ids)}
if any(row is None for row in purchases.values()):
raise HTTPException(409, '证面引用已变更,请重试')
raise HTTPException(409, '关联购买证已变更,请重试')
row = (locked[(cert_type, cert_id)] if locked is not None else
_get_row(db, user, cert_type, cert_id, lock=True)) if cert_id else None
historical = bool(row and _has_history(db,user,cert_type,cert_id))
@@ -252,7 +374,8 @@ def prepare_cert(db, user, cert_type, data, cert_id=None, *, locked=None, effect
if duplicate and duplicate.id != cert_id:
raise HTTPException(400, '证号已存在,删除后也不得重建额度池')
effective_id = cert_id or effective_id or str(uuid4())
pending, removed = _validate_refs(db,user,effective_id,refs,historical,purchases,current=bool(cert_id)) if refs_present else ([],[])
refs_locked = bool(row and _has_usage_beyond_zero_opening(db, user, cert_type, cert_id))
pending, removed = _validate_refs(db,user,effective_id,refs,refs_locked,purchases,current=bool(cert_id)) if refs_present else ([],[])
changed = {key for key, value in values.items() if row is None or getattr(row, key) != value}
refs_changed = bool(removed) or any(previous is None or previous.del_flag == 1 or any(getattr(previous, key) != value for key, value in item.items() if key != 'id') for previous, item in pending)
return dict(model=model, row=row, values=values, cert_id=cert_id, effective_id=effective_id,
@@ -299,19 +422,98 @@ def apply_cert(db, user, prepared):
return row
def ensure_zero_opening(db, user, cert_type, cert_id, *, row=None):
"""建档后无需手工期初确认:缺 opening 流水时写入期初已用=0;draft 直接启用。"""
if row is None:
row = _get_row(db, user, cert_type, cert_id)
if row.status in ('void', 'closed'):
return row
existing = db.query(PlCertQtyLog.id).filter_by(
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, event_type='opening', del_flag=0,
).first()
if existing:
return row
row = _get_row(db, user, cert_type, cert_id, lock=True)
if row.status in ('void', 'closed'):
return row
if db.query(PlCertQtyLog.id).filter_by(
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, event_type='opening', del_flag=0,
).with_for_update().first():
return row
# 有效次数原文未识别时保留 draft,不自动启用;明确一次/多次后保存或读取再启用。
if usage_policy(row.valid_use_text) == 'needs_confirmation':
return row
used = Decimal(row.used_qty or 0)
op_id = f'auto-opening:{cert_type}:{cert_id}'
digest = hashlib.sha256(op_id.encode()).hexdigest()
operation, replay = _claim_operation(db, user, 'opening', op_id, digest)
if replay:
return row
db.add(PlCertQtyLog(
**_audit(user),
operation_id=op_id,
request_hash=digest,
cert_type=cert_type,
cert_id=cert_id,
event_type='opening',
quantity_unit=row.quantity_unit,
total_delta=Decimal(0),
reserved_delta=Decimal(0),
used_delta=used,
before_total_qty=row.total_qty,
after_total_qty=row.total_qty,
before_reserved_qty=row.reserved_qty,
after_reserved_qty=row.reserved_qty,
before_used_qty=used,
after_used_qty=used,
reason='建档自动启用',
evidence_urls=None,
event_snapshot={'singleUseConsumed': False, 'sourceNote': 'auto', 'auto': True},
))
if row.status == 'draft':
row.status = 'active'
row.verified_by = user.id
row.verified_time = datetime.now()
row.update_by = user.id
operation.result_json = {'certType': cert_type, 'certId': cert_id, 'auto': True}
db.flush()
return row
def save_cert(db, user, cert_type, data, cert_id=None):
row = apply_cert(db, user, prepare_cert(db, user, cert_type, data, cert_id))
ensure_zero_opening(db, user, cert_type, row.id, row=row)
return get_cert(db, user, cert_type, row.id)
def delete_cert(db, user, cert_type, cert_id):
row = _get_row(db,user,cert_type,cert_id,lock=True)
if _has_history(db,user,cert_type,cert_id) or row.reserved_qty or row.used_qty:
if row.reserved_qty or row.used_qty:
raise HTTPException(400, '已有用量或流水的证件只能停用、核销或作废')
if db.query(PlPlanCertUsage.id).filter_by(
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, del_flag=0,
).with_for_update().first():
raise HTTPException(400, '已有用量或流水的证件只能停用、核销或作废')
logs = (
db.query(PlCertQtyLog)
.filter_by(tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, del_flag=0)
.with_for_update()
.all()
)
# 仅建档自动期初(或无流水)仍允许删除;手工期初或其它额度流水则禁止。
def _auto_opening(log):
snap = log.event_snapshot or {}
return log.event_type == 'opening' and bool(snap.get('auto')) and (log.used_delta or 0) == 0
if any(not _auto_opening(log) for log in logs):
raise HTTPException(400, '已有用量或流水的证件只能停用、核销或作废')
if db.query(PlContractCertLink.id).filter_by(tenant_no=user.tenant_no,cert_type=cert_type,cert_id=cert_id,del_flag=0).with_for_update().first():
raise HTTPException(400, '证件已绑定合同')
if cert_type == 'purchase' and db.query(PlTransportCertPurchaseRef.id).filter_by(tenant_no=user.tenant_no,purchase_cert_id=cert_id,del_flag=0).with_for_update().first():
raise HTTPException(400, '证件被运输证引用')
for log in logs:
log.del_flag = 1
log.update_by = user.id
row.del_flag = 1
row.update_by = user.id
if cert_type == 'transport':
@@ -358,7 +560,24 @@ def _confirm_cert(db, user, cert_type, cert_id, data):
_assert_known_usage_policy(row)
if row.status == 'void': raise HTTPException(400, '作废证件不能启用')
if row.status == 'closed': raise HTTPException(400, '已核销证件不能启用')
if row.verified_time or _has_history(db,user,cert_type,cert_id):
# 建档自动期初(used=0)可被手工/接口期初覆盖;已有真实用量或非自动期初则拒绝。
openings = (
db.query(PlCertQtyLog)
.filter_by(
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id,
event_type='opening', del_flag=0,
)
.with_for_update()
.all()
)
for log in openings:
snap = log.event_snapshot or {}
if snap.get('auto') and (log.used_delta or 0) == 0:
log.del_flag = 1
log.update_by = user.id
else:
raise HTTPException(400, '期初已确认,不能重复初始化额度')
if _has_history(db, user, cert_type, cert_id):
raise HTTPException(400, '期初已确认,不能重复初始化额度')
if body.used_qty > row.total_qty:
raise HTTPException(400, '期初已用量不能超过核定量')
@@ -0,0 +1,236 @@
"""新规则:按段次客户+品类整笔选证(不拆购买证)。"""
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_cert_service import is_single_use_consumed
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 _ensure_opening_quiet(db, user, cert_type, cert_id, row):
"""选证前尽量自动补期初;单元测试 mock 库失败时静默跳过。"""
try:
from app.services.pl_quota_cert_service import ensure_zero_opening
ensure_zero_opening(db, user, cert_type, cert_id, row=row)
except Exception:
return
def _vehicle_ok(cert, vehicle_no: str) -> bool:
plates = cert.vehicle_numbers_json or []
if not isinstance(plates, list):
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,
'产品未配置证用品类,请先在证用品类中关联系统产品后再安排运输',
)
# MySQL ASC 会把 NULL 排最前;无到期证应排在末尾,优先消耗更早到期的证(D5)
candidates = (
db.query(PlPurchaseCert)
.filter(
PlPurchaseCert.tenant_no == user.tenant_no,
PlPurchaseCert.del_flag == 0,
PlPurchaseCert.status == 'active',
PlPurchaseCert.customer_id == customer_id,
PlPurchaseCert.category_id.in_(categories),
)
.order_by(
PlPurchaseCert.valid_to.is_(None).asc(),
PlPurchaseCert.valid_to.asc(),
PlPurchaseCert.id.asc(),
)
.all()
)
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
_ensure_opening_quiet(db, user, 'purchase', purchase.id, purchase)
if not _has_opening(db, user, 'purchase', purchase.id):
continue
if not _in_force(purchase, on_date):
continue
if is_single_use_consumed(db, user, 'purchase', purchase):
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.is_(None).asc(),
PlTransportCert.valid_to.asc(),
PlTransportCert.id.asc(),
)
.all()
)
for transport in transports:
if not transport.verified_by or not transport.verified_time:
continue
_ensure_opening_quiet(db, user, 'transport', transport.id, transport)
if not _has_opening(db, user, 'transport', transport.id):
continue
if not _in_force(transport, on_date):
continue
if is_single_use_consumed(db, user, 'transport', transport):
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
+1 -1
View File
@@ -89,7 +89,7 @@ def missing_purchase_link_message(demands, purchases, transports, cert_nos):
seen.add(key)
notices.append(
f'运输证 {move_text} 没有引用合同正在使用的购买证 {buy_text}。'
'请先打开该运输证,在「证面引用」的「对应购买证档案」中选择这张购买证,再安排运输'
'请先打开该运输证,在「关联购买证」中选择这张购买证档案,再安排运输'
)
return ';'.join(notices)
+57 -18
View File
@@ -131,28 +131,52 @@ def build_usage_request(db, user, plan_id, operation, action, *, lock=False, che
sources, actuals = [], {}
else:
sources, actuals = extract_sources(db, user, plan_id, mapping, actual=action in ('settle', 'actual_adjust'), lock=lock, preview_vehicle=preview_vehicle, measurement_version=config.measurement_version)
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
customer_rule = plan_uses_customer_category(plan)
if action in ('reserve', 'resize') and not explicit and sources:
purchase_ids, transport_ids = resolve_contract_bound_certs(db, user, sources)
source_ids = {source.source_id for source in sources}
allowed = {('purchase', cert_id) for cert_id in purchase_ids} | {('transport', cert_id) for cert_id in transport_ids}
manual = [
row for row in config.manual_allocations
if row.source_id in source_ids and (row.permit_kind, row.permit_id) in allowed
]
config = config.model_copy(update={
'purchase_cert_ids': purchase_ids,
'transport_cert_ids': transport_ids,
'manual_allocations': manual,
'conditions': [],
})
if customer_rule:
# 新规则:禁止手工选证;按客户+品类整笔自动选证
from app.services.pl_quota_customer_select import select_whole_cover_certs
vehicle = (preview_vehicle or {}).get('licensePlate', plan.license_plate) or ''
on_date = max(date.today(), plan.plan_date or date.today())
purchase_ids, transport_ids, manuals = select_whole_cover_certs(
db, user, sources, vehicle_no=vehicle, on_date=on_date,
)
config = config.model_copy(update={
'purchase_cert_ids': purchase_ids,
'transport_cert_ids': transport_ids,
'manual_allocations': manuals,
'conditions': [],
})
else:
purchase_ids, transport_ids = resolve_contract_bound_certs(db, user, sources)
source_ids = {source.source_id for source in sources}
allowed = {('purchase', cert_id) for cert_id in purchase_ids} | {('transport', cert_id) for cert_id in transport_ids}
manual = [
row for row in config.manual_allocations
if row.source_id in source_ids and (row.permit_kind, row.permit_id) in allowed
]
config = config.model_copy(update={
'purchase_cert_ids': purchase_ids,
'transport_cert_ids': transport_ids,
'manual_allocations': manual,
'conditions': [],
})
elif action in ('reserve', 'resize') and explicit and customer_rule:
raise HTTPException(409, '新规则下安排仅支持自动选证,请勿手工指定证件;选不到请标记证书齐备')
keys = [('purchase', cid) for cid in config.purchase_cert_ids] + [('transport', cid) for cid in config.transport_cert_ids]
if len(set(keys)) != len(keys):
raise HTTPException(400, '证件选择重复')
if action in ('reserve', 'resize') or op.allow_supplement:
certs = {key: _get_row(db, user, *key, lock=False) for key in sorted(keys)}
if sources and not keys:
raise HTTPException(409, '请先在合同中绑定购买证和运输证后再安排运输')
if check_conditions and explicit:
msg = (
'找不到适用的购买证和运输证,请补充证件或标记证书齐备后再安排运输'
if customer_rule else
'请先在合同中绑定购买证和运输证后再安排运输'
)
raise HTTPException(409, msg)
if check_conditions and explicit and not customer_rule:
validate_conditions(config, certs, plan_fingerprint(sources))
executed = bool(plan_execution_lock_reason(db, user.tenant_no, plan.id))
request = UsageRequest(
@@ -201,8 +225,11 @@ def preview_plan(db, user, plan_id, config, preview_vehicle=None):
def save_configuration(db, user, plan_id, config):
from app.services.pl_plan_service import record_plan_change
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
config = config if isinstance(config, PlanQuotaConfiguration) else PlanQuotaConfiguration.model_validate(config)
plan = _plan(db, user, plan_id, lock=True)
if plan_uses_customer_category(plan):
raise HTTPException(409, '新规则下安排仅自动选证,不支持手工保存用证配置')
if plan.transport_status == 'arranged' or plan.archive_status == 'archived':
raise HTTPException(409, '已安排计划请通过调整运输在同一事务重算用证')
# 草稿允许缺少条件确认,但真实来源身份及选证权限必须有效。
@@ -308,15 +335,27 @@ def get_plan_quota(db, user, plan_id):
options.append(item)
usages = db.query(PlPlanCertUsage).filter_by(tenant_no=user.tenant_no, plan_id=plan_id, del_flag=0).order_by(PlPlanCertUsage.create_time.desc()).all()
version = resolve_measurement_version(db, user, plan)
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
customer_rule = plan_uses_customer_category(plan)
result = {'planId': plan.id, 'planNo': plan.plan_no, 'licensePlate': plan.license_plate,
'quotaCertWaived': int(getattr(plan, 'quota_cert_waived', 0) or 0) == 1,
'certQuotaRuleVersion': getattr(plan, 'cert_quota_rule_version', None) or 'legacy',
'measurementVersion': version,
'configuration': {**(plan.cert_configuration or {}), 'measurementVersion': version}, 'sourceOptions': options,
'stopProducts': stop_products,
'canSaveConfiguration': plan.transport_status != 'arranged' and plan.archive_status != 'archived',
# 新规则禁止手工保存用证配置;安排仅自动选证
'canSaveConfiguration': (
not customer_rule
and plan.transport_status != 'arranged'
and plan.archive_status != 'archived'
),
'usages': []}
for usage in usages:
_get_row(db, user, usage.cert_type, usage.cert_id)
cert = _get_row(db, user, usage.cert_type, usage.cert_id)
items = db.query(PlPlanCertUsageItem).filter_by(tenant_no=user.tenant_no, usage_id=usage.id, del_flag=0).order_by(PlPlanCertUsageItem.allocation_seq).all()
result['usages'].append({**_row_to_dict(usage), 'items': [_row_to_dict(row) for row in items]})
payload = {**_row_to_dict(usage), 'items': [_row_to_dict(row) for row in items]}
# 详情展示证面附件:用量行本身无附件字段,从证件档案回填
payload['attachmentUrls'] = getattr(cert, 'attachment_urls', None) or ''
payload['certNo'] = payload.get('certNo') or getattr(cert, 'cert_no', None) or usage.cert_no_snapshot
result['usages'].append(payload)
return result
+3 -1
View File
@@ -159,7 +159,9 @@ def extract_sources(db, user, plan_id, mapping=None, *, actual=False, lock=False
same_city = int(getattr(stop, 'same_city', 0) or 0) == 1
snapshot = {'source': _facts(row), 'related': [_facts(r) for r in related], 'stops': stop_facts,
'vehicle': plan.license_plate, 'trailer': plan.trailer_no, 'planType': plan.plan_type,
'sameCity': 1 if same_city else 0}
'sameCity': 1 if same_city else 0,
'customerId': getattr(stop, 'customer_id', None),
'customerName': getattr(stop, 'customer_name', None)}
if measurement_version == 'per_stop_v2':
snapshot['measurementVersion'] = measurement_version
if preview_vehicle is not None:
+107 -2
View File
@@ -4,12 +4,12 @@ from decimal import Decimal
from sqlalchemy import and_, or_, literal, func
from fastapi import HTTPException
from app.crud.crm.data_scope_helpers import apply_crm_owner_scope
from app.models.crm_models import CrmContract, CrmContractProduct
from app.models.crm_models import CrmContract, CrmContractProduct, CrmCustomer
from app.models.pl_models import PlPlan
from app.models.pl_quota_models import (PlContractCertLink as Link, PlContractCertProduct as Scope,
PlPlanCertUsage as Usage, PlPlanCertUsageItem as Item, PlCertQtyLog as Log)
from app.services.pl_data_scope import apply_pl_scope
from app.services.pl_quota_cert_service import MODELS, _get_row
from app.services.pl_quota_cert_service import MODELS, _get_row, get_cert, usage_policy
ZERO = Decimal('0')
CONTRACT_TYPES = {'sale', 'purchase', 'framework_sale', 'framework_purchase', 'framework'}
@@ -109,6 +109,39 @@ def _contracts(db, user):
user.to_user_info(), db, user.tenant_no)
def _customers(db, user):
return apply_crm_owner_scope(_active(db, user, CrmCustomer), CrmCustomer,
user.to_user_info(), db, user.tenant_no)
def _customer_cert_counts(db, user):
"""按客户汇总购买证+运输证数量;默认排除已核销/作废,与证件列表一致。"""
parts = []
for model in MODELS.values():
query = apply_pl_scope(_active(db, user, model), model, user, db, user.tenant_no)
query = query.filter(
model.customer_id.isnot(None),
model.customer_id != '',
model.status.notin_(('closed', 'void')),
)
parts.append(query.with_entities(model.customer_id.label('customer_id'), literal(1).label('one')))
combined = parts[0].union_all(*parts[1:]).subquery()
return (
db.query(combined.c.customer_id.label('customer_id'), func.count().label('cert_count'))
.group_by(combined.c.customer_id)
.subquery()
)
def _customer_summary(row):
return {
'id': row.id,
'customerId': row.id,
'customerNo': row.customer_no or '',
'customerName': row.customer_name or '',
}
def _certificates(db, user, keyword=None, cert_type=None, cert_no=None, cert_types=None):
queries = []
for kind, model in MODELS.items():
@@ -351,6 +384,78 @@ def contract_statistics(db, user, contract_id):
return {**_contract_summary(contract), 'certificates': result}
def list_customers(db, user, keyword=None, page=1, page_size=20, customer_no=None, customer_name=None,
cert_count_op=None, cert_count=None, cert_count_min=None, cert_count_max=None):
counts = _customer_cert_counts(db, user)
query = _customers(db, user).join(counts, counts.c.customer_id == CrmCustomer.id)
if keyword:
query = query.filter(or_(
CrmCustomer.customer_no.contains(keyword, autoescape=True),
CrmCustomer.customer_name.contains(keyword, autoescape=True),
CrmCustomer.short_name.contains(keyword, autoescape=True),
))
if _text(customer_no):
query = query.filter(CrmCustomer.customer_no.contains(_text(customer_no), autoescape=True))
if _text(customer_name):
query = query.filter(CrmCustomer.customer_name.contains(_text(customer_name), autoescape=True))
for clause in _number_clauses(counts.c.cert_count, cert_count_op, cert_count, cert_count_min, cert_count_max):
query = query.filter(clause)
total = query.count()
rows = query.with_entities(CrmCustomer, counts.c.cert_count).order_by(
CrmCustomer.create_time.desc(), CrmCustomer.id
).offset((page - 1) * page_size).limit(page_size).all()
return {
'list': [{**_customer_summary(customer), 'certCount': int(count or 0)} for customer, count in rows],
'total': total,
'page': page,
'pageSize': page_size,
}
def customer_statistics(db, user, customer_id):
customer = _customers(db, user).filter(CrmCustomer.id == customer_id).first()
if customer is None:
raise HTTPException(404, '客户不存在或无权访问')
certificates = []
for kind, model in MODELS.items():
query = apply_pl_scope(_active(db, user, model), model, user, db, user.tenant_no)
query = query.filter(
model.customer_id == customer_id,
model.status.notin_(('closed', 'void')),
)
for row in query.order_by(model.create_time.desc(), model.id).all():
item = get_cert(db, user, kind, row.id)
sale = item.get('sellerName') if kind == 'purchase' else item.get('shipperName')
buy = item.get('buyerName') if kind == 'purchase' else item.get('consigneeName')
certificates.append({
'id': row.id,
'certId': row.id,
'certType': kind,
'certNo': item.get('certNo') or '',
'productNameOnCert': item.get('productNameOnCert') or '',
'quantityUnit': item.get('quantityUnit') or '',
'totalQty': item.get('totalQty'),
'reservedQty': item.get('reservedQty'),
'usedQty': item.get('usedQty'),
'availableQty': item.get('availableQty'),
'status': item.get('status') or '',
'validFrom': item.get('validFrom'),
'validTo': item.get('validTo'),
'salePartyName': sale or '',
'buyPartyName': buy or '',
'validUseText': item.get('validUseText') or '',
'usagePolicy': item.get('usagePolicy') or usage_policy(row.valid_use_text),
'singleUseConsumed': bool(item.get('singleUseConsumed')),
'openingConfirmed': bool(item.get('openingConfirmed')),
'createTime': item.get('createTime'),
})
certificates.sort(
key=lambda row: (str(row.get('createTime') or ''), row.get('certNo') or ''),
reverse=True,
)
return {**_customer_summary(customer), 'certificates': certificates}
def certificate_statistics(db, user, cert_type, cert_id):
_get_row(db, user, cert_type, cert_id)
certs = _certificates(db, user, cert_type=cert_type)
+47 -22
View File
@@ -16,7 +16,7 @@ from app.models.pl_models import PlPlan
from app.models.crm_models import CrmContractProduct
from app.schemas.pl.quota_usage import UsageManual, UsageRequest
from app.services.pl_data_scope import assert_pl_row_in_scope
from app.services.pl_quota_cert_service import _audit, _get_row, usage_policy
from app.services.pl_quota_cert_service import _audit, _get_row, is_single_use_consumed, usage_policy
from app.services.pl_quota_contract_service import _contract
from app.services.pl_quota_allocation import (SourceDemand, PermitCandidate, ManualAllocation, Allocation,
allocate_plan, settle_allocations, validate_allocation_pairing, InsufficientQuota)
@@ -198,8 +198,12 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
if product is None or product.product_id!=s.product_id: raise ValueError('合同产品归属不符')
scopes={}; candidates={'purchase':[],'transport':[]}
skipped_for_vehicle=False
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
customer_rule = plan_uses_customer_category(plan)
from app.services.pl_quota_cert_service import ensure_zero_opening
for k in sorted(selected):
kind,pid=k; c=certs[k]
c = certs[k] = ensure_zero_opening(db, user, kind, pid, row=c)
if not query(m.PlCertQtyLog,cert_type=kind,cert_id=pid,event_type='opening',del_flag=0).first(): raise ValueError('证件尚未确认期初')
if c.status=='closed': raise ValueError('已核销证件不能再安排用证')
if c.status!='active' or not c.verified_time or not c.verified_by: raise ValueError('证件尚未启用复核')
@@ -211,16 +215,20 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
if kind=='transport' and (not r.vehicle_no or r.vehicle_no not in (c.vehicle_numbers_json or [])):
skipped_for_vehicle=True
continue
if policy=='once':
previous=query(m.PlPlanCertUsage,cert_type=kind,cert_id=pid,del_flag=0).all()
if c.opening_single_use_consumed or any(u.plan_id!=plan.id and (u.usage_status in ('reserved','in_transit','settled','exception') or (u.confirmation_snapshot or {}).get('executed')) for u in previous):
raise ValueError('一次有效证件已使用或已占用')
if policy=='once' and is_single_use_consumed(db, user, kind, c, exclude_plan_id=plan.id):
raise ValueError('一次有效证件已使用或已占用')
eligible=set()
for sid,s in sources.items():
link=query(m.PlContractCertLink,cert_type=kind,cert_id=pid,contract_id=s.contract_id,del_flag=0,link_status='active').first()
scope=None if link is None else query(m.PlContractCertProduct,contract_cert_link_id=link.id,contract_product_id=s.contract_product_id,del_flag=0,scope_status='active').first()
if scope:
eligible.add(sid); scopes[(k,sid)]=(link,scope)
if customer_rule:
# 新规则已由客户+品类整笔选证;账本不再依赖合同绑证 scope。
eligible=set(sources)
for sid in eligible:
scopes[(k,sid)]=(None,None)
else:
for sid,s in sources.items():
link=query(m.PlContractCertLink,cert_type=kind,cert_id=pid,contract_id=s.contract_id,del_flag=0,link_status='active').first()
scope=None if link is None else query(m.PlContractCertProduct,contract_cert_link_id=link.id,contract_product_id=s.contract_product_id,del_flag=0,scope_status='active').first()
if scope:
eligible.add(sid); scopes[(k,sid)]=(link,scope)
refs=frozenset(x.purchase_cert_id for x in query(m.PlTransportCertPurchaseRef,transport_cert_id=pid,del_flag=0).all() if x.purchase_cert_id) if kind=='transport' else frozenset()
own=usages[k].reserved_qty if k in usages else ZERO
candidates[kind].append(PermitCandidate(pid,c.total_qty-c.used_qty-c.reserved_qty+own,c.quantity_unit,c.valid_to,frozenset(eligible),refs))
@@ -279,8 +287,10 @@ def _reserve(db,user,r,plan,certs,usages,items,selected,preview=False,lock_previ
'sourceId':a.source_id,'certId':a.permit_id,'sourceQuantity':_canonical(a.source_quantity),
'permitQuantity':_canonical(a.permit_quantity),'sourceUnit':a.source_unit,
'permitUnit':a.permit_unit,'allocationSeq':a.allocation_seq,
'contractCertLinkId':link.id,'contractCertProductId':scope.id,
'scopeRevision':link.scope_revision,'certRevision':certs[k].revision,
'contractCertLinkId':None if link is None else link.id,
'contractCertProductId':None if scope is None else scope.id,
'scopeRevision':0 if link is None else link.scope_revision,
'certRevision':certs[k].revision,
'ownReservedQty':_canonical(usages[k].reserved_qty if k in usages else ZERO),
})
return result
@@ -309,7 +319,27 @@ def _reserve(db,user,r,plan,certs,usages,items,selected,preview=False,lock_previ
item=previous.get(a.source_id)
if item is None:
item=m.PlPlanCertUsageItem(**_audit(user),usage_id=u.id); db.add(item); items[k].append(item)
values=dict(contract_cert_link_id=link.id,contract_cert_product_id=scope.id,contract_scope_revision=link.scope_revision,contract_id=s.contract_id,contract_product_id=s.contract_product_id,plan_product_id=s.plan_product_id,source_stop_type=s.source_stop_type,source_stop_id=s.source_stop_id,source_stop_product_id=s.source_stop_product_id,related_stop_products=s.related_stop_products,allocation_seq=a.allocation_seq,source_unit=s.unit,planned_source_qty=s.quantity,allocated_source_qty=a.source_quantity,planned_cert_qty=a.permit_quantity,reserved_qty=a.permit_quantity,used_qty=ZERO,item_status='reserved',source_fingerprint=s.fingerprint,source_snapshot={**s.snapshot, 'measurementVersion': s.snapshot.get('measurementVersion', 'legacy_single_side'), 'productId': s.product_id},del_flag=0,reserve_input_mode=_reserve_input_mode(s.unit, c.quantity_unit),reserve_conversion_note=notes.get((k[0],k[1],a.source_id)),reserve_confirmed_by=user.id,reserve_confirmed_time=datetime.now(),update_by=user.id)
values=dict(
contract_cert_link_id=None if link is None else link.id,
contract_cert_product_id=None if scope is None else scope.id,
contract_scope_revision=0 if link is None else link.scope_revision,
contract_id=s.contract_id,contract_product_id=s.contract_product_id,plan_product_id=s.plan_product_id,
source_stop_type=s.source_stop_type,source_stop_id=s.source_stop_id,source_stop_product_id=s.source_stop_product_id,
related_stop_products=s.related_stop_products,allocation_seq=a.allocation_seq,source_unit=s.unit,
planned_source_qty=s.quantity,allocated_source_qty=a.source_quantity,planned_cert_qty=a.permit_quantity,
reserved_qty=a.permit_quantity,used_qty=ZERO,item_status='reserved',source_fingerprint=s.fingerprint,
source_snapshot={
**s.snapshot,
'measurementVersion': s.snapshot.get('measurementVersion', 'legacy_single_side'),
'productId': s.product_id,
'quotaRuleVersion': getattr(plan, 'cert_quota_rule_version', None),
'customerId': (s.snapshot or {}).get('customerId'),
'customerName': (s.snapshot or {}).get('customerName'),
},
del_flag=0,reserve_input_mode=_reserve_input_mode(s.unit, c.quantity_unit),
reserve_conversion_note=notes.get((k[0],k[1],a.source_id)),
reserve_confirmed_by=user.id,reserve_confirmed_time=datetime.now(),update_by=user.id,
)
for name,value in values.items(): setattr(item,name,value)
@@ -340,6 +370,8 @@ ARCHIVE_QUOTA_BLOCK = '合同绑定的购买证和运输证剩余额度不够覆
def _settlement_cover_cert(db, user, r, plan, kind, cert):
"""合同上另有证件时,不合格的证跳过,不因此挡住其余还能扣的证。"""
from app.services.pl_quota_cert_service import ensure_zero_opening
cert = ensure_zero_opening(db, user, kind, cert.id, row=cert)
if not _query(db, user, m.PlCertQtyLog, cert_type=kind, cert_id=cert.id, event_type='opening', del_flag=0).first():
return False
if cert.status != 'active' or not cert.verified_time or not cert.verified_by:
@@ -351,15 +383,8 @@ def _settlement_cover_cert(db, user, r, plan, kind, cert):
return False
if kind == 'transport' and (not r.vehicle_no or r.vehicle_no not in (cert.vehicle_numbers_json or [])):
return False
if policy == 'once':
previous = _query(db, user, m.PlPlanCertUsage, cert_type=kind, cert_id=cert.id, del_flag=0).all()
if cert.opening_single_use_consumed or any(
u.plan_id != plan.id and (
u.usage_status in ('reserved', 'in_transit', 'settled', 'exception')
or (u.confirmation_snapshot or {}).get('executed')
) for u in previous
):
return False
if policy == 'once' and is_single_use_consumed(db, user, kind, cert, exclude_plan_id=plan.id):
return False
return True
+104 -1
View File
@@ -290,13 +290,19 @@ def mark_ready(db, user, plan_id):
plan.quota_cert_waived_by = user.id
plan.quota_cert_waived_by_name = name[:100] if name else None
plan.quota_cert_waived_time = datetime.now()
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
if plan_uses_customer_category(plan):
plan.quota_cert_pending_fulfill = 1
change_content = "标记证书齐备:可安排运输;归档前须在证书齐备中完善并关闭"
else:
change_content = "标记证书齐备:安排和归档不再校验、不扣购买证与运输证额度"
plan.update_by = user.id
record_plan_change(
db,
tenant_no=user.tenant_no,
plan_id=plan.id,
change_type="quota_cert_waived",
change_content="标记证书齐备:安排和归档不再校验、不扣购买证与运输证额度",
change_content=change_content,
user_id=user.id,
user_name=plan.quota_cert_waived_by_name,
dept_id=user.dept_id or "",
@@ -304,3 +310,100 @@ def mark_ready(db, user, plan_id):
db.flush()
labels = _product_names(db, user, [plan.id])
return _row(plan, labels.get(plan.id, ""))
def list_pending_fulfill_plans(db, user, page=1, page_size=20, keyword=""):
"""新规则下已齐备、待完善挂证的计划。"""
from app.services.pl_cert_quota_rule_config_service import VERSION_CUSTOMER_CATEGORY
query = db.query(PlPlan).filter(
PlPlan.tenant_no == user.tenant_no,
PlPlan.del_flag == 0,
PlPlan.follow_status != "cancelled",
PlPlan.archive_status != "cancelled",
PlPlan.cert_quota_rule_version == VERSION_CUSTOMER_CATEGORY,
PlPlan.quota_cert_pending_fulfill == 1,
PlPlan.quota_cert_waived == 1,
)
query = apply_pl_scope(query, PlPlan, user, db, user.tenant_no)
if keyword:
query = query.filter(or_(
PlPlan.plan_no.contains(keyword, autoescape=True),
PlPlan.supplier_name.contains(keyword, autoescape=True),
PlPlan.customer_name.contains(keyword, autoescape=True),
))
total = query.count()
rows = (
query.order_by(PlPlan.plan_date.is_(None), PlPlan.plan_date.asc(), PlPlan.id.asc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
labels = _product_names(db, user, [row.id for row in rows])
return {"total": total, "list": [_row(plan, labels.get(plan.id, "")) for plan in rows]}
def fulfill_plan_certs(db, user, plan_id, *, operation_id=None):
"""完善并关闭:按安排同款自动选证并 reserve,成功后出队。"""
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
from app.services.pl_plan_service import record_plan_change
from app.services.pl_quota_business import apply_plan_quota
from app.services.pl_quota_planning import _plan
from types import SimpleNamespace
plan = _plan(db, user, plan_id, lock=True)
if not plan_uses_customer_category(plan):
raise HTTPException(409, '仅新规则计划支持完善并关闭')
if int(getattr(plan, 'quota_cert_pending_fulfill', 0) or 0) != 1:
raise HTTPException(409, '该计划不在证书齐备待完善列表中')
if plan.follow_status == 'cancelled' or plan.archive_status in ('cancelled', 'archived'):
raise HTTPException(409, '已作废或已归档的计划不能完善证件')
# 暂时清齐备跳过,以便真正 reserve;失败则恢复 pending
plan.quota_cert_waived = 0
payload = SimpleNamespace(
operation_id=operation_id or str(__import__('uuid').uuid4()),
quota_configuration=None,
quota_allow_supplement=False,
quota_manual_actuals=[],
quota_reason='fulfill_quota_certs',
quota_evidence_urls=None,
quota_source_note='证书齐备完善并关闭',
)
try:
action = 'resize' if plan.transport_status == 'arranged' else 'reserve'
apply_plan_quota(db, user, plan, action, payload)
except Exception:
plan.quota_cert_waived = 1
raise
plan.quota_cert_pending_fulfill = 0
plan.quota_cert_waived = 0
plan.update_by = user.id
record_plan_change(
db,
tenant_no=user.tenant_no,
plan_id=plan.id,
change_type='quota_cert_fulfilled',
change_content='证书齐备完善并关闭:已挂载购买证/运输证并完成额度预占',
user_id=user.id,
user_name=((getattr(user, 'name', None) or '')[:100] or None),
dept_id=user.dept_id or '',
)
db.flush()
labels = _product_names(db, user, [plan.id])
return _row(plan, labels.get(plan.id, ''))
def fulfill_plans_batch(db, user, plan_ids):
results = []
for plan_id in plan_ids:
try:
with db.begin_nested():
row = fulfill_plan_certs(db, user, plan_id)
results.append({'planId': plan_id, 'ok': True, 'plan': row})
except HTTPException as exc:
results.append({'planId': plan_id, 'ok': False, 'message': str(exc.detail)})
except Exception as exc:
results.append({'planId': plan_id, 'ok': False, 'message': str(exc)})
return {'results': results}
@@ -0,0 +1,302 @@
#!/usr/bin/env python3
"""回填购买证/运输证的客户与证用品类(客户+品类额度改造)。
对已有证件:
1. 按证面品名 product_name_on_cert 去重创建 pl_cert_category(已存在同名则复用)
2. 购买证 category_id ← 与证面品名同名的品类
3. 购买证 customer_id ← buyer_name 精确匹配 crm_customer.customer_name
4. 运输证 customer_id ← 优先非内部客户的收货方 consignee_name,否则 shipper_name
5. 可选:品类关联系统产品(仅 product_name 与品类名完全一致)
不自动:
- 模糊归并「硫酸」与「98% 工业硫酸」为同一品类(需人工合并)
- 模糊挂接相近产品名(默认只精确匹配;可用 --link-exact-products)
用法:
cd backend && source venv/bin/activate
PYTHONPATH=. python scripts/backfill_pl_cert_customer_category.py --dry-run
PYTHONPATH=. python scripts/backfill_pl_cert_customer_category.py --apply
PYTHONPATH=. python scripts/backfill_pl_cert_customer_category.py --apply --link-exact-products
环境:读取 backend/.env 的 DATABASE_URL(可用 ENV_FILE 覆盖路径)。
"""
from __future__ import annotations
import argparse
import os
import sys
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional, Tuple
from urllib.parse import unquote, urlparse
from uuid import uuid4
BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT))
INTERNAL_HINTS = ('山峡', '云栋')
def load_database_url() -> str:
env_file = os.environ.get('ENV_FILE') or str(BACKEND_ROOT / '.env')
text = Path(env_file).read_text(encoding='utf-8')
for line in text.splitlines():
line = line.strip()
if not line or line.startswith('#'):
continue
if line.startswith('DATABASE_URL='):
return line.split('=', 1)[1].strip().strip('"').strip("'")
raise SystemExit(f'DATABASE_URL not found in {env_file}')
def connect():
import pymysql
raw = load_database_url().replace('mysql+pymysql://', 'mysql://', 1)
p = urlparse(raw)
return pymysql.connect(
host=p.hostname or '127.0.0.1',
port=p.port or 3306,
user=unquote(p.username or ''),
password=unquote(p.password or ''),
database=(p.path or '/').lstrip('/'),
charset='utf8mb4',
autocommit=False,
)
def is_internal_name(name: Optional[str]) -> bool:
text = (name or '').strip()
return any(h in text for h in INTERNAL_HINTS)
def normalize_name(value: Optional[str]) -> str:
return (value or '').strip()
def main() -> int:
parser = argparse.ArgumentParser(description='Backfill cert customer_id / category_id')
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument('--dry-run', action='store_true', help='只打印计划,不写库')
mode.add_argument('--apply', action='store_true', help='写入数据库')
parser.add_argument('--tenant', default='shanxia2.0', help='租户编号')
parser.add_argument(
'--link-exact-products',
action='store_true',
help='把品类名与 CRM 产品名完全一致的产品写入 pl_cert_category_product',
)
args = parser.parse_args()
apply = bool(args.apply)
tenant = args.tenant
now = datetime.now().replace(microsecond=0)
conn = connect()
cur = conn.cursor()
try:
# --- load customers ---
cur.execute(
"SELECT id, customer_name FROM crm_customer WHERE tenant_no=%s AND del_flag=0",
(tenant,),
)
customer_by_name: Dict[str, str] = {}
dup_names = set()
for cid, cname in cur.fetchall():
key = normalize_name(cname)
if not key:
continue
if key in customer_by_name and customer_by_name[key] != cid:
dup_names.add(key)
else:
customer_by_name[key] = cid
for key in dup_names:
customer_by_name.pop(key, None)
# --- existing categories ---
cur.execute(
"SELECT id, category_name FROM pl_cert_category WHERE tenant_no=%s AND del_flag=0",
(tenant,),
)
category_by_name = {normalize_name(n): i for i, n in cur.fetchall()}
# --- distinct cert face names ---
cur.execute(
"""
SELECT DISTINCT product_name_on_cert FROM (
SELECT product_name_on_cert FROM pl_purchase_cert
WHERE tenant_no=%s AND del_flag=0
UNION
SELECT product_name_on_cert FROM pl_transport_cert
WHERE tenant_no=%s AND del_flag=0
) t WHERE product_name_on_cert IS NOT NULL AND TRIM(product_name_on_cert) <> ''
""",
(tenant, tenant),
)
face_names = [normalize_name(r[0]) for r in cur.fetchall() if normalize_name(r[0])]
print(f'tenant={tenant} apply={apply} face_names={face_names}')
print(f'crm_customers={len(customer_by_name)} ambiguous_names={sorted(dup_names)[:10]}')
created_categories = []
for name in face_names:
if name in category_by_name:
continue
cid = str(uuid4())
created_categories.append((cid, name))
category_by_name[name] = cid
print(f' [category+] {name} -> {cid}')
if apply:
cur.execute(
"""
INSERT INTO pl_cert_category
(id, category_name, status, tenant_no, create_dept_id, create_time, update_time, del_flag)
VALUES (%s,%s,'1',%s,'',%s,%s,0)
""",
(cid, name, tenant, now, now),
)
# --- purchase backfill ---
cur.execute(
"""
SELECT id, cert_no, product_name_on_cert, buyer_name, customer_id, category_id, status
FROM pl_purchase_cert WHERE tenant_no=%s AND del_flag=0
""",
(tenant,),
)
purchase_rows = cur.fetchall()
purchase_ok = purchase_miss_customer = purchase_miss_category = 0
for pid, cert_no, face, buyer, cust_id, cat_id, status in purchase_rows:
face_n = normalize_name(face)
buyer_n = normalize_name(buyer)
new_cat = category_by_name.get(face_n)
new_cust = customer_by_name.get(buyer_n)
updates = {}
if not cat_id and new_cat:
updates['category_id'] = new_cat
if not cust_id and new_cust:
updates['customer_id'] = new_cust
if updates:
print(
f' [purchase] {cert_no} status={status} face={face_n!r} buyer={buyer_n!r} -> {updates}'
)
if apply:
sets = ', '.join(f'{k}=%s' for k in updates)
cur.execute(
f"UPDATE pl_purchase_cert SET {sets}, update_time=%s WHERE id=%s",
(*updates.values(), now, pid),
)
purchase_ok += 1
if not (cust_id or updates.get('customer_id')):
purchase_miss_customer += 1
print(f' [purchase!] {cert_no} 无法匹配客户 buyer={buyer_n!r}')
if not (cat_id or updates.get('category_id')):
purchase_miss_category += 1
# --- transport backfill ---
cur.execute(
"""
SELECT id, cert_no, product_name_on_cert, shipper_name, consignee_name, customer_id, status
FROM pl_transport_cert WHERE tenant_no=%s AND del_flag=0
""",
(tenant,),
)
transport_ok = transport_miss = 0
for tid, cert_no, face, shipper, consignee, cust_id, status in cur.fetchall():
candidates = []
consignee_n = normalize_name(consignee)
shipper_n = normalize_name(shipper)
if consignee_n and not is_internal_name(consignee_n):
candidates.append(('consignee', consignee_n))
if shipper_n and not is_internal_name(shipper_n):
candidates.append(('shipper', shipper_n))
if not candidates:
if consignee_n:
candidates.append(('consignee', consignee_n))
elif shipper_n:
candidates.append(('shipper', shipper_n))
new_cust = None
picked = None
for role, name in candidates:
hit = customer_by_name.get(name)
if hit:
new_cust = hit
picked = (role, name)
break
if not cust_id and new_cust:
print(
f' [transport] {cert_no} status={status} via {picked} -> customer_id={new_cust}'
)
if apply:
cur.execute(
"UPDATE pl_transport_cert SET customer_id=%s, update_time=%s WHERE id=%s",
(new_cust, now, tid),
)
transport_ok += 1
elif not cust_id:
transport_miss += 1
print(
f' [transport!] {cert_no} 无法匹配客户 consignee={consignee_n!r} shipper={shipper_n!r}'
)
# --- optional exact product links ---
product_links = 0
if args.link_exact_products:
cur.execute(
"SELECT id, product_name FROM crm_product WHERE tenant_no=%s AND del_flag=0",
(tenant,),
)
products_by_name: Dict[str, list] = {}
for prid, pname in cur.fetchall():
key = normalize_name(pname)
products_by_name.setdefault(key, []).append(prid)
for cname, category_id in category_by_name.items():
ids = products_by_name.get(cname) or []
if len(ids) != 1:
if ids:
print(f' [product?] 品类 {cname!r} 精确匹配到 {len(ids)} 个产品,跳过')
continue
product_id = ids[0]
cur.execute(
"""
SELECT id FROM pl_cert_category_product
WHERE tenant_no=%s AND category_id=%s AND product_id=%s AND del_flag=0
""",
(tenant, category_id, product_id),
)
if cur.fetchone():
continue
print(f' [product+] 品类 {cname!r} <-> product {product_id}')
product_links += 1
if apply:
cur.execute(
"""
INSERT INTO pl_cert_category_product
(id, category_id, product_id, scope_status, tenant_no, create_dept_id,
create_time, update_time, del_flag)
VALUES (%s,%s,%s,'active',%s,'',%s,%s,0)
""",
(str(uuid4()), category_id, product_id, tenant, now, now),
)
print('--- summary ---')
print(f'categories_created={len(created_categories)}')
print(f'purchase_updated={purchase_ok} miss_customer={purchase_miss_customer} miss_category={purchase_miss_category}')
print(f'transport_updated={transport_ok} miss_customer={transport_miss}')
print(f'product_links={product_links}')
if apply:
conn.commit()
print('COMMITTED')
else:
conn.rollback()
print('DRY-RUN (rolled back)')
return 0
except Exception:
conn.rollback()
raise
finally:
cur.close()
conn.close()
if __name__ == '__main__':
raise SystemExit(main())
@@ -0,0 +1,356 @@
#!/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')
ids['category_product'] = uid('cat-prod')
# 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']]),
('pl_cert_category_product', 'id', [ids['category_product']]),
('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'])
# 品类 ↔ 种子产品(选证按产品反查品类;缺关联则安排必败)
cur.execute(
"""
INSERT INTO pl_cert_category_product
(id, category_id, product_id, scope_status,
tenant_no, create_dept_id, create_by, create_time, update_time, del_flag)
VALUES (%s,%s,%s,'active',%s,'',%s,%s,%s,0)
""",
(ids['category_product'], category_id, PRODUCT_ID, TENANT, OWNER_USER_ID, now, now),
)
print('+ category_product', PRODUCT_ID, '->', category_id)
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,754 @@
"""客户+品类证件额度:切点、列表默认过滤、齐备语义、整笔选证。"""
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_settle_pending_fulfill_without_usage_rows_keeps_pending_message(monkeypatch):
"""证书齐备待完善且无用量行时,归档结转错误不能回落成通用扣减文案。"""
from contextlib import nullcontext
from app.services import pl_quota_business as biz
from fastapi import HTTPException
plan = SimpleNamespace(
id='p1',
quota_cert_waived=1,
quota_cert_pending_fulfill=1,
cert_quota_rule_version='customer_category',
cert_configuration={},
archive_status='none',
)
user = SimpleNamespace(id='u', tenant_no='t')
pending = '该计划仍处于证书齐备待完善,请先在证书齐备中完善并关闭后再归档'
class _Query:
def filter_by(self, **kwargs):
return self
def all(self):
return []
db = MagicMock()
db.query.return_value = _Query()
db.flush = MagicMock()
db.begin_nested = MagicMock(return_value=nullcontext())
def boom(*_a, **_k):
raise HTTPException(409, pending)
monkeypatch.setattr(biz, 'apply_plan_quota', boom)
assert biz.settle_plan_quota(db, user, plan, None) is False
message = biz.read_quota_settlement_error(db, 't', 'p1', plan=plan)
assert '待完善' in message
assert '证件没有扣减' not in message
def test_list_certs_default_excludes_closed_void(monkeypatch):
from app.services import pl_quota_cert_service as svc
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')
def test_save_configuration_blocked_for_customer_category(monkeypatch):
from app.services import pl_quota_planning as planning
from app.schemas.pl.quota_planning import PlanQuotaConfiguration
plan = SimpleNamespace(
id='p1',
cert_quota_rule_version='customer_category',
transport_status='pending_arrange',
archive_status='none',
cert_configuration={},
)
monkeypatch.setattr(planning, '_plan', lambda *a, **k: plan)
with pytest.raises(HTTPException) as exc:
planning.save_configuration(
MagicMock(),
SimpleNamespace(id='u', tenant_no='t', dept_id=''),
'p1',
PlanQuotaConfiguration(),
)
assert exc.value.status_code == 409
assert '自动选证' in str(exc.value.detail)
def test_legacy_waive_still_skips_settle(monkeypatch):
from app.services import pl_quota_business as biz
plan = SimpleNamespace(
id='p1',
quota_cert_waived=1,
quota_cert_pending_fulfill=0,
cert_quota_rule_version='legacy',
cert_configuration={},
)
monkeypatch.setattr(
'app.services.pl_cert_quota_rule_config_service.plan_uses_customer_category',
lambda p: False,
)
monkeypatch.setattr('app.services.pl_quota_waiver.assert_waived_sources', lambda *a, **k: None)
result = biz.apply_plan_quota(MagicMock(), SimpleNamespace(id='u'), plan, 'settle', None)
assert result.get('waived') is True
def test_null_valid_to_sorted_after_dated_certs():
"""无到期日的证不能排在有到期日之前(MySQL NULL ASC 默认会错序)。"""
from app.services import pl_quota_customer_select as sel
from app.schemas.pl.quota_usage import UsageSource
from app.models.pl_quota_models import PlPurchaseCert, PlCertQtyLog
source = UsageSource(
source_stop_type='delivery',
source_stop_id='s1',
source_stop_product_id='sp1',
plan_product_id='pp1',
contract_id='c1',
contract_product_id='cp1',
product_id='prod1',
quantity=Decimal('10'),
unit='吨',
fingerprint='z' * 64,
snapshot={'customerId': 'cust1'},
requires_transport=False,
)
unbounded = SimpleNamespace(
id='buy-null', customer_id='cust1', category_id='cat1', status='active',
verified_by='u', verified_time=datetime.now(), valid_from=None, valid_to=None,
total_qty=Decimal('100'), used_qty=Decimal('0'), reserved_qty=Decimal('0'),
quantity_unit='吨', opening_single_use_consumed=0,
)
dated = SimpleNamespace(
id='buy-dated', customer_id='cust1', category_id='cat1', status='active',
verified_by='u', verified_time=datetime.now(), valid_from=None, valid_to=date(2026, 12, 1),
total_qty=Decimal('100'), used_qty=Decimal('0'), reserved_qty=Decimal('0'),
quantity_unit='吨', opening_single_use_consumed=0,
)
class PurchaseQuery:
def __init__(self):
self._order = None
def filter(self, *a, **k):
return self
def order_by(self, *exprs):
self._order = exprs
return self
def all(self):
# 应先按 valid_to IS NULL 升序(非空在前),再按 valid_to ASC
assert self._order is not None and len(self._order) >= 2
return [dated, unbounded]
class LogQuery:
def filter_by(self, **k):
return self
def first(self):
return SimpleNamespace(id='opening')
db = MagicMock()
def q_side(model):
if model is PlPurchaseCert:
return PurchaseQuery()
if model is PlCertQtyLog:
return LogQuery()
return MagicMock()
db.query.side_effect = q_side
orig = sel.category_ids_for_product
sel.category_ids_for_product = lambda db, tenant, pid: {'cat1'}
try:
purchase_ids, _, manuals = sel.select_whole_cover_certs(
db, SimpleNamespace(tenant_no='t'), [source], vehicle_no='皖A1', on_date=date(2026, 11, 14),
)
finally:
sel.category_ids_for_product = orig
assert purchase_ids == ['buy-dated']
assert manuals[0].permit_id == 'buy-dated'
def test_same_city_source_skips_transport_requirement():
"""同市来源 requires_transport=False,整笔选证可不带运输证。"""
from app.services import pl_quota_customer_select as sel
from app.schemas.pl.quota_usage import UsageSource
from app.models.pl_quota_models import PlPurchaseCert, PlCertQtyLog
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('20'),
unit='吨',
fingerprint='y' * 64,
snapshot={'customerId': 'cust1', 'sameCity': 1},
requires_transport=False,
)
purchase = SimpleNamespace(
id='buy1', customer_id='cust1', category_id='cat1', status='active',
verified_by='u', verified_time=datetime.now(), valid_from=None, valid_to=date(2027, 1, 1),
total_qty=Decimal('100'), used_qty=Decimal('0'), reserved_qty=Decimal('0'),
quantity_unit='吨', opening_single_use_consumed=0,
)
class PurchaseQuery:
def filter(self, *a, **k):
return self
def order_by(self, *a, **k):
return self
def all(self):
return [purchase]
class LogQuery:
def filter_by(self, **k):
return self
def first(self):
return SimpleNamespace(id='opening')
db = MagicMock()
def q_side(model):
if model is PlPurchaseCert:
return PurchaseQuery()
if model is PlCertQtyLog:
return LogQuery()
return MagicMock()
db.query.side_effect = q_side
orig = sel.category_ids_for_product
sel.category_ids_for_product = lambda db, tenant, pid: {'cat1'}
try:
purchase_ids, transport_ids, manuals = sel.select_whole_cover_certs(
db, SimpleNamespace(tenant_no='t'), [source], vehicle_no='皖A1', on_date=date(2026, 11, 14),
)
finally:
sel.category_ids_for_product = orig
assert purchase_ids == ['buy1']
assert transport_ids == []
assert manuals[0].permit_id == 'buy1'
def test_candidate_context_customer_rule_skips_contract_scope(monkeypatch):
"""新规则账本候选不以合同绑证 scope 判定适用,否则预占会误报人工分配不适用。"""
from datetime import date
from decimal import Decimal
from types import SimpleNamespace
from app.services import pl_quota_usage_service as usage
from app.schemas.pl.quota_usage import UsageSource
plan = SimpleNamespace(id='plan', cert_quota_rule_version='customer_category', plan_no='P1')
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='dun',
fingerprint='f' * 64,
snapshot={'customerId': 'cust1'},
requires_transport=True,
)
request = SimpleNamespace(
sources=[source],
on_date=date(2026, 11, 15),
vehicle_no='皖A9TEST1',
manual_allocations=[],
)
cert = SimpleNamespace(
id='buy1', status='active', verified_time=date(2026, 11, 1), verified_by='u',
valid_from=date(2026, 11, 1), valid_to=date(2027, 1, 1), valid_use_text='多次有效',
total_qty=Decimal('100'), used_qty=Decimal('0'), reserved_qty=Decimal('0'),
quantity_unit='dun', vehicle_numbers_json=['皖A9TEST1'], opening_single_use_consumed=0,
revision=1, cert_no='G1',
)
class Q:
def __init__(self, row=None, rows=None):
self._row = row
self._rows = rows or ([] if row is None else [row])
def first(self):
return self._row
def all(self):
return self._rows
def fake_query(db, user, model, lock=False, **filters):
name = getattr(model, '__name__', '')
if name == 'PlCertQtyLog':
return Q(SimpleNamespace(id='opening'))
if name == 'PlTransportCertPurchaseRef':
return Q(rows=[])
if name in ('PlContractCertLink', 'PlContractCertProduct'):
raise AssertionError('customer_category 不应查询合同绑证 scope')
return Q()
monkeypatch.setattr(usage, '_query', fake_query)
monkeypatch.setattr(usage, '_contract', lambda *a, **k: SimpleNamespace(status='executing', contract_type='sale'))
monkeypatch.setattr(
'app.services.pl_cert_quota_rule_config_service.plan_uses_customer_category',
lambda p: True,
)
# product ownership check uses _query for CrmContractProduct
def fake_query2(db, user, model, lock=False, **filters):
name = getattr(model, '__name__', '')
if name == 'CrmContractProduct':
return Q(SimpleNamespace(product_id='prod1'))
return fake_query(db, user, model, lock=lock, **filters)
monkeypatch.setattr(usage, '_query', fake_query2)
sources, scopes, candidates = usage._candidate_context(
MagicMock(), SimpleNamespace(tenant_no='t', id='u'), request, plan,
{('purchase', 'buy1'): cert}, {}, {}, {('purchase', 'buy1')},
)
assert 'delivery:sp1' in sources
assert scopes[(('purchase', 'buy1'), 'delivery:sp1')] == (None, None)
assert candidates['purchase'][0].product_ids == frozenset({'delivery:sp1'})
def test_list_certs_filters_by_customer_id(monkeypatch):
from app.services import pl_quota_cert_service as svc
class Col:
def __init__(self, name):
self.name = name
class CustomerCol:
def __eq__(self, other):
return ('CUSTOMER_FILTER', other)
class FakeModel:
__tablename__ = 'pl_purchase_cert'
tenant_no = SimpleNamespace()
del_flag = 0
customer_id = CustomerCol()
status = SimpleNamespace(notin_=lambda *a, **k: 'STATUS_FILTER')
create_time = SimpleNamespace(desc=lambda: 'DESC')
cert_no = seller_name = buyer_name = SimpleNamespace(contains=lambda *a, **k: True)
__table__ = SimpleNamespace(columns=[Col('id'), Col('status')])
class Query:
def __init__(self):
self.filters = []
def filter(self, *a, **k):
self.filters.extend(a)
return self
def filter_by(self, **k):
return self
def order_by(self, *a):
return self
def all(self):
return []
q = Query()
db = MagicMock()
db.query.return_value = q
monkeypatch.setattr(svc, '_model', lambda t: FakeModel)
monkeypatch.setattr(svc, 'apply_pl_scope', lambda query, *a, **k: query)
monkeypatch.setattr(svc, 'get_cert', lambda *a, **k: {})
svc.list_certs(db, SimpleNamespace(tenant_no='t'), 'purchase', customer_id='cust-1')
assert ('CUSTOMER_FILTER', 'cust-1') in q.filters
assert any(f == 'STATUS_FILTER' for f in q.filters)
def test_is_single_use_consumed_blocks_other_plan_usage():
from app.services.pl_quota_cert_service import is_single_use_consumed
from app.models.pl_quota_models import PlPlanCertUsage
cert = SimpleNamespace(id='buy1', valid_use_text='一次有效', opening_single_use_consumed=0)
usage_row = SimpleNamespace(
plan_id='other-plan',
usage_status='reserved',
confirmation_snapshot=None,
)
class UsageQuery:
def filter_by(self, **k):
return self
def all(self):
return [usage_row]
db = MagicMock()
def q_side(model):
assert model is PlPlanCertUsage
return UsageQuery()
db.query.side_effect = q_side
user = SimpleNamespace(tenant_no='t')
assert is_single_use_consumed(db, user, 'purchase', cert) is True
assert is_single_use_consumed(db, user, 'purchase', cert, exclude_plan_id='other-plan') is False
multi = SimpleNamespace(id='buy2', valid_use_text='多次有效', opening_single_use_consumed=1)
assert is_single_use_consumed(db, user, 'purchase', multi) is False
def test_whole_cover_skips_once_used_purchase_with_remaining(monkeypatch):
"""一次有效证已被他计划占用时,即使余量足够也不能再选。"""
from app.services import pl_quota_customer_select as sel
from app.schemas.pl.quota_usage import UsageSource
source = UsageSource(
source_stop_type='delivery',
source_stop_id='s1',
source_stop_product_id='sp1',
plan_product_id='pp1',
contract_id='c1',
contract_product_id='cp1',
product_id='prod1',
quantity=Decimal('10'),
unit='吨',
fingerprint='y' * 64,
snapshot={'customerId': 'cust1'},
requires_transport=False,
)
used_once = SimpleNamespace(
id='buy-once', customer_id='cust1', category_id='cat1', status='active',
verified_by='u', verified_time=datetime.now(), valid_from=None, valid_to=date(2026, 12, 1),
total_qty=Decimal('100'), used_qty=Decimal('10'), reserved_qty=Decimal('0'),
quantity_unit='吨', opening_single_use_consumed=0, valid_use_text='一次有效',
)
fresh = SimpleNamespace(
id='buy-fresh', customer_id='cust1', category_id='cat1', status='active',
verified_by='u', verified_time=datetime.now(), valid_from=None, valid_to=date(2027, 6, 1),
total_qty=Decimal('100'), used_qty=Decimal('0'), reserved_qty=Decimal('0'),
quantity_unit='吨', opening_single_use_consumed=0, valid_use_text='多次有效',
)
class PurchaseQuery:
def filter(self, *a, **k):
return self
def order_by(self, *a, **k):
return self
def all(self):
return [used_once, fresh]
class LogQuery:
def filter_by(self, **k):
return self
def first(self):
return SimpleNamespace(id='opening')
from app.models.pl_quota_models import PlPurchaseCert, PlCertQtyLog
db = MagicMock()
def q_side(model):
if model is PlPurchaseCert:
return PurchaseQuery()
if model is PlCertQtyLog:
return LogQuery()
return MagicMock()
db.query.side_effect = q_side
user = SimpleNamespace(tenant_no='t')
monkeypatch.setattr(sel, 'category_ids_for_product', lambda db, tenant, pid: {'cat1'})
monkeypatch.setattr(
sel,
'is_single_use_consumed',
lambda db, user, kind, cert, **kw: cert.id == 'buy-once',
)
purchase_ids, transport_ids, manuals = sel.select_whole_cover_certs(
db, user, [source], vehicle_no='皖A12345', on_date=date(2026, 11, 14),
)
assert purchase_ids == ['buy-fresh']
assert transport_ids == []
assert manuals[0].permit_id == 'buy-fresh'
def test_list_customers_requires_customer_cert_counts(monkeypatch):
from app.services import pl_quota_statistics as stats
class Q:
def __init__(self, total=1, rows=None):
self._total = total
self._rows = rows or []
def join(self, *a, **k):
return self
def filter(self, *a, **k):
return self
def count(self):
return self._total
def with_entities(self, *a, **k):
return self
def order_by(self, *a, **k):
return self
def offset(self, *a, **k):
return self
def limit(self, *a, **k):
return self
def all(self):
return self._rows
customer = SimpleNamespace(id='cust1', customer_no='C1', customer_name='甲客户', create_time=None)
monkeypatch.setattr(stats, '_customer_cert_counts', lambda db, user: SimpleNamespace(c=SimpleNamespace(customer_id='id', cert_count='cnt')))
monkeypatch.setattr(stats, '_customers', lambda db, user: Q(rows=[(customer, 3)]))
result = stats.list_customers(MagicMock(), SimpleNamespace(tenant_no='t', to_user_info=lambda: {}))
assert result['total'] == 1
assert result['list'][0]['customerName'] == '甲客户'
assert result['list'][0]['certCount'] == 3
def test_get_cert_customer_name_from_crm_not_consignee(monkeypatch):
"""详情/编辑用 CRM 系统客户名,不用证面收货单位。"""
from app.services import pl_quota_cert_service as svc
class Col:
def __init__(self, name):
self.name = name
row = SimpleNamespace(
id='t1',
cert_no='Y1',
customer_id='cust-kanghua',
consignee_name='国能宿州热电有限公司',
valid_use_text='多次有效',
total_qty=Decimal('10'),
used_qty=Decimal('0'),
reserved_qty=Decimal('0'),
__table__=SimpleNamespace(
columns=[
Col('id'),
Col('cert_no'),
Col('customer_id'),
Col('consignee_name'),
Col('valid_use_text'),
Col('total_qty'),
Col('used_qty'),
Col('reserved_qty'),
]
),
)
monkeypatch.setattr(svc, '_get_row', lambda *a, **k: row)
monkeypatch.setattr(svc, 'is_single_use_consumed', lambda *a, **k: False)
monkeypatch.setattr(
svc, '_customer_display_name', lambda *a, **k: '滁州康华电子材料有限公司'
)
monkeypatch.setattr(svc, '_refs', lambda *a, **k: [])
monkeypatch.setattr(svc, '_has_history', lambda *a, **k: False)
monkeypatch.setattr(svc, '_transport_derived_categories', lambda *a, **k: [])
opening = MagicMock()
opening.filter_by.return_value.first.return_value = None
db = MagicMock()
db.query.return_value = opening
result = svc.get_cert(db, SimpleNamespace(tenant_no='t'), 'transport', 't1')
assert result['customerName'] == '滁州康华电子材料有限公司'
assert result['consigneeName'] == '国能宿州热电有限公司'
assert result['customerId'] == 'cust-kanghua'
+4 -3
View File
@@ -57,7 +57,7 @@ def test_permission_guards_validation_and_atomic_creation(api):
saved = client.post(path, json=payload())
assert saved.status_code == 200
row = saved.json()
assert row['tenantNo'] == 't' and row['status'] == 'draft' and row['openingConfirmed'] is False
assert row['tenantNo'] == 't' and row['status'] == 'active' and row['openingConfirmed'] is True
assert client.put(path + '/' + row['id'], json={'totalQty': '-1'}).status_code == 422
assert client.get(path + '/' + row['id']).json()['totalQty'] == '30.000000'
app.dependency_overrides[auth] = lambda: {'role': 'admin', 'user_id': 'u', 'tenant_no': 'other'}
@@ -97,7 +97,7 @@ def test_plan_overview_accepts_web_funnel_aliases_and_validates_dates(api, monke
assert filters == {'plan_no': '001', 'license_plate': '皖A',
'plan_date_from': date(2026, 11, 1), 'plan_date_to': date(2026, 11, 3),
'quota_status_in': 'reserved,settled', 'supplier_name': None, 'customer_name': None,
'product_name': None, 'follow_status_in': None}
'product_name': None, 'follow_status_in': None, 'conversion_only': False}
assert client.get('/api/transport/quota-certs/plans', params={'planDateFrom': 'wrong'}).status_code == 422
assert received == []
@@ -130,7 +130,8 @@ def test_total_adjust_requires_provenance_and_replays_without_duplicate_log(api)
assert response.status_code == 200
assert client.post(path, json=body).status_code == 200
logs = client.get(base + '/' + row['id'] + '/logs').json()
assert len(logs) == 1 and logs[0]['totalDelta'] == '5.000000'
adjust_logs = [item for item in logs if item.get('eventType') == 'total_adjust']
assert len(adjust_logs) == 1 and adjust_logs[0]['totalDelta'] == '5.000000'
assert client.post(path, json={**body,'totalQty':'40'}).status_code == 409
assert client.post(path, json={**body,'operationId':'adjust-2','sourceNote':''}).status_code == 409
assert client.get(base + '/' + row['id']).json()['totalQty'] == '35.000000'
+5
View File
@@ -251,6 +251,11 @@ def approval_case(business, monkeypatch):
c.db.commit()
c.user.to_tenant_user_dict=lambda:{}
monkeypatch.setattr(api,'get_tenant_no',lambda *a:'t')
# 安排入口要求已配置客户品类额度生效时间;本套用例聚焦共享运输/钉版本,不测切点本身
monkeypatch.setattr(
'app.services.pl_cert_quota_rule_config_service.assert_cert_quota_rule_configured',
lambda *a, **k: None,
)
c.api=api
return c
+41 -19
View File
@@ -41,14 +41,31 @@ def transport(**kw):
return data
def test_save_auto_enables_with_zero_opening(ctx):
"""建档即启用:期初已用默认 0,无需手工期初确认。"""
db, u, s, m = ctx
row = s.save_cert(db, u, 'purchase', purchase())
assert row['status'] == 'active'
assert row['openingConfirmed'] is True
assert row['usedQty'] == Decimal('0')
assert row['verifiedBy'] == 'u'
assert row['verifiedTime'] is not None
log = db.query(m.PlCertQtyLog).filter_by(del_flag=0, event_type='opening').one()
assert (log.event_snapshot or {}).get('auto') is True
transport_row = s.save_cert(db, u, 'transport', transport())
assert transport_row['status'] == 'active' and transport_row['openingConfirmed'] is True
def test_manual_optional_image_complete_fields(ctx):
db,u,s,m = ctx
data = purchase(buyerAddress='地址', buyerPhone='电话', buyerLegalRepresentative='甲', sellerAddress='乙址', sellerPhone='乙电话', sellerLegalRepresentative='乙', purchasePurpose='用途', issueDate='2026-01-01', validFrom='2026-01-01', validTo='2027-01-01')
row = s.save_cert(db,u,'purchase',data)
assert row['buyerAddress'] == '地址' and row['attachmentUrls'] is None
assert row['totalQty'] == Decimal('100.123456')
assert row['status'] == 'draft'
assert db.query(m.PlCertQtyLog).count() == 0
assert row['status'] == 'active'
assert row['openingConfirmed'] is True
assert row['usedQty'] == Decimal('0')
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0, event_type='opening').count() == 1
db.rollback()
assert db.query(m.PlPurchaseCert).count() == 0
@@ -56,9 +73,8 @@ def test_manual_optional_image_complete_fields(ctx):
def test_opening_confirmation_remains_visible_after_reverification_required(ctx):
db, user, svc, models = ctx
row = svc.save_cert(db, user, 'purchase', purchase())
assert row['openingConfirmed'] is False
row = svc.confirm_cert(db, user, 'purchase', row['id'], {
'operationId': 'op-ui', 'usedQty': '0', 'singleUseConsumed': False, 'reason': '全新证'})
assert row['openingConfirmed'] is True
assert row['status'] == 'active'
changed = svc.save_cert(db, user, 'purchase', {'buyerAddress': '更正地址'}, row['id'])
assert changed['verifiedTime'] is None
assert changed['openingConfirmed'] is True
@@ -89,10 +105,12 @@ def test_reference_delta_omitted_empty_and_invalid_prewrite(ctx):
def test_confirmation_and_history_protection(ctx):
db,u,s,m=ctx
row=s.save_cert(db,u,'purchase',purchase())
assert row['status']=='active' and row['openingConfirmed'] is True
with pytest.raises((HTTPException,ValidationError)): s.confirm_cert(db,u,'purchase',row['id'],{'usedQty':'0','singleUseConsumed':True,'reason':'核对','operationId':'op'})
row=s.confirm_cert(db,u,'purchase',row['id'],{'usedQty':'0','singleUseConsumed':True,'reason':'核对','evidenceUrls':'https://minio/evidence','operationId':'op'})
assert row['openingSingleUseConsumed']==1 and row['status']=='active'
assert db.query(m.PlCertQtyLog).one().event_type=='opening'
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).one().event_type=='opening'
# 建档自动期初后身份已锁定;手工覆盖期初后同样不可改核定量/证号。
for data in ({'quantityUnit':'千克'},{'certNo':'new'},{'totalQty':'90'}):
with pytest.raises(HTTPException): s.save_cert(db,u,'purchase',data,row['id'])
with pytest.raises(HTTPException): s.delete_cert(db,u,'purchase',row['id'])
@@ -151,8 +169,9 @@ def test_opening_retry_preserves_balance_and_operation_identity(ctx):
db.query(m.PlPurchaseCert).one().used_qty=Decimal('5')
retry=s.confirm_cert(db,u,'purchase',row['id'],opening())
assert retry['usedQty']==Decimal('5')
assert db.query(m.PlCertQtyLog).count()==1
assert db.query(m.PlCertOperation).count()==1
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).count()==1
# 自动期初操作号 + 手工期初操作号;重放不新增手工操作
assert db.query(m.PlCertOperation).filter_by(operation_id='opening').count()==1
with pytest.raises(HTTPException): s.confirm_cert(db,u,'purchase',row['id'],opening(usedQty='7'))
with pytest.raises(HTTPException): s.confirm_cert(db,u,'purchase',row['id'],opening(operationId='second'))
@@ -209,10 +228,11 @@ def test_reverify_after_correction_keeps_opening_and_balances(ctx):
assert corrected['status']=='suspended' and corrected['verifiedBy'] is None
result=s.verify_cert(db,u,'purchase',row['id'],{'operationId':'verify-1','reason':'地址更正核对','evidenceUrls':'https://minio/evidence'})
assert result['status']=='active' and result['usedQty']==Decimal('5')
assert db.query(m.PlCertQtyLog).count()==1
assert db.query(m.PlCertOperation).count()==2
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).count()==1
# 自动期初 + 手工期初 + 复核
assert db.query(m.PlCertOperation).count()==3
result=s.verify_cert(db,u,'purchase',row['id'],{'operationId':'verify-1','reason':'地址更正核对','evidenceUrls':'https://minio/evidence'})
assert db.query(m.PlCertOperation).count()==2
assert db.query(m.PlCertOperation).count()==3
def test_actual_scope_denies_user_without_roles(ctx):
@@ -261,7 +281,7 @@ def test_historical_opening_accepts_written_source_without_image(ctx):
row=s.save_cert(db,u,'purchase',purchase())
result=s.confirm_cert(db,u,'purchase',row['id'],{'operationId':'written-source','usedQty':'5','singleUseConsumed':True,'reason':'历史核对','sourceNote':'2026年10月纸质台账第3页,经张三核实'})
assert result['usedQty']==Decimal('5') and result['status']=='active'
assert db.query(m.PlCertQtyLog).one().event_snapshot['sourceNote'].startswith('2026')
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).one().event_snapshot['sourceNote'].startswith('2026')
def test_unknown_use_text_is_preserved_and_requires_confirmation(ctx):
@@ -314,11 +334,11 @@ def test_unlinked_reference_can_bind_purchase_archive_after_opening(ctx):
assert saved['purchaseRefs'][0]['purchaseCertId']==purchase_row['id']
assert saved['purchaseRefs'][0]['referenceNo']=='G35260264950502'
linked=saved['purchaseRefs'][0]
with pytest.raises(HTTPException, match='不能改变证面引用身份'):
with pytest.raises(HTTPException, match='不能改变关联购买证身份'):
s.save_cert(db,u,'transport',{'purchaseRefs':[{'id':linked['id'],'referenceType':'unknown','referenceNo':purchase_row['certNo'],'purchaseCertId':purchase_row['id']}]},row['id'])
with pytest.raises(HTTPException, match='不能移除'):
s.save_cert(db,u,'transport',{'purchaseRefs':[]},row['id'])
with pytest.raises(HTTPException, match='不能改变证面引用身份'):
with pytest.raises(HTTPException, match='不能改变关联购买证身份'):
s.save_cert(db,u,'transport',{'purchaseRefs':[
{k:linked[k] for k in ('id','referenceType','referenceNo','purchaseCertId')},
{'referenceType':'unknown','referenceNo':'NEW'},
@@ -423,7 +443,7 @@ def test_opening_verify_stale_operation_snapshot_uses_unique_insert_arbitration(
result=fn(db,u,'purchase',p['id'],body)
assert missed and result['id']==p['id']
assert db.query(m.PlCertOperation).count()==count
assert db.query(m.PlCertQtyLog).count()==1
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).count()==1
def test_opening_and_verify_allow_blank_reason(ctx):
@@ -431,7 +451,7 @@ def test_opening_and_verify_allow_blank_reason(ctx):
p=s.save_cert(db,u,'purchase',purchase())
row=s.confirm_cert(db,u,'purchase',p['id'],{**opening(),'reason':' '})
assert row['status']=='active'
assert db.query(m.PlCertQtyLog).one().reason==''
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).one().reason==''
s.save_cert(db,u,'purchase',{'buyerAddress':'更正地址'},p['id'])
verified=s.verify_cert(db,u,'purchase',p['id'],{'operationId':'verify-blank','reason':''})
assert verified['status']=='active' and verified['verifiedTime']
@@ -440,10 +460,12 @@ def test_opening_and_verify_allow_blank_reason(ctx):
def test_rejected_opening_rolls_back_claimed_operation(ctx):
db,u,s,m=ctx
p=s.save_cert(db,u,'purchase',purchase())
auto_ops = db.query(m.PlCertOperation).count()
auto_logs = db.query(m.PlCertQtyLog).filter_by(del_flag=0).count()
with pytest.raises(HTTPException):
s.confirm_cert(db,u,'purchase',p['id'],{**opening(),'usedQty':'999'})
assert db.query(m.PlCertOperation).count()==0
assert db.query(m.PlCertQtyLog).count()==0
assert db.query(m.PlCertOperation).count()==auto_ops
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).count()==auto_logs
def test_replay_reads_current_certificate_and_opening_not_old_snapshot(ctx,monkeypatch):
@@ -472,7 +494,7 @@ def test_closed_certificate_keeps_history_and_cannot_be_reopened(ctx):
used = confirmed['usedQty']
closed = s.save_cert(db, u, 'purchase', {'status': 'closed'}, row['id'])
assert closed['status'] == 'closed' and closed['usedQty'] == used
assert db.query(m.PlCertQtyLog).count() == 1
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).count() == 1
for action, payload in (
('confirm', {**opening(), 'operationId': 'reopen-opening'}),
('verify', {'operationId': 'reopen-verify', 'reason': '重新启用'}),
+36
View File
@@ -317,3 +317,39 @@ def test_missing_contract_is_empty_binding_not_unreadable(case, contract_id):
assert rows[0]['bindingsReadable'] is True
assert rows[0]['certificates'] == []
assert rows[1]['bindingsReadable'] is False
def test_plan_quota_usages_include_cert_attachment_urls(case):
from app.models.crm_models import CrmContract
from app.models.pl_quota_models import (
PlPlanCertUsage, PlPlanCertUsageItem, PlPurchaseCert, PlTransportCert,
)
from app.services.pl_quota_planning import get_plan_quota
c = case
for model in (CrmContract, PlPlanCertUsage, PlPlanCertUsageItem, PlPurchaseCert, PlTransportCert):
model.__table__.create(c.db.bind)
c.db.add(PlPurchaseCert(
id='p1', cert_no='G-BUY', product_name_on_cert='硫酸', valid_use_text='一次有效',
buyer_name='买方', seller_name='卖方', total_qty=33, quantity_unit='吨',
attachment_urls='https://example.com/buy.jpg', tenant_no='t', status='active',
))
c.db.add(PlTransportCert(
id='t1', cert_no='Y-TR', product_name_on_cert='硫酸', valid_use_text='多次有效',
shipper_name='货主', total_qty=70, quantity_unit='吨', vehicle_numbers_json=['皖A1'],
attachment_urls='', tenant_no='t', status='active',
))
c.db.add(PlPlanCertUsage(
id='u1', tenant_no='t', plan_id='plan', plan_no_snapshot='PLAN',
cert_type='purchase', cert_id='p1', cert_no_snapshot='G-BUY', cert_revision=1,
quantity_unit='吨', reserved_qty=10, used_qty=0, usage_status='reserved',
))
c.db.add(PlPlanCertUsage(
id='u2', tenant_no='t', plan_id='plan', plan_no_snapshot='PLAN',
cert_type='transport', cert_id='t1', cert_no_snapshot='Y-TR', cert_revision=1,
quantity_unit='吨', reserved_qty=10, used_qty=0, usage_status='reserved',
))
c.db.flush()
usages = {u['certId']: u for u in get_plan_quota(c.db, c.user, 'plan')['usages']}
assert usages['p1']['attachmentUrls'] == 'https://example.com/buy.jpg'
assert usages['p1']['certNo'] == 'G-BUY'
assert usages['t1']['attachmentUrls'] in ('', None)
@@ -29,6 +29,8 @@ CREATE TABLE `pl_purchase_cert` (
`seller_legal_representative` varchar(100) DEFAULT NULL COMMENT '销售方法定代表人',
`seller_phone` varchar(100) DEFAULT NULL COMMENT '销售方电话',
`purchase_purpose` varchar(1000) DEFAULT NULL COMMENT '用途',
`customer_id` varchar(64) DEFAULT NULL COMMENT '系统客户ID;选证必填',
`category_id` varchar(64) DEFAULT NULL COMMENT '证用品类ID;选证必填',
`tenant_no` varchar(64) NOT NULL COMMENT '租户编号,由服务端当前用户上下文设置',
`order_num` int DEFAULT NULL COMMENT '公共排序号',
`remark` varchar(1000) DEFAULT NULL COMMENT '系统备注,与证面备注分开',
@@ -74,6 +76,7 @@ CREATE TABLE `pl_transport_cert` (
`route_text` text COMMENT '运输路线完整原文',
`consignee_name` varchar(255) DEFAULT NULL COMMENT '收货单位名称或姓名',
`applicant_name` varchar(255) DEFAULT NULL COMMENT '申请单位名称或姓名',
`customer_id` varchar(64) DEFAULT NULL COMMENT '系统客户ID;选证必填',
`tenant_no` varchar(64) NOT NULL COMMENT '租户编号,由服务端当前用户上下文设置',
`order_num` int DEFAULT NULL COMMENT '公共排序号',
`remark` varchar(1000) DEFAULT NULL COMMENT '系统备注,与证面备注分开',
@@ -0,0 +1,24 @@
-- 只读:按证面品名归纳候选证用品类名(上线前人工建品类参考)
-- 不写库。执行后人工创建 pl_cert_category、关联产品,并回填证件 customer_id/category_id,再配置 effective_at。
SET NAMES utf8mb4;
SELECT
TRIM(product_name_on_cert) AS candidate_category_name,
COUNT(*) AS cert_count,
SUM(CASE WHEN cert_type = 'purchase' THEN 1 ELSE 0 END) AS purchase_count,
SUM(CASE WHEN cert_type = 'transport' THEN 1 ELSE 0 END) AS transport_count
FROM (
SELECT 'purchase' AS cert_type, product_name_on_cert
FROM pl_purchase_cert
WHERE del_flag = 0
AND product_name_on_cert IS NOT NULL
AND TRIM(product_name_on_cert) <> ''
UNION ALL
SELECT 'transport' AS cert_type, product_name_on_cert
FROM pl_transport_cert
WHERE del_flag = 0
AND product_name_on_cert IS NOT NULL
AND TRIM(product_name_on_cert) <> ''
) t
GROUP BY TRIM(product_name_on_cert)
ORDER BY cert_count DESC, candidate_category_name;
@@ -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,7 @@
-- 客户+品类规则下用证明细不再依赖合同绑证 scope;将合同绑定字段改为可空。
SET NAMES utf8mb4;
ALTER TABLE `pl_plan_cert_usage_item`
MODIFY COLUMN `contract_cert_link_id` varchar(64) DEFAULT NULL COMMENT '合同与证件绑定ID;客户品类规则可空',
MODIFY COLUMN `contract_cert_product_id` varchar(64) DEFAULT NULL COMMENT '合同证件产品范围明细ID;客户品类规则可空',
MODIFY COLUMN `contract_scope_revision` int(11) NOT NULL DEFAULT 0 COMMENT '合同证件适用范围版本;客户品类规则为0';
@@ -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,7 @@
-- 客户+品类规则下用证明细不再依赖合同绑证 scope;将合同绑定字段改为可空。
SET NAMES utf8mb4;
ALTER TABLE `pl_plan_cert_usage_item`
MODIFY COLUMN `contract_cert_link_id` varchar(64) DEFAULT NULL COMMENT '合同与证件绑定ID;客户品类规则可空',
MODIFY COLUMN `contract_cert_product_id` varchar(64) DEFAULT NULL COMMENT '合同证件产品范围明细ID;客户品类规则可空',
MODIFY COLUMN `contract_scope_revision` int(11) NOT NULL DEFAULT 0 COMMENT '合同证件适用范围版本;客户品类规则为0';
@@ -0,0 +1,16 @@
-- 合同用证 → 客户用证(菜单展示名);保留菜单 ID 与权限码。
SET NAMES utf8mb4;
START TRANSACTION;
UPDATE sys_menu
SET menu_name = '客户用证', update_time = NOW()
WHERE del_flag = 0
AND (
id = 'PL_CONTRACT_QUOTA'
OR (path = 'contract-quota' AND perms = 'pl:quota-cert:list')
OR id = 'MINI_PL_CERT_CONTRACT'
OR perms = 'mini:pl:contract-quota:list'
)
AND menu_name = '合同用证';
COMMIT;
@@ -0,0 +1,227 @@
# 客户+品类证件额度 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `.agents/skills/subagent-driven-development/SKILL.md`(推荐)或 `.agents/skills/executing-plans/SKILL.md` 按任务推进。步骤用 checkbox(`- [x]`)跟踪。
> **设计权威:** `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`
- [x] **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(或设计等价字段)
- [x] **Step 2:** 字段注释与 AGENTS 字段序一致;索引含 tenant_no + 常用筛选。
- [x] **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/`
- [x] **Step 1:** 增加 SQLAlchemy 模型与 Pydantic schema(camelCase alias)。
- [x] **Step 2:** 品类 CRUD + 关联产品差量保存(遵守 child-data-save-rules)。
- [x] **Step 3:** pytest:创建品类、关联两产品、停用关联、租户隔离。
- [x] **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`)
- [x] **Step 1:** GET/PUT 当前租户 `effective_at`。
- [x] **Step 2:** `assert_cert_quota_rule_configured`:`effective_at is None` → HTTP 409 固定中文文案。
- [x] **Step 3:** 计划创建时按设计 §7 打 `cert_quota_rule_version`。
- [x] **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/` 状态漏斗
- [x] **Step 1:** 列表 API 默认 `status not in (closed, void)`;显式传 status 时以参数为准。
- [x] **Step 2:** 前端列头漏斗可选到已核销/作废;默认不选。
- [x] **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` 若同表单模型)
- [x] **Step 1:** 品类列表/编辑/关联产品(Fresh Mint,选 P 模式并在说明中写明)。
- [x] **Step 2:** 系统信息下 Tab「运输证件规则」编辑生效时间。
- [x] **Step 3:** 购买证/运输证表单增加客户、品类(运输证展示派生品类)。
- [x] **Step 4:** 菜单 SQL/权限码文档化(若需插入菜单,写 SQL 片段)。
### Task 1.6:计划详情展示位(可读)
**Files:**
- Modify: `web-admin/src/components/business/pl-plan-view-tabs/` 或等价详情
- Modify: 计划详情 API 若需返回 usage 证件摘要
- [x] **Step 1:** 详情区展示已关联购买证/运输证(无数据时占位「暂无」)。
- [x] **Step 2:** 同市来源可只显示购买证。
### 一期完成门禁
- [x] 本地执行相关 pytest,记录命令与结果。
- [x] `pnpm build:typecheck`(若改了 web-admin)或说明未跑原因。
- [x] **停止并等待用户:「开始第二期」**
---
## 二期:安排/归档新选证 + 祖父条款
### Task 2.1:用证来源按段客户(D6/D7/D8/D9)
**Files:**
- Modify: `backend/app/services/pl_quota_sources.py`
- [x] **Step 1:** 外部装/送段均进入来源(非仅单侧计量吞掉对侧额度需求——按设计 D7);内部山峡/云栋过滤保留。
- [x] **Step 2:** `same_city` → `requires_transport=False` 保留。
- [x] **Step 3:** 来源快照写入 `customer_id`/`customer_name`。
- [x] **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 调用
- [x] **Step 1:** 实现设计 §5 伪代码;单测:10 吨跳过、100 吨命中;无运输证跳过该购买证;同市无运输证成功。
- [x] **Step 2:** 新规则路径禁止拆分到多购买证。
- [x] **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`、前端安排提交
- [x] **Step 1:** `customer_category` 版本:`build_usage_request` 不再 `resolve_contract_bound_certs`。
- [x] **Step 2:** 安排失败文案引导证书齐备;移除/隐藏手工 `quotaConfiguration` 安排入口。
- [x] **Step 3:** 归档 settle 仍走账本;新规则余额不足文案更新为客户/品类语境。
- [x] **Step 4:** legacy:已安排归档不因缺少客户品类绑证失败(单测)。
### Task 2.4:合同绑证 UI 收敛
**Files:**
- Modify: `web-admin/src/views/pl/contract-quota/` 等
- [x] **Step 1:** 新规则生效后隐藏或只读提示,避免双轨操作。
### 二期完成门禁
- [x] 相关 pytest 全绿并记录。
- [x] **停止并等待用户:「开始第三期」**
---
## 三期:证书齐备 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
- [x] **Step 1:** 标记齐备 → `quota_cert_pending_fulfill=1`,安排可跳过 reserve。
- [x] **Step 2:** `settle`/`archive`:若 pending 或 waived 未完善 → 409,不得跳过额度。
- [x] **Step 3:** pytest:齐备可安排、不可归档;完善后可归档。
### Task 3.2:完善并关闭 API
**Files:**
- Modify: `backend/app/api/pl/plan.py` 或 `quota_cert.py`
- Create service:对单计划执行与安排相同的选证 + reserve
- [x] **Step 1:** `POST .../fulfill-quota-certs`(单条)与 batch 接口。
- [x] **Step 2:** 成功:写 usage、清 pending、出队;失败:保留 pending + 原因。
- [x] **Step 3:** 并发单测或文档化行锁策略。
### Task 3.3:证书齐备 Web
**Files:**
- Modify/Create: 证书齐备列表页(现有 menu 路由对齐)
- [x] **Step 1:** 仅列出新规则待完善计划。
- [x] **Step 2:** 上传购买证/运输证入口。
- [x] **Step 3:** 操作列完善并关闭 + 批量;成功刷新列表。
- [x] **Step 4:** 归档弹窗对接「未完善」错误引导。
### Task 3.4:详情展示真实挂证
- [x] **Step 1:** 完善或安排成功后详情可见证号/品类/占用等摘要。
### 三期完成门禁
- [x] 全链路 pytest + 必要前端 typecheck。
- [x] 更新本 plan 所有 checkbox;在设计文档顶部标注「已实现待验收」或按用户要求提交。
- [x] **停止并等待用户验收;非用户要求不要 push。**
---
## 数据迁移作业(可穿插在一期后、切点打开前)
- [x] 提供只读 SQL/脚本:按 `product_name_on_cert` 分组列出候选品类名。
- [x] 文档说明:用户手工建品类并关联产品、回填证件 `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;三期代码已落地,待业务验收;非用户要求不 push)
> 关联计划:`docs/superpowers/plans/2026-11-14-pl-customer-cert-quota.md`
> 现状参考:`openspec/changes/pl-contract-cert-quota-control/`(合同绑证模型,本需求起新规则不再作为选证来源)
## 1. 目标与非目标
### 目标
将易制毒购买证/运输证的适用范围从「合同 + 合同产品」改为「客户 + 证用品类」,安排仅自动选证(整笔不拆证),证书齐备只放行安排、归档必须真证挂载,并用可配置生效时间做历史祖父,降低上线冲击。
### 非目标
- 不改变易制爆「合法使用证明」现状。
- 不推倒 `pl_plan_cert_usage` / `pl_cert_qty_log` 核销账本,只改候选来源与门禁。
- 不复用 CRM `crm_product_category` 作为证用品类。
- 安排界面不做手工选证。
- 产品行 `purchase_cert_url` / `transport_cert_url` 附件能力可保留作展示/打包,但**不再**作为安排/归档额度门禁。
## 2. 已拍板决策一览
| 编号 | 决策 |
| --- | --- |
| D1 | 证绑定客户,不绑定合同/合同产品 |
| D2 | 独立证用品类;购买证绑一个品类;品类 → 多个系统产品 |
| D3 | 运输证绑客户 + 引用购买证;品类 = 引用购买证品类并集去重 |
| D4 | 同客户下所有合同可用该客户证件(仍受品类/有效期/车号/余量约束) |
| D5 | 选证规则 A:单证须整笔盖住本次需求,不够则跳过换下一张到期更晚但够量的;按 `valid_to` 升序 |
| D6 | 按段次外部客户选证:装货段用供应商客户,送货段用收货客户 |
| D7 | 装、送凡外部且需用证的段,各扣各的(可双套) |
| D8 | 安庆山峡、安徽云栋豁免购买证/运输证 |
| D9 | 同市(段次 `same_city=1`)只要求购买证 |
| D10 | 安排只自动选证;选不到只能「证书齐备」;无手工选证 |
| D11 | 证书齐备:可安排;归档不可绕过 |
| D12 | 齐备列表:上传证 → 单条/批量「完善并关闭」(同安排选证)→ 成功出队 |
| D13 | 计划详情展示已关联购买证/运输证 |
| D14 | 解析后自动绑定系统客户:保留 |
| D15 | 证件列表默认不展示 `closed`/`void`,列头漏斗可筛 |
| D16 | 切点:【系统信息】配置 `effective_at`;未配置则拦住安排;计划创建时打规则版本 |
| D17 | 上线前创建计划:不因新规则挡归档;旧齐备不进待完善 |
| D18 | 品类↔产品映射由人工配置;可从证面品名辅助归纳品类 |
## 3. 现状要点(改造锚点)
| 能力 | 路径 | 改造动作 |
| --- | --- | --- |
| 合同绑证选证 | `pl_quota_planning.resolve_contract_bound_certs` 等 | 新规则停用为选证源 |
| 用证来源 | `pl_quota_sources.extract_sources` | 按段客户;双侧外部都进来源(对齐 D7);内部客户过滤保留 |
| 分配 | `pl_quota_allocation.allocate_plan` | 改为整笔贪心(D5),不再为新规则做跨证拆分 |
| 安排挂钩 | `pl/transport.py` → `apply_plan_quota(reserve\|resize)` | 切点校验 + 新候选 |
| 归档挂钩 | `pl/plan.py` → `settle_plan_quota` | 齐备不可跳过 settle(新规则) |
| 齐备 | `pl_quota_waiver` | 安排可 waive;归档不可;完善写账本 |
| 证件主数据 | `pl_purchase_cert` / `pl_transport_cert` | 增加客户 ID、品类 ID 等 |
## 4. 数据模型
### 4.1 新建:证用品类
**表 `pl_cert_category`**
- 标准业务字段序:id → 业务字段 → tenant_no、order_num、remark、审计、del_flag
- 业务字段建议:`category_name`(如硫酸)、`category_code`(可选)、`status`(启用/停用)
**表 `pl_cert_category_product`**
- `category_id`、`product_id`(CRM 产品)、`scope_status`(active/inactive)
- 租户内 `(category_id, product_id)` 有效行唯一
### 4.2 证件主数据扩展
**`pl_purchase_cert`**
- `customer_id`(系统客户,必填后方可 active 参与选证;解析自动匹配保留)
- `category_id`(证用品类,必填后方可参与选证)
- 保留 `product_name_on_cert` 证面原文
**`pl_transport_cert`**
- `customer_id`(系统客户)
- 品类不落冗余主字段亦可:选证时由 `pl_transport_cert_purchase_ref` → 购买证 `category_id` 并集计算;若需列表筛选可冗余缓存并在引用变更时刷新
**`pl_transport_cert_purchase_ref`**
- 继续作为运输证 ↔ 购买证引用;选证要求引用的购买证参与本段配对
### 4.3 合同绑证表
- `pl_contract_cert_link` / `pl_contract_cert_product`:**新规则选证不再读取**
- 表可保留只读/历史;前端合同绑证入口对新规则租户隐藏或只读提示「已改为客户+品类」
### 4.4 计划与切点
**系统配置(新建表,推荐 `pl_cert_quota_rule_config` 或租户参数表)**
- `tenant_no`
- `effective_at`(DATETIME,可空)
- 审计字段
- 每租户一行(或最新有效一行)
**`pl_plan` 增加**
- `cert_quota_rule_version`:`legacy` | `customer_category`(创建时写入;空/null 视为 legacy)
- 可选保留 `quota_cert_waived*`;语义见 §6
- 建议增加完善标记,例如:
- `quota_cert_pending_fulfill`:1 = 已齐备待完善(新规则)
- 或用「waived=1 且无 settled/reserved 新规则用量」推断;实现时优先显式字段以免歧义
### 4.5 用量账本
- 继续使用 `pl_plan_cert_usage` / `pl_plan_cert_usage_item` / `pl_cert_qty_log`
- item 上原「合同绑定」维度改为可空或改为记录 `customer_id` + `category_id` 快照;以最小迁移为准,保证对账可追溯
## 5. 选证算法(新规则)
对每个用证来源(段 × 易制毒产品行,外部客户,非豁免):
```
需求量 Q,单位 U,客户 C,产品 P,是否同市 S,车号 V,业务日 D
1. 解析品类:P 落在哪些 active 的 pl_cert_category_product → 得到 category 集合
2. 候选购买证:
- customer_id=C, category_id∈集合, status=active 且已复核
- D ∈ [valid_from, valid_to]
- available = total - reserved - used(及一次有效等现有占用规则)≥ Q(同单位;异单位按现有换算/拦截策略)
3. 按 valid_to ASC, id ASC 排序
4. 依次尝试购买证 B:
- 若 S(同市):选中 B,结束本源
- 若异地:在「引用了 B」的运输证中找 T:
- customer_id=C(或与业务确认是否允许仅购买证客户一致)
- 车号覆盖 V,有效期与 active/复核,available ≥ Q
- 可按 T.valid_to ASC 取第一张
- 找不到 T 则跳过 B,试下一张购买证
5. 若无一整笔覆盖 → 本源失败 → 整单安排失败(或完善失败)
```
多产品/多段:每个来源独立跑上述算法;详情与账本按来源挂多证。
**禁止**:把 Q 拆到多张购买证(D5)。
## 6. 证书齐备 2.0
| 动作 | 新规则行为 |
| --- | --- |
| 标记齐备 | 允许在证件不足时安排;释放已有预占(若有);置待完善;**不**写 usage 预占 |
| 安排 | `apply_plan_quota` 对 waived+pending 跳过 reserve(与今类似,但仅安排路径) |
| 归档 | **禁止**因 waived 跳过 settle;若仍 pending 完善 → 409 提示先去证书齐备完善 |
| 完善并关闭 | 按 §5 自动选证并 `reserve`(计划已安排)或等价补记账;成功清除 pending,出列表 |
| 批量完善 | 对勾选行逐单执行;部分成功部分失败,返回明细 |
| 列表范围 | 仅 `cert_quota_rule_version=customer_category` 且待完善的计划;legacy 齐备不进列表 |
界面:证书齐备页支持上传购买证/运输证(解析绑客户保留)+ 操作列完善并关闭 + 批量。
## 7. 切点与门禁
```
arrange / resize:
if effective_at is null:
409「请先在系统信息中配置客户品类额度生效时间」
resolve plan.cert_quota_rule_version(已打标用标;未打标且 create_time>=effective_at 视为新规则并补标——仅创建路径应打标,安排时以已存字段为准)
if version == customer_category:
走 §5;失败可引导证书齐备
else:
祖父:不按新客户品类规则拦截归档相关;安排若仍发生,建议对 legacy 保持「不强制新选证」或只读旧行为——
【定稿】上线前创建的计划:已安排不挡归档;若 legacy 计划再次调整运输,实现时默认也不强制新规则选证(避免历史单改派被卡死),在设计实现备注中写清并单测覆盖。
```
**系统信息 Tab**
- 路由挂在现有「系统信息」下(`web-admin/src/router/modules/sqxp.ts` 的 SystemInfo 子级)
- 配置 `effective_at`;保存权限建议管理员
- 文案说明:未配置将无法安排运输;修改只影响之后新建且按创建打标的计划
**计划创建**
```
if effective_at and create_time >= effective_at:
cert_quota_rule_version = customer_category
else:
cert_quota_rule_version = legacy
```
## 8. 前端范围
| 页面/组件 | 变更 |
| --- | --- |
| 证用品类管理 | 新建列表+关联产品 |
| 购买证/运输证建档 | 客户、品类;运输证引用;列表默认排除 closed/void |
| 合同绑证 UI | 新规则下隐藏或只读说明 |
| 安排 | 去掉手工用证配置;失败文案引导齐备 |
| 计划详情/查看 | 展示关联购买证/运输证 |
| 证书齐备 | 列表、上传、完善并关闭、批量 |
| 系统信息 | 新 Tab 配置生效时间 |
| 归档 | 待完善不可归档的提示 |
H5 `m/pl/plan-cert`:评估与 Web 对齐的上传/建档;完善列表以 Web 为主时可第二期补齐,但不可破坏现有 H5 建档。
## 9. 历史数据迁移
1. **证件**:从现有证面 `product_name_on_cert` 归纳候选品类名,脚本/SQL 可生成品类草稿;**品类↔产品**由用户在界面手工完成后再开切点。
2. **客户**:尽量用现有解析/名称匹配回填 `customer_id`;匹配不上保持空,active 选证前必须补齐。
3. **计划**:不批量改历史 `quota_cert_waived`;不强制历史单进待完善。
4. **切点**:业务准备好后在系统信息填写 `effective_at`(可等于「今天 0 点」或切换瞬间)。
## 10. 分期
| 期 | 内容 | 可验收结果 |
| --- | --- | --- |
| 一期 | 品类主数据、证件绑客户/品类、列表默认过滤、系统信息 effective_at、详情展示位(可先读账本) | 未配时间则安排被拦;可维护品类与证件 |
| 二期 | 新选证算法接入安排/归档;legacy 祖父;停用合同选证源 | 新计划按客户品类自动占证/结转 |
| 三期 | 齐备 2.0 列表与完善并关闭;归档强制已完善 | 齐备可安排、完善后可归档 |
## 11. 验收要点
- 同市只扣购买证;异地购买证+运输证;山峡/云栋不扣。
- 10 吨早到期 + 100 吨晚到期、需求 >10 → 只用 100 吨那套。
- 硫酸+盐酸两产品 → 两套(或多段)独立挂证。
- 未配置 effective_at → 安排 409。
- legacy 已安排 → 可归档不被新规则卡死。
- 新规则齐备 → 可安排、不可归档;完善成功后可归档且账本有用量。
- 证件列表默认无已核销/作废。
- 合法使用证明行为与改前一致。
## 12. 风险
- D7 双侧扣证可能使额度消耗快于客户直觉,需在界面展示「本段客户/用证」。
- 改 `effective_at` 若未打标会漂;必须创建打标。
- 完善批量并发抢同一证余量:逐单事务 + 行锁(沿用现有证件锁)。
- 分配器从可拆分改为整笔:旧单测需分流 legacy/新规则。
+6
View File
@@ -61,6 +61,9 @@
"@tailwindcss/vite": "^4.1.14",
"@tinymce/tinymce-vue": "^6.3.0",
"@turbodocx/html-to-docx": "^1.22.2",
"@univerjs/preset-sheets-core": "1.4.0",
"@univerjs/preset-sheets-filter": "1.4.0",
"@univerjs/presets": "1.4.0",
"@vue-flow/core": "^1.48.2",
"@vue/reactivity": "^3.5.21",
"@vueuse/core": "^13.9.0",
@@ -86,6 +89,9 @@
"pinia-plugin-persistedstate": "^4.3.0",
"pinyin-pro": "^3.28.1",
"qrcode.vue": "^3.6.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"rxjs": "7.8.2",
"sortablejs": "^1.15.7",
"tailwindcss": "^4.1.14",
"tinymce": "^8.5.1",
+1469 -19
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -9,4 +9,5 @@ allowBuilds:
core-js: false
es5-ext: false
esbuild: true
protobufjs: false
vue-demi: true
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { resolveArchiveQuotaBlockMode } from '../src/utils/pl-archive-quota-block'
test('pending fulfill plan only alerts, no certificate-ready confirm', () => {
assert.equal(
resolveArchiveQuotaBlockMode(
'实际数量已保存,证件没有扣减。请把实际数量改小,或先补充购买证和运输证,再重新归档。',
'用证没有完成',
{ quotaCertPendingFulfill: 1, quotaCertWaived: 1 }
),
'alert-pending'
)
})
test('already waived plan does not offer archive-time certificate ready', () => {
assert.equal(
resolveArchiveQuotaBlockMode('余额不足', '用证没有完成', { quotaCertWaived: 1 }),
'alert-pending'
)
})
test('pending keywords still force alert mode without plan flags', () => {
assert.equal(
resolveArchiveQuotaBlockMode(
'该计划仍处于证书齐备待完善,请先在证书齐备中完善并关闭后再归档',
'用证没有完成',
{}
),
'alert-pending'
)
})
test('unmarked legacy quota block still offers certificate ready', () => {
assert.equal(
resolveArchiveQuotaBlockMode(
'实际数量已保存,证件没有扣减。请把实际数量改小,或先补充购买证和运输证,再重新归档。',
'用证没有完成',
{ quotaCertPendingFulfill: 0, quotaCertWaived: 0 }
),
'confirm-ready'
)
})
test('non quota-title blocks stay plain alert', () => {
assert.equal(resolveArchiveQuotaBlockMode('不能归档:缺证', '不能归档', {}), 'alert-plain')
})
+17 -4
View File
@@ -203,13 +203,14 @@ test('real template keeps plans quota proof order and separate lazy contract sco
canViewQuotaCert: true,
canViewProof: true,
detailActiveTab: 'proof',
detailContract: { id: 42 }
detailContract: { id: 42, customerId: 'cust-42' }
}
const rows = panes(ctx)
assert.deepEqual(
rows.map((v: any) => v.props.name),
['plans', 'quota', 'proof']
)
assert.equal(rows[1].props.label, '客户证件与额度')
assert.equal(rows[2].props.label, '运输计划合法使用证明')
assert.equal(rows[2].props.lazy, '')
assert.equal(panel(rows[1]), undefined)
@@ -219,14 +220,19 @@ test('real template keeps plans quota proof order and separate lazy contract sco
assert.equal(proof.props.active, true)
assert.equal(proof.props.embedded, '')
ctx.detailContract.id = 43
ctx.detailContract.customerId = 'cust-43'
ctx.currentDetailId = 43
assert.equal(panel(panes(ctx)[2]).key, 43)
ctx.detailActiveTab = 'quota'
const quota = panel(panes(ctx)[1])
assert.equal(quota.type?.name || quota.type, 'CustomerCertQuotaPanel')
assert.equal(quota.props['customer-id'] ?? quota.props.customerId, 'cust-43')
assert.equal(quota.props.active, true)
ctx.canViewQuotaCert = false
assert.deepEqual(
panes(ctx).map((v: any) => v.props.name),
['plans', 'proof']
)
ctx.detailActiveTab = 'quota'
assert.equal(panel(panes(ctx)[1]), undefined)
})
@@ -265,7 +271,14 @@ test('mounted template unmounts old contract and never mounts quota for proof-on
}
}),
StopDocuments: Child('proof'),
ContractQuotaPanel: Child('quota'),
CustomerCertQuotaPanel: vue.defineComponent({
props: ['customerId', 'active'],
setup(props) {
lifecycle.push(`mount:quota:${props.customerId}`)
vue.onBeforeUnmount(() => lifecycle.push(`unmount:quota:${props.customerId}`))
return () => vue.h('span')
}
}),
PlContractLinkedPlansPanel: vue.defineComponent({
setup() {
return () => vue.h('span')
@@ -282,7 +295,7 @@ test('mounted template unmounts old contract and never mounts quota for proof-on
canViewQuotaCert: false,
canViewProof: true,
detailActiveTab: 'proof',
detailContract: { id: 'A' }
detailContract: { id: 'A', customerId: 'cust-A' }
})
const renderer = vue.createRenderer<any, any>({
createElement: () => ({ children: [] }),
@@ -0,0 +1,75 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
test('web contract detail embeds customer cert panel and renamed tab', () => {
const source = readFileSync(
new URL('../src/views/crm/contract/components/ContractDetailDialog.vue', import.meta.url),
'utf8'
)
assert.match(source, /label="客户证件与额度"/)
assert.match(source, /CustomerCertQuotaPanel/)
assert.doesNotMatch(source, /ContractQuotaPanel/)
assert.match(source, /:customer-id="detailContract\.customerId"/)
})
test('h5 contract detail includes customer cert tab', () => {
const source = readFileSync(
new URL('../src/views/m/crm/contract/detail.vue', import.meta.url),
'utf8'
)
assert.match(source, /CustomerCertQuotaTab/)
assert.match(source, /客户证件与额度/)
assert.match(source, /canViewQuotaCert/)
assert.match(source, /:customer-id="contract\.customerId"/)
})
test('customer cert panel lists by customerId without contract bindings', () => {
const source = readFileSync(
new URL('../src/views/pl/contract-quota/CustomerCertQuotaPanel.vue', import.meta.url),
'utf8'
)
assert.match(source, /quotaCertApi\.list\('purchase', \{ customerId \}\)/)
assert.match(source, /quotaCertApi\.list\('transport', \{ customerId \}\)/)
assert.match(source, /有效次数/)
assert.match(source, /singleUseConsumed/)
assert.doesNotMatch(source, /saveBindings|openCreate|bindings\(/)
})
test('standalone contract-quota page is customer quota list', () => {
const panel = readFileSync(
new URL('../src/views/pl/contract-quota/CustomerQuotaPanel.vue', import.meta.url),
'utf8'
)
const wrapper = readFileSync(
new URL('../src/views/pl/contract-quota/ContractQuotaPanel.vue', import.meta.url),
'utf8'
)
const router = readFileSync(new URL('../src/router/modules/pl.ts', import.meta.url), 'utf8')
assert.match(panel, /客户用证/)
assert.match(panel, /quotaStatisticsApi\.customers/)
assert.match(wrapper, /CustomerQuotaPanel/)
assert.match(router, /title: '客户用证'/)
})
test('web customer quota panel uses toQueryParams from useServerColumnFilters', () => {
const panel = readFileSync(
new URL('../src/views/pl/contract-quota/CustomerQuotaPanel.vue', import.meta.url),
'utf8'
)
assert.match(panel, /toQueryParams/)
assert.match(panel, /\.\.\.toQueryParams\(\)/)
assert.doesNotMatch(panel, /\bbuildQuery\b/)
assert.doesNotMatch(panel, /\bresetFilters\b/)
assert.match(panel, /clearAllFilters/)
assert.match(panel, /watchVisibleColumns/)
})
test('h5 customer quota tab does not call buildQuery', () => {
const tab = readFileSync(
new URL('../src/views/m/pl/plan-cert/CustomerTab.vue', import.meta.url),
'utf8'
)
assert.doesNotMatch(tab, /\bbuildQuery\b/)
assert.match(tab, /quotaStatisticsApi\.customers/)
})
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const readyTab = readFileSync(
new URL('../src/views/m/pl/plan-cert/ReadyTab.vue', import.meta.url),
'utf8'
)
const contractTab = readFileSync(
new URL('../src/views/m/pl/plan-cert/CustomerTab.vue', import.meta.url),
'utf8'
)
const planCertIndex = readFileSync(
new URL('../src/views/m/pl/plan-cert/index.vue', import.meta.url),
'utf8'
)
const archiveForm = readFileSync(
new URL('../src/views/m/pl/plan-cert/archive-form.vue', import.meta.url),
'utf8'
)
const stopCard = readFileSync(
new URL('../src/views/m/pl/plan/PlanStopReadCard.vue', import.meta.url),
'utf8'
)
test('H5 证书齐备对齐 2.0:待完善列表与完善并关闭,且文案不宣称归档可跳过额度', () => {
assert.match(readyTab, /listPendingFulfill/)
assert.match(readyTab, /certReadyApi\.fulfill/)
assert.match(readyTab, /fulfillBatch/)
assert.match(readyTab, /已标记待完善/)
assert.match(readyTab, /完善并关闭/)
assert.match(readyTab, /归档前仍须/)
assert.doesNotMatch(readyTab, /安排和归档时都不再校验/)
assert.doesNotMatch(readyTab, /也不扣减额度/)
})
test('H5 客户用证按客户列表展示证件', () => {
assert.match(contractTab, /quotaStatisticsApi\.customers/)
assert.match(contractTab, /\/m\/pl\/plan-cert\/customer\//)
assert.match(contractTab, /暂无客户用证数据/)
assert.match(planCertIndex, /title: '客户用证'/)
})
test('H5 建档页使用关联购买证且无证面引用残留', () => {
assert.match(archiveForm, /关联购买证/)
assert.doesNotMatch(archiveForm, /证面引用/)
})
test('H5 段次只读卡展示同市只需购买证', () => {
assert.match(stopCard, /是否同市/)
assert.match(stopCard, /同市只需购买证/)
})
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { toxicArrangeBlockMessage } from '../src/utils/pl-quota-cert-waiver'
const detail = readFileSync(new URL('../src/views/m/pl/plan/detail.vue', import.meta.url), 'utf8')
const stopCard = readFileSync(
new URL('../src/views/m/pl/plan/PlanStopReadCard.vue', import.meta.url),
'utf8'
)
const loadSection = readFileSync(
new URL('../src/views/m/pl/plan/MobilePlanLoadSection.vue', import.meta.url),
'utf8'
)
const deliverySection = readFileSync(
new URL('../src/views/m/pl/plan/MobilePlanDeliverySection.vue', import.meta.url),
'utf8'
)
const certFace = readFileSync(
new URL('../src/views/m/pl/plan-cert/CertFacePanel.vue', import.meta.url),
'utf8'
)
const arrangeSubmit = readFileSync(
new URL('../src/composables/usePlPlanArrangeSubmit.ts', import.meta.url),
'utf8'
)
const formDialog = readFileSync(
new URL('../src/components/business/pl-plan-form-dialog/index.vue', import.meta.url),
'utf8'
)
const detailActions = readFileSync(
new URL('../src/utils/mp-pl-plan-detail-actions.ts', import.meta.url),
'utf8'
)
test('H5 计划详情提供查看用证入口并挂载 CertFacePanel', () => {
assert.match(detail, /查看用证/)
assert.match(detail, /CertFacePanel/)
assert.match(detail, /quotaOpen/)
})
test('H5 装送段可编辑同市,只读卡标明同市只需购买证', () => {
assert.match(loadSection, /sameCity/)
assert.match(deliverySection, /sameCity/)
assert.match(stopCard, /同市只需购买证/)
})
test('H5 用证面板新规则只读提示,且占用明细可回填证号', () => {
assert.match(certFace, /客户\+品类自动选证/)
assert.match(certFace, /安排不提供手工选证/)
assert.match(certFace, /certNoSnapshot/)
assert.match(certFace, /sourceStopProductId/)
})
test('H5/共用安排提交不附带手工 quotaConfiguration', () => {
assert.doesNotMatch(arrangeSubmit, /quotaConfiguration/)
assert.match(formDialog, /toxicArrangeBlockMessage/)
assert.match(formDialog, /buildArrangePayloadFromPlanForm/)
})
test('新规则安排以服务端齐备/余量检查为准,不被本地合同绑证缺口误拦', () => {
const unbound = '合同HT-1尚未绑定购买证和运输证,请先到合同中绑定后再安排运输'
assert.equal(
toxicArrangeBlockMessage(
{ certQuotaRuleVersion: 'customer_category', arrangeBlockMessage: '' },
unbound
),
''
)
assert.equal(
toxicArrangeBlockMessage(
{
certQuotaRuleVersion: 'customer_category',
arrangeBlockMessage: '新规则下证书齐备只放行安排'
},
unbound
),
'新规则下证书齐备只放行安排'
)
})
test('H5 计划详情无运输归档操作(归档在 Web;齐备完善在 plan-cert)', () => {
assert.doesNotMatch(detailActions, /archive/)
assert.doesNotMatch(detailActions, /归档/)
assert.match(detailActions, /arrange/)
})
@@ -11,10 +11,14 @@ test('危化品证上传位于运输管理并复用现有 WebView 壳', async ()
const { WORKBENCH_MODULES } = await import(`data:text/javascript,${encodeURIComponent(source)}`)
const transport = WORKBENCH_MODULES.find((m) => m.key === 'pl')
const entry = transport.menus.find((m) => m.code === 'pl-plan-cert-webview')
assert.ok(entry, '运输管理缺少危化品证上传入口')
assert.equal(entry.name, '危化品证上传')
assert.ok(entry, '运输管理缺少运输证件管理入口')
assert.equal(entry.name, '运输证件管理')
assert.equal(entry.perms, 'mini:pl:plan-cert:list')
assert.equal(entry.enabled, true)
assert.ok(
Array.isArray(entry.anyPerms) && entry.anyPerms.includes('mini:pl:quota-cert:list'),
'入口应覆盖证件建档等子权限'
)
const api = await read('miniprogram/config/api.js')
const ast = ts.createSourceFile('api.js', api, ts.ScriptTarget.Latest, true)
let expression
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs'
import test from 'node:test'
import ts from 'typescript'
import * as vue from 'vue'
function loadFreightCalc() {
const path = new URL('../src/composables/usePlFreightCalc.ts', import.meta.url)
assert.ok(existsSync(path), 'usePlFreightCalc must exist')
const source = readFileSync(path, 'utf8')
const ast = ts.createSourceFile('usePlFreightCalc.ts', source, ts.ScriptTarget.Latest, true)
const code = ast.statements
.filter((node) => !ts.isImportDeclaration(node))
.map((node) => node.getText(ast))
.join('\n')
const compiled = ts.transpileModule(code, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
}).outputText
const module = { exports: {} as Record<string, unknown> }
const requireStub = () => {
throw new Error('unexpected require in freight calc test harness')
}
// eslint-disable-next-line no-new-func
const factory = new Function(
'exports',
'require',
'module',
'__filename',
'__dirname',
'computed',
'ref',
'watch',
compiled + '\nmodule.exports = { usePlFreightCalc, sumProductActualQuantities };'
)
factory(
module.exports,
requireStub,
module,
'usePlFreightCalc.ts',
'.',
vue.computed,
vue.ref,
vue.watch
)
return module.exports as {
usePlFreightCalc: typeof import('../src/composables/usePlFreightCalc').usePlFreightCalc
}
}
test('resetFreightCalc re-sums transport qty from existing product actuals', async () => {
const { usePlFreightCalc } = loadFreightCalc()
const products = vue.ref([{ actualQty: 0.04, unit: '吨' }, { actualQty: 0.04, unit: '吨' }])
const hasFreight = vue.ref(1)
const freightType = vue.ref('by_weight')
const scope = vue.effectScope()
const calc = scope.run(() => usePlFreightCalc({ products, hasFreight, freightType }))
assert.ok(calc)
await vue.nextTick()
assert.equal(calc!.transportQty.value, 0.08)
calc!.resetFreightCalc()
await vue.nextTick()
assert.equal(calc!.transportQtyTouched.value, false)
assert.equal(
calc!.transportQty.value,
0.08,
'reset after products already loaded must not leave transport qty empty'
)
scope.stop()
})
test('resetFreightCalc restores product sum after manual transport qty edit', async () => {
const { usePlFreightCalc } = loadFreightCalc()
const products = vue.ref([{ actualQty: 0.04, unit: '吨' }])
const hasFreight = vue.ref(1)
const freightType = vue.ref('by_weight')
const scope = vue.effectScope()
const calc = scope.run(() => usePlFreightCalc({ products, hasFreight, freightType }))
assert.ok(calc)
await vue.nextTick()
calc!.transportQty.value = 9.9
calc!.markTransportQtyTouched()
calc!.resetFreightCalc()
await vue.nextTick()
assert.equal(calc!.transportQtyTouched.value, false)
assert.equal(calc!.transportQty.value, 0.04)
scope.stop()
})
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const viewTabs = readFileSync(
new URL('../src/components/business/pl-plan-view-tabs/index.vue', import.meta.url),
'utf8'
)
const h5Detail = readFileSync(
new URL('../src/views/m/pl/plan/detail.vue', import.meta.url),
'utf8'
)
test('Web 计划详情:关联证件在装送货后且无危化品证书信息区', () => {
assert.doesNotMatch(viewTabs, /危化品证书信息/)
assert.doesNotMatch(viewTabs, /PlPlanHazardCertViewTable/)
const loadIdx = viewTabs.indexOf('装送货信息')
const certIdx = viewTabs.indexOf('关联购买证 / 运输证')
const freightIdx = viewTabs.indexOf('运费信息')
assert.ok(loadIdx >= 0 && certIdx > loadIdx && freightIdx > certIdx)
assert.match(viewTabs, /AttachmentUploadField/)
assert.match(viewTabs, /attachmentUrls/)
})
test('H5 计划详情:关联购买证/运输证替换危化品证书信息并展示附件', () => {
assert.doesNotMatch(h5Detail, /危化品证书信息/)
assert.match(h5Detail, /关联购买证 \/ 运输证/)
assert.match(h5Detail, /关联证件/)
const loadIdx = h5Detail.indexOf('plan-sec-load')
const certIdx = h5Detail.indexOf('plan-sec-cert')
const freightIdx = h5Detail.indexOf('plan-sec-freight')
assert.ok(loadIdx >= 0 && certIdx > loadIdx && freightIdx > certIdx)
assert.match(h5Detail, /AttachmentUploadField|CertAttachments/)
assert.match(h5Detail, /attachmentUrls/)
})
+1 -1
View File
@@ -645,7 +645,7 @@ test('referenced bindings and references cannot be edited or removed', async ()
s.removeReference(0)
s.addReference()
assert.equal(s.form.value.purchaseRefs.length, 1)
assert.ok(h.messages.some((m) => m.includes('不能新增或移除证面引用')))
assert.ok(h.messages.some((m) => m.includes('不能新增或移除关联购买证')))
assert.equal(s.referenceRowLocked(s.form.value.purchaseRefs[0]), false)
} finally {
h.stop()
@@ -17,3 +17,20 @@ test('unmarked plans still block on the server message or the local certificate
assert.equal(toxicArrangeBlockMessage({ arrangeBlockMessage: '' }, unbound), unbound)
assert.equal(toxicArrangeBlockMessage(null, unbound), unbound)
})
test('customer_category plans trust the server arrange check and ignore local contract-binding gaps', () => {
assert.equal(
toxicArrangeBlockMessage(
{ certQuotaRuleVersion: 'customer_category', arrangeBlockMessage: '' },
unbound
),
''
)
assert.equal(
toxicArrangeBlockMessage(
{ certQuotaRuleVersion: 'customer_category', arrangeBlockMessage: '证件余量不足' },
unbound
),
'证件余量不足'
)
})
+61 -66
View File
@@ -105,6 +105,9 @@ function harness(fixed?: string) {
useServerColumnFilters,
createStatisticsLoader,
quotaStatisticsApi: api,
certQuotaRuleApi: {
get: async () => ({ configured: false })
},
onBeforeUnmount: (fn: Function) => unmount.push(fn)
}
const src = ts.createSourceFile(
@@ -154,16 +157,21 @@ test('cert tabs keep their identities and legal proof is a sibling menu', () =>
assert.equal(legal.meta!.title, '合法使用证明')
assert.deepEqual(
group.children!.filter((r) => !r.meta?.isHide).map((r) => r.path),
['quota-cert', 'contract-quota', 'certificate-usage', 'plan-cert', 'cert-ready']
['quota-cert', 'cert-category', 'contract-quota', 'certificate-usage', 'plan-cert', 'cert-ready']
)
assert.equal(group.children![0].id, 'PL_QUOTA_CERT')
assert.equal(group.children![3].id, 'PL00000000000000000000000000015')
assert.equal(group.children!.find((r) => r.path === 'contract-quota')?.meta?.title, '客户用证')
assert.equal(
group.children!.find((r) => r.path === 'plan-cert')?.id,
'PL00000000000000000000000000015'
)
assert.deepEqual(
group.children!.filter((r) => !r.meta?.isHide).map((r) => r.meta!.menuCode),
[
'pl:quota-cert:list',
'pl:quota-cert:list',
'pl:quota-cert:list',
'pl:quota-cert:list',
'pl:plan-cert:list',
'pl:cert-ready:list'
]
@@ -648,71 +656,58 @@ function compiledSfc(path: string, dependencies: Record<string, any>, inlineTemp
)
return new Function(...Object.keys(env), `${code}; return component`)(...Object.values(env))
}
for (const wrapper of [
'contract-quota/ContractQuotaPanel.vue',
'certificate-usage/CertificateUsagePanel.vue'
]) {
for (const active of [undefined, false])
test(`compiled ${wrapper} ${active === false ? 'inactive skips requests' : 'defaults to active and loads'}`, async () => {
const calls: string[] = []
const dependencies = {
useUserStore: () => vue.reactive({ accessToken: 'token', getUserInfo: { tenantNo: 'a' } }),
useRouter: () => ({ push() {}, replace() {} }),
fetchGetMenuCodes: async () => {
calls.push('permissions')
return { all: true }
},
quotaStatisticsApi: {
contracts: async () => {
calls.push('contracts')
return { list: [], total: 0 }
},
certificates: async () => {
calls.push('certificates')
return { list: [], total: 0 }
}
},
usePersistedListColumns,
useServerColumnFilters,
createStatisticsLoader,
useElementSize: () => ({ width: vue.ref(1120) }),
useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }),
statusLabel: statisticsLabels.statusLabel,
contractTypeLabel: statisticsLabels.contractTypeLabel
}
// Compile the real inner setup too; only its visual child tree is omitted here.
const StatisticsPanel = compiledSfc(
'../src/views/pl/quota-statistics/StatisticsPanel.vue',
dependencies,
false
)
StatisticsPanel.render = () => null
const Wrapper = compiledSfc(`../src/views/pl/${wrapper}`, { StatisticsPanel })
const renderer = vue.createRenderer<any, any>({
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
setText: () => {},
setElementText: () => {},
parentNode: () => null,
nextSibling: () => null,
insert: () => {},
remove: () => {},
patchProp: () => {}
})
const app = renderer.createApp(Wrapper, active === undefined ? {} : { active })
app.mount({})
await flush()
await vue.nextTick()
assert.deepEqual(
calls,
active === false
? []
: ['permissions', wrapper.startsWith('contract') ? 'contracts' : 'certificates']
)
app.unmount()
for (const active of [undefined, false])
test(`compiled certificate-usage/CertificateUsagePanel.vue ${active === false ? 'inactive skips requests' : 'defaults to active and loads'}`, async () => {
const calls: string[] = []
const dependencies = {
useUserStore: () => vue.reactive({ accessToken: 'token', getUserInfo: { tenantNo: 'a' } }),
useRouter: () => ({ push() {}, replace() {} }),
fetchGetMenuCodes: async () => {
calls.push('permissions')
return { all: true }
},
quotaStatisticsApi: {
certificates: async () => {
calls.push('certificates')
return { list: [], total: 0 }
}
},
usePersistedListColumns,
useServerColumnFilters,
createStatisticsLoader,
useElementSize: () => ({ width: vue.ref(1120) }),
useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }),
statusLabel: statisticsLabels.statusLabel,
contractTypeLabel: statisticsLabels.contractTypeLabel
}
const StatisticsPanel = compiledSfc(
'../src/views/pl/quota-statistics/StatisticsPanel.vue',
dependencies,
false
)
StatisticsPanel.render = () => null
const Wrapper = compiledSfc('../src/views/pl/certificate-usage/CertificateUsagePanel.vue', {
StatisticsPanel
})
}
const renderer = vue.createRenderer<any, any>({
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
setText: () => {},
setElementText: () => {},
parentNode: () => null,
nextSibling: () => null,
insert: () => {},
remove: () => {},
patchProp: () => {}
})
const app = renderer.createApp(Wrapper, active === undefined ? {} : { active })
app.mount({})
await flush()
await vue.nextTick()
assert.deepEqual(calls, active === false ? [] : ['permissions', 'certificates'])
app.unmount()
})
test('expanded content follows the table viewport width and keeps alignment local', () => {
const h = harness()
+1 -1
View File
@@ -111,7 +111,7 @@
const panelTitleClassList = [...panelTitleIconByText.map(([, cls]) => cls), 'auto-title-list']
const panelTitleBarClass = 'title-bar-heading'
const panelTitleBarTextRules = [/^(购买证与运输证|合法使用证明|计划用证|证件建档|合同用证|证件用量|计划段次用证|证书齐备|合同证件与额度)$/]
const panelTitleBarTextRules = [/^(购买证与运输证|合法使用证明|计划用证|证件建档|证用品类|合同用证|客户用证|证件用量|计划段次用证|证书齐备|合同证件与额度|客户证件与额度|运输证件规则)$/]
const panelTitleHeadingSelector =
'.page-container .panel-top h2, .page-container .panel-top h3, .page-container .storage-content-header h3, .page-container .storage-aside__head h2, .page-container .user-dept-aside__head h2, .console-container .panel-top h2'
+7
View File
@@ -624,6 +624,13 @@ export interface Plan {
hazardCertReady?: number
hazardCertReadyBy?: string
hazardCertReadyTime?: string
/** 证书齐备:新规则仅放行安排 */
quotaCertWaived?: number
quotaCertWaivedBy?: string
quotaCertWaivedByName?: string
quotaCertWaivedTime?: string
/** 新规则证书齐备待完善 */
quotaCertPendingFulfill?: number
erpEntered?: number
remark?: string
createBy?: string
+82 -3
View File
@@ -44,6 +44,8 @@ export interface CertBody {
routeText?: string | null
consigneeName?: string | null
applicantName?: string | null
customerId?: string | null
categoryId?: string | null
contractBindings?: CertificateContractBinding[]
purchaseRefs?: PurchaseReference[]
}
@@ -59,8 +61,11 @@ export interface QuotaCert extends Omit<CertBody, 'status'> {
verifiedTime?: string | null
openingConfirmed: boolean
usagePolicy: 'once' | 'multiple' | 'needs_confirmation'
singleUseConsumed?: boolean
createTime?: string
certType?: CertType
derivedCategoryIds?: string[]
customerName?: string | null
}
export interface ScopeProduct {
id?: string
@@ -128,8 +133,10 @@ export interface LegalDocument {
}
const base = '/transport/quota-certs'
export const quotaCertApi = {
list: (kind: CertType, params: { keyword?: string; status?: string } = {}) =>
request.get(`${base}/${kind}`, { params }).then(pickBxData<QuotaCert[]>),
list: (
kind: CertType,
params: { keyword?: string; status?: string; customerId?: string } = {}
) => request.get(`${base}/${kind}`, { params }).then(pickBxData<QuotaCert[]>),
get: (kind: CertType, id: string) =>
request.get(`${base}/${kind}/${id}`).then(pickBxData<QuotaCert>),
save: (kind: CertType, body: CertBody, id?: string) =>
@@ -327,6 +334,7 @@ export interface PlanQuota {
canSaveConfiguration?: boolean
arrangeBlockMessage?: string
quotaCertWaived?: boolean
certQuotaRuleVersion?: 'legacy' | 'customer_category' | string
configuration: Partial<QuotaConfiguration>
sourceOptions: QuotaSourceOption[]
usages: Record<string, any>[]
@@ -393,7 +401,55 @@ 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
productNo?: string | null
productName?: string | null
model?: string | null
}>
}
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 = {
@@ -451,6 +507,13 @@ export interface StatisticsPage<T> {
export interface ContractStatisticsSummary extends ContractOption {
certCount: number
}
export interface CustomerStatisticsSummary {
id: string
customerId: string
customerNo: string
customerName: string
certCount: number
}
export interface CertificateStatisticsSummary {
id: string
certId: string
@@ -469,6 +532,14 @@ export interface CertificateStatisticsSummary {
buyPartyName?: string | null
validUseText?: string | null
}
export interface CustomerStatistics extends CustomerStatisticsSummary {
certificates: (CertificateStatisticsSummary & {
usagePolicy?: QuotaCert['usagePolicy']
singleUseConsumed?: boolean
openingConfirmed?: boolean
createTime?: string
})[]
}
export interface StatisticsProduct {
id: string
contractProductId: string
@@ -526,6 +597,8 @@ export interface StatisticsQuery {
contractNo?: string
contractName?: string
contractTypeIn?: string
customerNo?: string
customerName?: string
certCountOp?: string
certCount?: string
certCountMin?: string
@@ -569,12 +642,18 @@ export const quotaStatisticsApi = {
request
.get(`${base}/statistics/contracts`, { params })
.then(pickBxData<StatisticsPage<ContractStatisticsSummary>>),
customers: (params: StatisticsQuery) =>
request
.get(`${base}/statistics/customers`, { params })
.then(pickBxData<StatisticsPage<CustomerStatisticsSummary>>),
certificates: (params: StatisticsQuery) =>
request
.get(`${base}/statistics/certificates`, { params })
.then(pickBxData<StatisticsPage<CertificateStatisticsSummary>>),
contract: (id: string) =>
request.get(`${base}/contracts/${id}/statistics`).then(pickBxData<ContractStatistics>),
customer: (id: string) =>
request.get(`${base}/customers/${id}/statistics`).then(pickBxData<CustomerStatistics>),
certificate: (kind: CertType, id: string) =>
request.get(`${base}/${kind}/${id}/statistics`).then(pickBxData<CertificateStatistics>)
}
-13
View File
@@ -1900,19 +1900,6 @@ body {
}
}
.list-table.is-excel-copy {
th.is-excel-col,
td.is-excel-rowhead {
background: var(--mint-primary-bg) !important;
color: var(--mint-primary-dark);
}
td.is-excel-cell {
background: rgba(16, 185, 129, 0.16) !important;
box-shadow: inset 0 0 0 1px var(--mint-primary-dark);
}
}
/* 筛选区 label:13px 次要色(对齐 zkxw filter-form 实践;filter-hint 仍为 11px) */
.page-container .filter-block .el-form-item__label,
.console-container .filter-block .el-form-item__label,
@@ -0,0 +1,370 @@
<!-- 列表 Excel 只读查看:Univer OSS,可筛选/复制,不可编辑或写回 -->
<template>
<el-dialog
v-model="innerVisible"
title="Excel 查看"
width="96%"
top="2vh"
class="dialog-form-layout list-excel-viewer-dialog"
align-center
destroy-on-close
append-to-body
:close-on-click-modal="false"
style="backdrop-filter: none"
@opened="onOpened"
@closed="onClosed"
>
<div v-loading="booting" class="list-excel-viewer-body">
<p class="list-excel-viewer-hint">只读模式:可筛选、复制;不可编辑或写回系统。</p>
<div ref="containerRef" class="list-excel-viewer-host" />
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, shallowRef } from 'vue'
import { ElMessage } from 'element-plus'
import type { ListExcelSnapshot } from '@/utils/list-excel-copy'
import { excelCell, snapshotToUniverCellData } from '@/utils/list-excel-copy'
defineOptions({ name: 'ListExcelViewerDialog' })
const props = defineProps<{
modelValue: boolean
snapshot: ListExcelSnapshot | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const innerVisible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const containerRef = ref<HTMLElement | null>(null)
const booting = ref(false)
const univerRef = shallowRef<{ dispose: () => void } | null>(null)
const univerApiRef = shallowRef<UniverAPI | null>(null)
type UniverAPI = {
createWorkbook: (data: Record<string, unknown>) => unknown
getActiveWorkbook: () => any
Enum: {
WorkbookPermissionPoint: Record<string, string>
WorksheetPermissionPoint: Record<string, string>
}
}
function disposeUniver() {
unbindCopyHotkey()
try {
univerRef.value?.dispose()
} catch {
// ignore dispose race
}
univerRef.value = null
univerApiRef.value = null
}
function selectionToTsv(univerAPI: UniverAPI): string {
const workbook = univerAPI.getActiveWorkbook?.()
const range = workbook?.getActiveRange?.() || workbook?.getActiveSheet?.()?.getActiveRange?.()
if (!range) return ''
const values: unknown[][] =
range.getDisplayValues?.() || range.getValues?.() || range.getRawValues?.() || []
if (!Array.isArray(values) || !values.length) {
const single = range.getDisplayValue?.() ?? range.getValue?.()
if (single == null || single === '') return ''
return excelCell(String(single))
}
return values
.map((row) =>
(Array.isArray(row) ? row : [row])
.map((cell) => excelCell(cell == null ? '' : String(cell)))
.join('\t')
)
.join('\r\n')
}
async function copySelectionFallback(univerAPI: UniverAPI) {
const text = selectionToTsv(univerAPI)
if (!text) return false
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制,到 Excel 中直接粘贴')
return true
} catch {
const area = document.createElement('textarea')
area.value = text
area.setAttribute('readonly', 'true')
area.style.position = 'fixed'
area.style.left = '-9999px'
document.body.appendChild(area)
area.select()
const ok = document.execCommand('copy')
area.remove()
if (ok) {
ElMessage.success('已复制,到 Excel 中直接粘贴')
return true
}
ElMessage.warning('复制失败,请允许剪贴板权限后重试')
return false
}
}
function isTextEntryTarget(target: EventTarget | null): boolean {
if (target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) return true
if (target instanceof HTMLInputElement) {
const type = (target.getAttribute('type') || 'text').toLowerCase()
return type !== 'checkbox' && type !== 'radio'
}
return target instanceof HTMLElement && target.isContentEditable
}
function onCopyHotkey(event: KeyboardEvent) {
if (!innerVisible.value || !univerApiRef.value) return
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'c') return
if (isTextEntryTarget(event.target)) return
const host = containerRef.value
const target = event.target as Node | null
// 弹层内任意位置(含 Univer canvas)都接住复制
const dialog = host?.closest('.el-dialog')
if (dialog && target && !dialog.contains(target) && target !== document.body) return
event.preventDefault()
event.stopPropagation()
void copySelectionFallback(univerApiRef.value)
}
function bindCopyHotkey() {
document.addEventListener('keydown', onCopyHotkey, true)
}
function unbindCopyHotkey() {
document.removeEventListener('keydown', onCopyHotkey, true)
}
/**
* 工作表 setPoint 必须先 protect(),否则会抛
* "Cannot set … because worksheet protection does not exist"。
* filterOnly 默认关掉 WorksheetCopy,必须再显式打开,否则 Ctrl/Cmd+C 无效。
*/
async function applyReadonlyPermissions(univerAPI: UniverAPI) {
const workbook = univerAPI.getActiveWorkbook()
if (!workbook) return
// 本地关闭编辑(不依赖 Authz / protect)
if (typeof workbook.setEditable === 'function') {
workbook.setEditable(false)
}
try {
const workbookPermission = workbook.getWorkbookPermission?.()
if (workbookPermission?.setMode) {
await workbookPermission.setMode('viewer')
}
const copyPoint = univerAPI.Enum?.WorkbookPermissionPoint?.CopyContent
if (workbookPermission?.setPoint && copyPoint) {
await workbookPermission.setPoint(copyPoint, true)
}
} catch (error) {
console.warn('[ListExcelViewer] workbook permission skipped', error)
}
const sheet = workbook.getActiveSheet?.()
if (!sheet?.getWorksheetPermission) return
try {
const sheetPermission = sheet.getWorksheetPermission()
// filterOnly = View + Sort + Filter;须先 protect
if (!sheetPermission.isProtected?.()) {
await sheetPermission.protect?.({ name: 'list-excel-readonly' })
}
if (sheetPermission.setMode) {
await sheetPermission.setMode('filterOnly')
}
const sheetCopy = univerAPI.Enum?.WorksheetPermissionPoint?.Copy
if (sheetPermission.setPoint && sheetCopy) {
await sheetPermission.setPoint(sheetCopy, true)
}
} catch (error) {
console.warn('[ListExcelViewer] worksheet filterOnly skipped', error)
}
}
function mountFilter(univerAPI: UniverAPI, snapshot: ListExcelSnapshot) {
const sheet = univerAPI.getActiveWorkbook()?.getActiveSheet?.()
if (!sheet?.getRange) return
const colCount = Math.max(snapshot.headers.length, 1)
const rowCount = Math.max(snapshot.rows.length + 1, 1)
const range = sheet.getRange(0, 0, rowCount, colCount)
try {
sheet.getFilter?.()?.remove?.()
} catch {
// no existing filter
}
range.createFilter?.()
}
async function loadUniverModules() {
const [
{ UniverSheetsCorePreset },
coreZhMod,
{ UniverSheetsFilterPreset },
filterZhMod,
{ createUniver, LocaleType, mergeLocales }
] = await Promise.all([
import('@univerjs/preset-sheets-core'),
import('@univerjs/preset-sheets-core/locales/zh-CN'),
import('@univerjs/preset-sheets-filter'),
import('@univerjs/preset-sheets-filter/locales/zh-CN'),
import('@univerjs/presets'),
import('@univerjs/preset-sheets-core/lib/index.css'),
import('@univerjs/preset-sheets-filter/lib/index.css')
])
return {
UniverSheetsCorePreset,
UniverPresetSheetsCoreZhCN: coreZhMod.default,
UniverSheetsFilterPreset,
UniverPresetSheetsFilterZhCN: filterZhMod.default,
createUniver,
LocaleType,
mergeLocales
}
}
async function bootUniver() {
disposeUniver()
const snapshot = props.snapshot
const container = containerRef.value
if (!snapshot || !container) return
if (!snapshot.headers.length) {
ElMessage.warning('当前列表没有可查看的数据列')
return
}
booting.value = true
try {
const {
UniverSheetsCorePreset,
UniverPresetSheetsCoreZhCN,
UniverSheetsFilterPreset,
UniverPresetSheetsFilterZhCN,
createUniver,
LocaleType,
mergeLocales
} = await loadUniverModules()
const cellData = snapshotToUniverCellData(snapshot)
const colCount = snapshot.headers.length
const rowCount = snapshot.rows.length + 1
const sheetId = 'sheet1'
const { univer, univerAPI } = createUniver({
locale: LocaleType.ZH_CN,
locales: {
[LocaleType.ZH_CN]: mergeLocales(UniverPresetSheetsCoreZhCN, UniverPresetSheetsFilterZhCN)
},
presets: [
UniverSheetsCorePreset({
container,
toolbar: true,
formulaBar: false,
footer: false,
header: true,
contextMenu: true
}),
UniverSheetsFilterPreset()
]
})
univerRef.value = univer
const api = univerAPI as unknown as UniverAPI
univerApiRef.value = api
api.createWorkbook({
id: 'list-excel-viewer',
name: '列表数据',
sheetOrder: [sheetId],
sheets: {
[sheetId]: {
id: sheetId,
name: '当前页',
rowCount: Math.max(rowCount + 20, 40),
columnCount: Math.max(colCount + 5, 10),
cellData
}
}
})
// 权限/筛选失败不应让整页判定为加载失败(表格数据已就绪)
try {
await applyReadonlyPermissions(api)
} catch (error) {
console.warn('[ListExcelViewer] readonly setup skipped', error)
}
try {
mountFilter(api, snapshot)
} catch (error) {
console.warn('[ListExcelViewer] filter setup skipped', error)
}
bindCopyHotkey()
} catch (error) {
console.error(error)
ElMessage.error('Excel 查看加载失败,请稍后重试')
disposeUniver()
} finally {
booting.value = false
}
}
async function onOpened() {
await nextTick()
await bootUniver()
}
function onClosed() {
disposeUniver()
}
onBeforeUnmount(() => {
disposeUniver()
})
</script>
<style scoped lang="scss">
.list-excel-viewer-body {
display: flex;
flex-direction: column;
height: calc(100vh - 160px);
min-height: 420px;
gap: 8px;
}
.list-excel-viewer-hint {
margin: 0;
font-size: 12px;
line-height: 1.4;
color: var(--mint-text-muted);
}
.list-excel-viewer-host {
flex: 1;
min-height: 0;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: 8px;
overflow: hidden;
}
</style>
<style lang="scss">
.list-excel-viewer-dialog.el-dialog {
max-height: calc(100vh - 40px);
margin-bottom: 0;
.el-dialog__body {
padding-top: 8px;
padding-bottom: 16px;
}
}
</style>
@@ -1,6 +1,7 @@
<!--
主列表薄封装:在 el-table 上启用列宽拖拽,并按 table-key 持久化到 localStorage。
用法:将 <el-table class="list-table" …> 换成 <ListTable table-key="crm-customer" class="list-table" …>
Excel 查看:工具栏打开只读 Univer 弹层,本组件提供 getExcelSnapshot()。
-->
<template>
<el-table
@@ -29,15 +30,11 @@
useAttrs,
watch
} from 'vue'
import { ElMessage } from 'element-plus'
import {
buildExcelTsv,
excelCell,
buildExcelSnapshot,
isCopyableColumn,
isRowSelectorColumn,
resolveCopyAxes,
toggleAxis,
type ListExcelColumn
type ListExcelColumn,
type ListExcelSnapshot
} from '@/utils/list-excel-copy'
import {
isFixedNarrowListColumnKey,
@@ -55,7 +52,7 @@
persistColumnWidth?: boolean
/** 是否开启 border(列宽拖拽依赖);默认 true */
border?: boolean
/** 是否允许 Excel 行列复制,默认 true */
/** 是否允许 Excel 只读查看,默认 true */
excelCopy?: boolean
}>(),
{
@@ -71,17 +68,11 @@
computed(() => props.tableKey)
)
const excelMode = ref(false)
const selectedCols = ref(new Set<number>())
const selectedRows = ref(new Set<number>())
let colAnchor: number | null = null
let rowAnchor: number | null = null
const resolvedBorder = computed(() => props.border !== false)
const tableClass = computed(() => {
const extra = attrs.class
return ['list-table', { 'is-excel-copy': excelMode.value }, extra]
return ['list-table', extra]
})
const tableAttrs = computed(() => {
@@ -157,14 +148,6 @@
}))
}
function copyableColumns(): ListExcelColumn[] {
return readColumns().filter((col) => col.id && isCopyableColumn(col))
}
function indexColumn(): ListExcelColumn | undefined {
return readColumns().find((col) => col.id && isRowSelectorColumn(col))
}
function tableRoot(): HTMLElement | null {
const el = tableRef.value?.$el as HTMLElement | undefined
return el ?? null
@@ -176,254 +159,45 @@
return Array.from(root.querySelectorAll('.el-table__body-wrapper tbody > tr.el-table__row'))
}
function columnFromCell(cell: HTMLElement): ListExcelColumn | undefined {
/** 当前可见列 + 当前页行文本,供 Excel 只读弹层使用 */
function getExcelSnapshot(): ListExcelSnapshot {
if (!props.excelCopy) return { headers: [], rows: [] }
const columns = readColumns()
const matched = columns.find((col) => col.id && cell.classList.contains(col.id))
if (matched) return matched
const row = cell.parentElement
if (!row) return undefined
const cells = Array.from(row.children).filter(
(el): el is HTMLTableCellElement => el instanceof HTMLTableCellElement
const copyable = columns.filter((col) => col.id && isCopyableColumn(col))
const rows = bodyRows()
const rowValues = rows.map((row) =>
copyable.map((col) => {
const td = row.querySelector(`td.${CSS.escape(col.id)}`)
return td?.textContent ?? ''
})
)
const visualIndex = cells.indexOf(cell as HTMLTableCellElement)
return visualIndex >= 0 ? columns[visualIndex] : undefined
// rowValues 已按可复制列对齐,传入过滤后的 columns
return buildExcelSnapshot({ columns: copyable, rowValues })
}
function paintExcelSelection() {
function bindSnapshotApi() {
const root = tableRoot()
if (!root) return
root.querySelectorAll('.is-excel-col, .is-excel-rowhead, .is-excel-cell').forEach((el) => {
el.classList.remove('is-excel-col', 'is-excel-rowhead', 'is-excel-cell')
})
if (!excelMode.value) return
const columns = copyableColumns()
const rows = bodyRows()
const axes = resolveCopyAxes({
selectedCols: [...selectedCols.value],
selectedRows: [...selectedRows.value],
columnCount: columns.length,
rowCount: rows.length
})
const indexCol = indexColumn()
selectedCols.value.forEach((index) => {
const col = columns[index]
if (!col) return
root.querySelectorAll(`th.${CSS.escape(col.id)}`).forEach((el) => {
el.classList.add('is-excel-col')
})
})
const bodies = root.querySelectorAll(
'.el-table__body-wrapper tbody, .el-table__fixed-body-wrapper tbody'
)
if (indexCol) {
selectedRows.value.forEach((index) => {
bodies.forEach((tbody) => {
const row = tbody.querySelectorAll(':scope > tr.el-table__row')[index]
row?.querySelector(`td.${CSS.escape(indexCol.id)}`)?.classList.add('is-excel-rowhead')
})
})
}
if (!axes) return
axes.rows.forEach((rowIndex) => {
axes.cols.forEach((colIndex) => {
const col = columns[colIndex]
if (!col) return
bodies.forEach((tbody) => {
const row = tbody.querySelectorAll(':scope > tr.el-table__row')[rowIndex]
row?.querySelector(`td.${CSS.escape(col.id)}`)?.classList.add('is-excel-cell')
})
})
})
;(root as HTMLElement & { __sxGetExcelSnapshot?: () => ListExcelSnapshot }).__sxGetExcelSnapshot =
getExcelSnapshot
}
function clearExcelSelection() {
selectedCols.value = new Set()
selectedRows.value = new Set()
colAnchor = null
rowAnchor = null
paintExcelSelection()
}
function setExcelMode(next: boolean) {
// excelCopy 已关闭时拒绝进入;仍允许关掉,否则 prop 变 false 后无法退出。
if (!props.excelCopy && next) return
excelMode.value = next
if (!next) clearExcelSelection()
else paintExcelSelection()
tableRoot()?.dispatchEvent(
new CustomEvent('sx-list-excel-state', {
bubbles: true,
detail: { active: excelMode.value }
})
)
}
function clickedRowIndex(row: HTMLTableRowElement): number {
const rows = bodyRows()
const direct = rows.indexOf(row)
if (direct >= 0) return direct
const tbody = row.parentElement
if (!tbody) return -1
const index = Array.from(tbody.querySelectorAll(':scope > tr.el-table__row')).indexOf(row)
if (index < 0 || index >= rows.length) return -1
return index
}
function keepCopyFocus() {
function unbindSnapshotApi() {
const root = tableRoot()
if (!root) return
const panel = root.closest('.panel')
const active = document.activeElement
if (panel && active instanceof Node && panel.contains(active)) return
if (!root.hasAttribute('tabindex')) root.setAttribute('tabindex', '-1')
root.focus({ preventScroll: true })
}
function onTableClick(event: MouseEvent) {
if (!excelMode.value || !props.excelCopy) return
const target = event.target as HTMLElement | null
if (!target) return
const header = target.closest('th')
const cell = target.closest('td')
if (header) {
event.preventDefault()
event.stopPropagation()
const columns = copyableColumns()
const index = columns.findIndex((col) => header.classList.contains(col.id))
if (index < 0) {
keepCopyFocus()
return
}
const result = toggleAxis(selectedCols.value, index, event.shiftKey, colAnchor)
selectedCols.value = result.selected
colAnchor = result.anchor
paintExcelSelection()
keepCopyFocus()
return
}
if (!cell) return
event.preventDefault()
event.stopPropagation()
keepCopyFocus()
const row = cell.closest('tr')
const column = columnFromCell(cell)
// 序号格和数据格都选整行;勾选列、展开列、操作列不选行。
if (!row || !column || !(isRowSelectorColumn(column) || isCopyableColumn(column))) return
const rowIndex = clickedRowIndex(row)
if (rowIndex < 0) return
const result = toggleAxis(selectedRows.value, rowIndex, event.shiftKey, rowAnchor)
selectedRows.value = result.selected
rowAnchor = result.anchor
paintExcelSelection()
}
function onTableDblClick(event: MouseEvent) {
if (!excelMode.value || !props.excelCopy) return
const target = event.target as HTMLElement | null
if (!target?.closest('td, th')) return
event.preventDefault()
event.stopPropagation()
}
function currentTsv(): string {
const columns = copyableColumns()
const rows = bodyRows()
const axes = resolveCopyAxes({
selectedCols: [...selectedCols.value],
selectedRows: [...selectedRows.value],
columnCount: columns.length,
rowCount: rows.length
})
if (!axes) return ''
const headers = axes.cols.map((index) => columns[index]?.label ?? '')
const matrix = axes.rows.map((rowIndex) =>
axes.cols.map((colIndex) => {
const col = columns[colIndex]
const td = col ? rows[rowIndex]?.querySelector(`td.${CSS.escape(col.id)}`) : null
return excelCell(td?.textContent ?? '')
})
)
return buildExcelTsv(headers, matrix)
}
async function copyExcelSelection() {
const text = currentTsv()
if (!text) return
try {
await navigator.clipboard.write([
new ClipboardItem({ 'text/plain': new Blob([text], { type: 'text/plain' }) })
])
ElMessage.success('已复制,到 Excel 中直接粘贴')
} catch {
const area = document.createElement('textarea')
area.value = text
area.setAttribute('readonly', 'true')
area.style.position = 'fixed'
area.style.left = '-9999px'
document.body.appendChild(area)
area.select()
const ok = document.execCommand('copy')
area.remove()
if (ok) ElMessage.success('已复制,到 Excel 中直接粘贴')
else ElMessage.warning('复制失败,请允许剪贴板权限后重试')
}
}
function isTextEntryTarget(target: EventTarget | null): boolean {
if (target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) return true
if (target instanceof HTMLInputElement) {
const type = (target.getAttribute('type') || 'text').toLowerCase()
return type !== 'checkbox' && type !== 'radio'
}
return target instanceof HTMLElement && target.isContentEditable
}
function onKeydown(event: KeyboardEvent) {
if (!excelMode.value || !props.excelCopy) return
if (isTextEntryTarget(event.target)) return
const root = tableRoot()
const panel = root?.closest('.panel')
const target = event.target as Node | null
if (panel && target && !panel.contains(target)) return
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'c') {
if (!currentTsv()) return
event.preventDefault()
void copyExcelSelection()
}
if (event.key === 'Escape') clearExcelSelection()
}
function onExcelToggle() {
setExcelMode(!excelMode.value)
}
function bindExcelEvents() {
const root = tableRoot()
root?.addEventListener('click', onTableClick, true)
root?.addEventListener('dblclick', onTableDblClick, true)
root?.addEventListener('sx-list-excel-toggle', onExcelToggle)
document.addEventListener('keydown', onKeydown)
}
function unbindExcelEvents() {
const root = tableRoot()
root?.removeEventListener('click', onTableClick, true)
root?.removeEventListener('dblclick', onTableDblClick, true)
root?.removeEventListener('sx-list-excel-toggle', onExcelToggle)
document.removeEventListener('keydown', onKeydown)
delete (root as HTMLElement & { __sxGetExcelSnapshot?: () => ListExcelSnapshot })
.__sxGetExcelSnapshot
}
onMounted(() => {
scheduleApply()
// 首屏列尚未完全注册时再补一次
mountApplyTimer = setTimeout(() => scheduleApply(), 100)
bindExcelEvents()
bindSnapshotApi()
})
onBeforeUnmount(() => {
unbindExcelEvents()
unbindSnapshotApi()
clearApplyTimers()
})
onDeactivated(() => {
@@ -432,13 +206,13 @@
onActivated(() => {
scheduleApply()
bindSnapshotApi()
})
watch(
() => (attrs as any).data,
() => {
scheduleApply()
if (excelMode.value) clearExcelSelection()
},
{ deep: false }
)
@@ -448,7 +222,6 @@
() => scheduleApply()
)
// 列显隐导致列数量变化时写回(避免在 drag 过程中用 onUpdated 反复 apply)
watch(
() => {
const cols = tableRef.value?.store?.states?.columns
@@ -456,24 +229,6 @@
},
() => {
scheduleApply()
if (excelMode.value) clearExcelSelection()
}
)
watch(
() =>
readColumns()
.map((col) => col.id)
.join('|'),
() => {
if (excelMode.value) clearExcelSelection()
}
)
watch(
() => props.excelCopy,
(enabled) => {
if (!enabled) setExcelMode(false)
}
)
@@ -495,13 +250,19 @@
resetWidths()
}
}
if (prop === 'getExcelSnapshot') return getExcelSnapshot
const table = tableRef.value
if (!table) return undefined
const value = table[prop as keyof typeof table]
return typeof value === 'function' ? value.bind(table) : value
},
has(_target, prop) {
if (prop === 'elTableRef' || prop === 'applyColumnWidths' || prop === 'resetColumnWidths') {
if (
prop === 'elTableRef' ||
prop === 'applyColumnWidths' ||
prop === 'resetColumnWidths' ||
prop === 'getExcelSnapshot'
) {
return true
}
const table = tableRef.value
@@ -1,17 +1,11 @@
<!-- 列表工具栏右侧:刷新 + 列显隐(勾选持久化由 usePersistedListColumns 负责) -->
<!-- 列表工具栏右侧:Excel 查看 + 刷新 + 列显隐(勾选持久化由 usePersistedListColumns 负责) -->
<template>
<div ref="rootEl" class="list-toolbar-end">
<el-tooltip
v-if="showExcelCopy"
:content="excelActive ? '退出 Excel 复制' : 'Excel 复制'"
placement="top"
>
<el-tooltip v-if="showExcelCopy" content="Excel 查看" placement="top">
<el-button
class="list-toolbar-end-btn"
:class="{ 'is-excel-active': excelActive }"
:aria-pressed="excelActive"
aria-label="Excel 复制"
@click="toggleExcelCopy"
aria-label="Excel 查看"
@click="openExcelViewer"
>
<svg class="list-excel-icon" viewBox="0 0 16 16" aria-hidden="true">
<rect
@@ -89,13 +83,25 @@
</el-scrollbar>
</div>
</el-popover>
<ListExcelViewerDialog
v-if="excelSnapshot"
v-model="excelViewerVisible"
:snapshot="excelSnapshot"
/>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, defineAsyncComponent, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh, Setting } from '@element-plus/icons-vue'
import type { ListColumnDef } from '@/composables/usePersistedListColumns'
import type { ListExcelSnapshot } from '@/utils/list-excel-copy'
const ListExcelViewerDialog = defineAsyncComponent(
() => import('@/components/business/list-excel-viewer-dialog/index.vue')
)
defineOptions({ name: 'ListToolbarEndActions' })
@@ -154,46 +160,28 @@
emit('resetColumns')
}
const excelActive = ref(false)
const rootEl = ref<HTMLElement | null>(null)
let panelEl: HTMLElement | null = null
const excelViewerVisible = ref(false)
const excelSnapshot = ref<ListExcelSnapshot | null>(null)
function controlledTable(): HTMLElement | null {
type ExcelTableEl = HTMLElement & { __sxGetExcelSnapshot?: () => ListExcelSnapshot }
function controlledTable(): ExcelTableEl | null {
const panel = rootEl.value?.closest('.panel')
const table = panel?.querySelector('[data-list-excel-copy="on"]')
return table instanceof HTMLElement ? table : null
return table instanceof HTMLElement ? (table as ExcelTableEl) : null
}
function toggleExcelCopy() {
controlledTable()?.dispatchEvent(new CustomEvent('sx-list-excel-toggle'))
}
function onExcelState(event: Event) {
const custom = event as CustomEvent<{ active?: boolean }>
if (event.target !== controlledTable()) return
excelActive.value = !!custom.detail?.active
}
function attachExcelStateListener() {
const panel = rootEl.value?.closest('.panel')
if (!(panel instanceof HTMLElement)) return false
panel.addEventListener('sx-list-excel-state', onExcelState, true)
panelEl = panel
return true
}
onMounted(() => {
if (!attachExcelStateListener()) {
nextTick(() => {
attachExcelStateListener()
})
function openExcelViewer() {
const table = controlledTable()
const snapshot = table?.__sxGetExcelSnapshot?.()
if (!snapshot || !snapshot.headers.length) {
ElMessage.warning('当前列表没有可查看的数据列')
return
}
})
onBeforeUnmount(() => {
panelEl?.removeEventListener('sx-list-excel-state', onExcelState, true)
panelEl = null
})
excelSnapshot.value = snapshot
excelViewerVisible.value = true
}
</script>
<style scoped lang="scss">
@@ -227,26 +215,6 @@
border: none !important;
box-shadow: none;
}
&.is-excel-active,
&.is-excel-active:hover,
&.is-excel-active:focus-visible,
&.is-excel-active:active {
color: #fff !important;
background: var(--mint-primary) !important;
border: none !important;
box-shadow:
0 0 0 2px #fff,
0 0 0 4px var(--mint-primary) !important;
}
&.is-excel-active:hover,
&.is-excel-active:focus-visible {
background: var(--mint-primary-dark) !important;
box-shadow:
0 0 0 2px #fff,
0 0 0 4px var(--mint-primary-dark) !important;
}
}
.list-excel-icon {
@@ -254,8 +222,4 @@
height: 16px;
display: block;
}
.is-excel-active .list-excel-icon rect {
fill: rgba(255, 255, 255, 0.22);
}
</style>
@@ -299,6 +299,7 @@
extractArchiveLineOverageDetail,
showArchiveLineOverageConfirm
} from '@/utils/pl-contract-qty-reminder'
import { resolveArchiveQuotaBlockMode } from '@/utils/pl-archive-quota-block'
const props = defineProps<{
visible: boolean
@@ -1602,8 +1603,13 @@
async function showArchiveQuotaBlockDialog(message: string, title = '不能归档') {
const html = `<p class="pl-arrange-block-text">${escapeArchiveBlockText(message)}</p>`
if (title !== '用证没有完成') {
await ElMessageBox.alert(html, title, {
const mode = resolveArchiveQuotaBlockMode(message, title, plan.value)
if (mode !== 'confirm-ready') {
const guide =
mode === 'alert-pending'
? `${html}<p class="pl-arrange-block-text">该运输计划已走证书齐备,归档前须完善证件。请到「证书齐备 → 已标记待完善证书」上传购买证/运输证并执行完善并关闭后再归档。</p>`
: html
await ElMessageBox.alert(guide, title, {
type: 'warning',
confirmButtonText: '知道了',
dangerouslyUseHTMLString: true,
@@ -1612,7 +1618,8 @@
}).catch(() => undefined)
return undefined
}
const readyHtml = `${html}<p class="pl-arrange-block-text">如果购买证、运输证还没上传,可以标记证书齐备。标记后这次归档不再校验,也不扣减这两证的额度。</p>`
// 仅旧规则(legacy)允许在归档时标记证书齐备以跳过额度
const readyHtml = `${html}<p class="pl-arrange-block-text">如果购买证、运输证还没上传,可以标记证书齐备。仅旧规则计划标记后这次归档不再校验、不扣这两证额度;新规则计划请走「证书齐备 → 已标记待完善证书」。</p>`
try {
await ElMessageBox.confirm(readyHtml, title, {
type: 'warning',
@@ -17,7 +17,14 @@
<span>当前车号</span>
<strong>{{ plan.licensePlate || '待安排' }}</strong>
</div>
<p v-if="viewOnly && plan.canSaveConfiguration === false" class="quota-note"
<p
v-if="viewOnly && plan.certQuotaRuleVersion === 'customer_category'"
class="quota-note"
>新规则计划由系统按客户+品类自动选证,本页仅查看占用;安排不提供手工选证。</p
>
<p
v-else-if="viewOnly && plan.canSaveConfiguration === false"
class="quota-note"
>已安排计划请从调整运输提交用证变更;已归档计划仅查看占用。</p
>
<template v-if="viewOnly">
@@ -98,7 +105,7 @@
<ViewKvTable :fields="group.fields" mode="dual" />
</section>
<section v-if="tab.kind === 'transport'" class="quota-face-group">
<h4>证面引用</h4>
<h4>关联购买证</h4>
<el-table
v-if="tab.cert.purchaseRefs?.length"
:data="tab.cert.purchaseRefs"
@@ -118,7 +125,7 @@
}}</template>
</el-table-column>
</el-table>
<p v-else class="quota-note">没有证面引用。</p>
<p v-else class="quota-note">没有关联购买证。</p>
</section>
<section v-if="tab.cert.attachmentUrls" class="quota-face-group">
<h4>证件附件</h4>
@@ -750,13 +757,33 @@
return !label || label === '—' ? '' : label
}
function boundCerts(row: QuotaSourceOption) {
return (
const fromStop =
plan.value?.stopProducts?.find(
(item) =>
item.sourceStopType === row.sourceStopType &&
item.sourceStopProductId === row.sourceStopProductId
)?.certificates || []
)
if (fromStop.length) return fromStop
const sourceId = `${row.sourceStopType}:${row.sourceStopProductId}`
const fromUsage: { certType: CertType; certId: string; certNo: string; quantityUnit: string }[] =
[]
for (const usage of plan.value?.usages || []) {
const kind = String(usage.certType || '') as CertType
if (kind !== 'purchase' && kind !== 'transport') continue
const items = Array.isArray(usage.items) ? usage.items : []
const hit = items.some((item: Record<string, unknown>) => {
const typed = `${String(item.sourceStopType || '')}:${String(item.sourceStopProductId || '')}`
return String(item.sourceId || '') === sourceId || typed === sourceId
})
if (!hit) continue
fromUsage.push({
certType: kind,
certId: String(usage.certId || ''),
certNo: String(usage.certNoSnapshot || ''),
quantityUnit: String(usage.quantityUnit || '')
})
}
return fromUsage
}
function certsOf(row: QuotaSourceOption, kind: CertType) {
return boundCerts(row).filter((cert) => cert.certType === kind)
@@ -234,8 +234,31 @@
</section>
<section class="pl-plan-form-section pl-plan-form-section--plain">
<h3 class="pl-plan-form-section-title">危化品证书信息</h3>
<PlPlanHazardCertViewTable :products="hazardProducts" />
<h3 class="pl-plan-form-section-title">关联购买证 / 运输证</h3>
<p v-if="plan?.quotaCertPendingFulfill" class="pl-plan-form-empty-hint">
证书齐备(待完善):请到「证书齐备 → 已标记待完善证书」挂证并完善并关闭
</p>
<div v-else-if="linkedCertRows.length" class="pl-plan-linked-certs">
<div v-for="item in linkedCertRows" :key="item.key" class="pl-plan-linked-cert-block">
<div class="pl-plan-linked-cert-block__meta">
<el-tag size="small" :type="item.kind === 'purchase' ? 'success' : 'warning'">
{{ item.kind === 'purchase' ? '购买证' : '运输证' }}
</el-tag>
<span class="pl-plan-linked-cert-block__no">{{ item.certNo || item.certId }}</span>
<span class="pl-plan-linked-cert-block__qty"
>预占 {{ item.reservedQty }} / 已用 {{ item.usedQty }}</span
>
</div>
<div class="pl-plan-linked-cert-block__slot">
<span class="pl-plan-linked-cert-block__label">证件附件</span>
<div v-if="item.attachmentUrls" class="pl-plan-linked-cert-block__attachment">
<AttachmentUploadField :model-value="item.attachmentUrls" disabled />
</div>
<span v-else class="pl-plan-linked-cert-block__missing">未上传</span>
</div>
</div>
</div>
<p v-else class="pl-plan-form-empty-hint">暂无关联证件</p>
</section>
<section class="pl-plan-form-section pl-plan-form-section--plain">
@@ -413,7 +436,6 @@
type BrandQtyRow
} from '@/components/business/brand-qty-chips-field/index.vue'
import PlPlanDriverFeedbackDialog from '@/components/business/pl-plan-driver-feedback-dialog/index.vue'
import PlPlanHazardCertViewTable from '@/components/business/pl-plan-hazard-cert-view-table/index.vue'
import PlPlanArchiveBrandViewBlock from '@/components/business/pl-plan-archive-brand-view-block/index.vue'
import PlPlanStopFeedbackPanel from '@/components/business/pl-plan-stop-feedback-panel/index.vue'
import PlPlanStopProductTable from '@/components/business/pl-plan-stop-product-table/index.vue'
@@ -459,7 +481,6 @@
} from '@/utils/pl-plan-stop-form'
import { buildArchiveBrandViewsBySegment } from '@/utils/pl-plan-archive-brand-view'
import { resolveFreightUnitLabel } from '@/composables/usePlFreightCalc'
import { isHazardProduct } from '@/composables/usePlToxicProductCertCheck'
import { useDictStore } from '@/store/modules/dict'
import { formatDate, formatDateTime } from '@/utils/format'
import {
@@ -1621,23 +1642,43 @@
return fields
})
/** 危化证区域展示 CRM 标准名(productRows 已回填 standardProductName;库内 productName 可能是别名) */
const hazardProducts = computed(() =>
products.value
.filter((item) => isHazardProduct(item))
.map((item) => {
const formRow = item.id ? planProductFormById.value.get(String(item.id)) : undefined
const standardName = String(formRow?.standardProductName || '').trim()
if (!standardName || standardName === item.productName) return item
return { ...item, productName: standardName }
})
)
const linkedCertRows = ref<
Array<{
key: string
kind: string
certId: string
certNo?: string
reservedQty: string | number
usedQty: string | number
attachmentUrls: string
}>
>([])
async function loadLinkedCerts(planId: string) {
linkedCertRows.value = []
try {
const { planQuotaApi } = await import('@/api/pl/quota-cert')
const data = await planQuotaApi.get(planId)
linkedCertRows.value = (data?.usages || []).map((u: any, index: number) => ({
key: `${u.certType || u.cert_type}-${u.certId || u.cert_id}-${index}`,
kind: String(u.certType || u.cert_type || ''),
certId: String(u.certId || u.cert_id || ''),
certNo: u.certNo || u.cert_no || u.certNoSnapshot || u.cert_no_snapshot,
reservedQty: u.reservedQty ?? u.reserved_qty ?? '—',
usedQty: u.usedQty ?? u.used_qty ?? '—',
attachmentUrls: String(u.attachmentUrls || u.attachment_urls || '').trim()
}))
} catch {
linkedCertRows.value = []
}
}
async function runLoadDetail(planId: string) {
await loadDetail(planId, {
initialPlan: props.initialPlan?.id === planId ? props.initialPlan : null,
prefetchedArrange: props.prefetchedArrange
})
await loadLinkedCerts(planId)
}
watch(
@@ -1885,6 +1926,93 @@
white-space: pre-wrap;
}
.pl-plan-linked-certs {
display: flex;
flex-direction: column;
gap: 12px;
}
.pl-plan-linked-cert-block {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: 10px 16px;
padding: 14px 16px;
border: 1px solid var(--border, #d9e8e2);
border-radius: 10px;
background: #fff;
}
.pl-plan-linked-cert-block__meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
min-width: 0;
}
.pl-plan-linked-cert-block__no {
font-size: 14px;
font-weight: 600;
color: #1e293b;
word-break: break-all;
}
.pl-plan-linked-cert-block__qty {
font-size: 13px;
color: var(--mint-text-muted, #64748b);
}
.pl-plan-linked-cert-block__slot {
display: inline-flex;
align-items: flex-start;
gap: 8px;
}
.pl-plan-linked-cert-block__label {
flex-shrink: 0;
color: var(--mint-text);
font-size: 13px;
line-height: 1.4;
white-space: nowrap;
}
.pl-plan-linked-cert-block__attachment {
display: inline-flex;
flex-shrink: 0;
:deep(.attachment-upload) {
width: auto;
}
:deep(.attachment-list) {
gap: 8px;
}
:deep(.attachment-item) {
width: 140px;
margin-bottom: 0;
border-radius: 8px;
}
:deep(.attachment-preview) {
height: 96px;
}
}
.pl-plan-linked-cert-block__missing {
display: inline-flex;
align-items: center;
justify-content: center;
width: 140px;
height: 96px;
border: 1px dashed var(--border, #d9e8e2);
border-radius: 8px;
background: var(--table-head, #f8fafc);
color: var(--mint-text-muted);
font-size: 12px;
}
.pl-plan-kv-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -86,11 +86,13 @@ export function usePlFreightCalc(options: {
)
function resetFreightCalc() {
transportQty.value = null
transportQtyTouched.value = false
freightAmount.value = null
freightAmountTouched.value = false
freightPrice.value = null
// 产品已加载时 watch 不会因 reset 重跑;须立即按当前实际数量回填,
// 否则归档弹窗再次打开会误报「运输量须大于0」。
transportQty.value = defaultTransportQty.value || null
}
function markTransportQtyTouched() {
+10
View File
@@ -39,6 +39,16 @@ declare module '@turbodocx/html-to-docx/dist/html-to-docx.browser.esm.js' {
export default HTMLtoDOCX
}
declare module '@univerjs/preset-sheets-core/locales/zh-CN' {
const locale: Record<string, string>
export default locale
}
declare module '@univerjs/preset-sheets-filter/locales/zh-CN' {
const locale: Record<string, string>
export default locale
}
declare module 'qrcode.vue' {
export type Level = 'L' | 'M' | 'Q' | 'H'
export type RenderAs = 'canvas' | 'svg'
+1 -1
View File
@@ -133,7 +133,7 @@
mode: 'list',
title: '运输证件管理',
search: '证件编号 / 品名',
tabs: ['证件建档', '合同用证', '证件用量', '计划段次用证', '证书齐备'],
tabs: ['证件建档', '客户用证', '证件用量', '计划段次用证', '证书齐备'],
filters: [],
caption: '正在加载运输证件管理'
}
+8 -1
View File
@@ -68,12 +68,19 @@ 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',
path: 'contract-quota',
component: '/pl/contract-quota/index',
meta: { title: '合同用证', keepAlive: false, menuCode: 'pl:quota-cert:list' }
meta: { title: '客户用证', keepAlive: false, menuCode: 'pl:quota-cert:list' }
},
{
id: 'PL_CERTIFICATE_USAGE',
+11
View File
@@ -727,6 +727,17 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
menuCode: 'system:dict:list'
}
},
{
id: 'SYSM_CERT_QUOTA_RULE',
name: 'CertQuotaRule',
path: 'cert-quota-rule',
component: '/system/cert-quota-rule/index',
meta: {
title: '运输证件规则',
keepAlive: false,
menuCode: 'system:tenant:list'
}
},
{
id: '106',
name: 'Menu',
+8 -2
View File
@@ -171,11 +171,17 @@ export const staticRoutes: AppRouteRecordRaw[] = [
component: () => import('@views/m/pl/plan-cert/archive-detail.vue'),
meta: { title: '证件建档', isHideTab: true, isMpWebview: true }
},
{
path: '/m/pl/plan-cert/customer/:id',
name: 'MpPlPlanCertCustomer',
component: () => import('@views/m/pl/plan-cert/customer-detail.vue'),
meta: { title: '客户用证', isHideTab: true, isMpWebview: true }
},
{
path: '/m/pl/plan-cert/contract/:id',
name: 'MpPlPlanCertContract',
component: () => import('@views/m/pl/plan-cert/contract-detail.vue'),
meta: { title: '合同用证', isHideTab: true, isMpWebview: true }
redirect: { name: 'MpPlPlanCert', query: { tab: 'contract' } },
meta: { title: '客户用证', isHideTab: true, isMpWebview: true }
},
{
path: '/m/pl/plan-cert/usage/:kind/:id',
@@ -1,11 +1,13 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
buildExcelSnapshot,
buildExcelTsv,
excelCell,
isCopyableColumn,
isRowSelectorColumn,
resolveCopyAxes,
snapshotToUniverCellData,
toggleAxis
} from './list-excel-copy'
@@ -135,3 +137,45 @@ describe('buildExcelTsv', () => {
assert.equal(tsv, '联系人\t状态\r\n张三\t合作中\r\n李四\t跟进中')
})
})
describe('buildExcelSnapshot', () => {
it('keeps copyable columns, cleans cells, and drops 操作', () => {
const snapshot = buildExcelSnapshot({
columns: [
{ id: 'name', label: ' 联系人\n' },
{ id: 'act', label: '操作' },
{ id: 'status', label: '状态' }
],
rowValues: [
['张三\t甲', '编辑', '合作中'],
['李四', '删除', '跟进中']
]
})
assert.deepEqual(snapshot.headers, ['联系人', '状态'])
assert.deepEqual(snapshot.rows, [
['张三 甲', '合作中'],
['李四', '跟进中']
])
})
it('returns empty snapshot when no copyable columns', () => {
const snapshot = buildExcelSnapshot({
columns: [{ id: 'a', label: '操作' }],
rowValues: [['x']]
})
assert.deepEqual(snapshot, { headers: [], rows: [] })
})
})
describe('snapshotToUniverCellData', () => {
it('puts headers on row 0 and data from row 1', () => {
const cellData = snapshotToUniverCellData({
headers: ['A', 'B'],
rows: [['1', '2']]
})
assert.deepEqual(cellData[0][0], { v: 'A', t: 1 })
assert.deepEqual(cellData[0][1], { v: 'B', t: 1 })
assert.deepEqual(cellData[1][0], { v: '1', t: 1 })
assert.deepEqual(cellData[1][1], { v: '2', t: 1 })
})
})
+48
View File
@@ -68,6 +68,54 @@ export function buildExcelTsv(headers: string[], rows: string[][]): string {
return lines.join('\r\n')
}
/** 灌入只读在线表格的快照:表头 + 当前页文本矩阵 */
export type ListExcelSnapshot = {
headers: string[]
rows: string[][]
}
/**
* 从可复制列与行文本构建快照。headers/rows 均经 excelCell 清洗。
* rowValues 与 columns 按下标对齐;操作列等会被跳过。
* 无可复制列时返回 { headers: [], rows: [] }。
*/
export function buildExcelSnapshot(input: {
columns: ListExcelColumn[]
/** 与 columns 按下标对齐的行文本;通常来自表格 DOM textContent */
rowValues: string[][]
}): ListExcelSnapshot {
const indices: number[] = []
const headers: string[] = []
input.columns.forEach((col, index) => {
if (!col.id || !isCopyableColumn(col)) return
indices.push(index)
headers.push(excelCell(col.label))
})
if (!indices.length) return { headers: [], rows: [] }
const rows = input.rowValues.map((row) =>
indices.map((colIndex) => excelCell(row[colIndex] ?? ''))
)
return { headers, rows }
}
/** 将快照转为 Univer cellData(第 0 行为表头)。 */
export function snapshotToUniverCellData(snapshot: ListExcelSnapshot): Record<
number,
Record<number, { v: string; t: number }>
> {
const cellData: Record<number, Record<number, { v: string; t: number }>> = {}
const writeRow = (r: number, values: string[]) => {
const row: Record<number, { v: string; t: number }> = {}
values.forEach((value, c) => {
row[c] = { v: value, t: 1 }
})
cellData[r] = row
}
writeRow(0, snapshot.headers)
snapshot.rows.forEach((row, index) => writeRow(index + 1, row))
return cellData
}
function range(count: number): number[] {
return Array.from({ length: count }, (_, index) => index)
}
@@ -0,0 +1,27 @@
/** 归档用证拦截弹窗:已齐备/待完善只提醒,未齐备旧规则才可再点证书齐备。 */
export type ArchiveQuotaBlockMode = 'alert-pending' | 'alert-plain' | 'confirm-ready'
export type ArchiveQuotaBlockPlanFlags = {
quotaCertPendingFulfill?: number | boolean | null
quotaCertWaived?: number | boolean | null
}
function flagOn(value: number | boolean | null | undefined) {
return value === true || Number(value || 0) === 1
}
export function resolveArchiveQuotaBlockMode(
message: string,
title: string,
plan?: ArchiveQuotaBlockPlanFlags | null
): ArchiveQuotaBlockMode {
const pendingByMessage =
message.includes('待完善') ||
message.includes('完善并关闭') ||
message.includes('证书齐备待完善')
const pendingByPlan = flagOn(plan?.quotaCertPendingFulfill) || flagOn(plan?.quotaCertWaived)
if (title !== '用证没有完成') return 'alert-plain'
if (pendingByPlan || pendingByMessage) return 'alert-pending'
return 'confirm-ready'
}
+7 -1
View File
@@ -1,10 +1,16 @@
/** 已标记证书齐备时,只保留服务端仍要拦住的来源问题,不再用本地两证缺口补一条拦截。 */
export function toxicArrangeBlockMessage(
quota: { quotaCertWaived?: boolean; arrangeBlockMessage?: string } | null | undefined,
quota: {
quotaCertWaived?: boolean
arrangeBlockMessage?: string
certQuotaRuleVersion?: string | null
} | null | undefined,
unboundMessage: string
) {
const serverMessage = (quota?.arrangeBlockMessage || '').trim()
if (quota?.quotaCertWaived) return serverMessage
// 新规则由服务端自动选证;本地「合同绑证」缺口不能再当安排拦截条件
if (quota?.certQuotaRuleVersion === 'customer_category') return serverMessage
return serverMessage || unboundMessage
}
@@ -504,13 +504,12 @@
/>
</el-tab-pane>
<el-tab-pane v-if="canViewQuotaCert" label="合同证件与额度" name="quota" lazy>
<ContractQuotaPanel
<el-tab-pane v-if="canViewQuotaCert" label="客户证件与额度" name="quota" lazy>
<CustomerCertQuotaPanel
v-if="visible && detailActiveTab === 'quota' && detailContract?.id && String(detailContract.id) === String(currentDetailId)"
:key="detailContract.id"
:contract-id="String(detailContract.id)"
:key="String(detailContract.customerId || detailContract.id)"
:customer-id="detailContract.customerId"
:active="detailActiveTab === 'quota'"
embedded
/>
</el-tab-pane>
@@ -1060,7 +1059,7 @@ import UploadDialog from './UploadDialog.vue';
import ContractFormDialog from './ContractFormDialog.vue';
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue';
import type { ViewField } from '@/components/business/system-view-dialog/types';
import ContractQuotaPanel from '@/views/pl/contract-quota/ContractQuotaPanel.vue';
import CustomerCertQuotaPanel from '@/views/pl/contract-quota/CustomerCertQuotaPanel.vue';
import StopDocuments from '@/views/pl/legal-use-document/StopDocuments.vue';
import PlContractLinkedPlansPanel from '@/components/business/pl-contract-linked-plans-panel/index.vue';
import {
@@ -0,0 +1,180 @@
<template>
<div class="mp-customer-cert-quota">
<p v-if="!customerId" class="mp-wv-empty">合同未关联客户,无法展示证件</p>
<p v-else-if="error" class="mp-wv-empty" role="alert">
{{ error }}
<button class="mp-wv-btn" type="button" @click="load">重试</button>
</p>
<div v-else-if="loading" class="mp-cert-page-loading"><van-loading /></div>
<template v-else>
<p class="mp-customer-cert-quota__tip">
展示该客户全部购买证、运输证;证件关联客户,不再按合同绑定。
</p>
<van-empty v-if="!rows.length" description="该客户暂无购买证或运输证" />
<article
v-for="row in rows"
:key="row.certType + row.id"
class="mp-wv-section"
role="button"
tabindex="0"
@click="openDetail(row)"
@keydown.enter.prevent="openDetail(row)"
>
<div class="mp-wv-card__head">
<div>
<h3>{{ row.certNo || '—' }}</h3>
<p>{{ row.productNameOnCert || '未填写证面品名' }}</p>
<p>有效期:{{ row.validFrom || '—' }} 至 {{ row.validTo || '—' }}</p>
</div>
</div>
<div class="mp-cert-tags">
<span
class="status-tag mp-cert-kind-tag"
:class="`status-tag--${certKindMobileTone(row.certType)}`"
>{{ certKindLabel(row.certType) }}</span
>
<span class="status-tag" :class="`status-tag--${statusTone(row.status)}`">{{
certStatusLabel(row.status)
}}</span>
<span v-if="row.singleUseConsumed" class="status-tag status-tag--danger"
>已使用不可再用</span
>
</div>
<div class="mp-wv-card__kv">
<div class="mp-wv-card__kv-row"
><span>有效次数</span><span>{{ row.validUseText || '—' }}</span></div
>
<div class="mp-wv-card__kv-row"
><span>核定</span><span>{{ qtyText(row.totalQty, row.quantityUnit) }}</span></div
>
<div class="mp-wv-card__kv-row"
><span>预占</span><span>{{ qtyText(row.reservedQty, row.quantityUnit) }}</span></div
>
<div class="mp-wv-card__kv-row"
><span>已用</span><span>{{ qtyText(row.usedQty, row.quantityUnit) }}</span></div
>
<div class="mp-wv-card__kv-row"
><span>余量</span><span>{{ qtyText(row.availableQty, row.quantityUnit) }}</span></div
>
</div>
</article>
</template>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useDictStore } from '@/store/modules/dict'
import { getProductUnitLabel } from '@/utils/crm-product-display'
import { quotaCertApi, type CertType, type QuotaCert } from '@/api/pl/quota-cert'
import { certKindLabel, certKindMobileTone } from '@/views/pl/quota-cert/cert-list-face'
import { formatCertQty } from '@/views/pl/quota-cert/model'
type CustomerCertRow = QuotaCert & { certType: CertType }
const props = withDefaults(
defineProps<{ customerId?: string | number | null; active?: boolean }>(),
{ active: true }
)
const router = useRouter()
const dictStore = useDictStore()
const loading = ref(false)
const error = ref('')
const rows = ref<CustomerCertRow[]>([])
let loadEpoch = 0
function unitLabel(unit?: string | null) {
const label = getProductUnitLabel(dictStore.getLabel, unit)
return !label || label === '—' ? '' : label
}
function qtyText(value: unknown, unit?: string | null) {
const text = formatCertQty(value)
const name = unitLabel(unit)
return name ? `${text} ${name}` : text
}
function certStatusLabel(status?: string | null) {
return (
{
draft: '待核对',
active: '证件已启用',
suspended: '证件已停用',
closed: '证件已核销',
void: '证件已作废'
}[status || ''] ||
status ||
'—'
)
}
function statusTone(status?: string | null) {
if (status === 'active') return 'success'
if (status === 'suspended') return 'warning'
if (status === 'closed' || status === 'void') return 'danger'
return 'info'
}
async function load() {
const customerId = props.customerId == null ? '' : String(props.customerId).trim()
const epoch = ++loadEpoch
if (!customerId) {
rows.value = []
error.value = ''
loading.value = false
return
}
loading.value = true
error.value = ''
try {
const [purchase, transport] = await Promise.all([
quotaCertApi.list('purchase', { customerId }),
quotaCertApi.list('transport', { customerId })
])
if (epoch !== loadEpoch) return
rows.value = [
...(purchase || []).map((item) => ({ ...item, certType: 'purchase' as const })),
...(transport || []).map((item) => ({ ...item, certType: 'transport' as const }))
].sort((a, b) => String(b.createTime || '').localeCompare(String(a.createTime || '')))
} catch (e: any) {
if (epoch !== loadEpoch) return
rows.value = []
error.value = e?.message || '加载客户证件失败'
} finally {
if (epoch === loadEpoch) loading.value = false
}
}
function openDetail(row: CustomerCertRow) {
void router.push(`/m/pl/plan-cert/archive/${row.certType}/${row.id}`)
}
watch(
() => [props.customerId, props.active],
([, active]) => {
if (active) void load()
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.mp-customer-cert-quota__tip {
margin: 0 0 12px;
font-size: 12px;
line-height: 1.5;
color: var(--mp-text-muted, #64748b);
}
.mp-wv-section {
cursor: pointer;
}
.mp-cert-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 8px 0 12px;
}
</style>
+46 -9
View File
@@ -342,6 +342,19 @@
</div>
<div v-show="activeTab === 'quota'" class="mp-contract-detail-pane">
<section class="mp-wv-section mp-contract-detail-card">
<h3>客户证件与额度</h3>
<CustomerCertQuotaTab
v-if="canViewQuotaCert && activeTab === 'quota'"
:key="String(contract.customerId || contract.id)"
:customer-id="contract.customerId"
:active="activeTab === 'quota'"
/>
<p v-else class="mp-wv-card__meta">无证件查看权限</p>
</section>
</div>
<div v-show="activeTab === 'plans'" class="mp-contract-detail-pane">
<section class="mp-wv-section mp-contract-detail-card">
<h3>运输计划</h3>
@@ -933,6 +946,8 @@
} from '@/utils/mp-webview'
import { useUserStore } from '@/store/modules/user'
import { useDictStore } from '@/store/modules/dict'
import { fetchGetMenuCodes } from '@/api/auth'
import CustomerCertQuotaTab from './CustomerCertQuotaTab.vue'
import '@/styles/mp-webview.scss'
import '../customer/customer.scss'
@@ -970,6 +985,7 @@
type DetailTabKey =
| 'contract'
| 'plans'
| 'quota'
| 'payment'
| 'invoice'
| 'approval'
@@ -979,6 +995,7 @@
const acting = ref(false)
const errorText = ref('')
const activeTab = ref<DetailTabKey>('contract')
const canViewQuotaCert = ref(false)
const detailBody = ref<HTMLElement>()
const partiesExpanded = ref(false)
const approverPickerOpen = ref(false)
@@ -1056,14 +1073,34 @@
}
}
const detailTabs: Array<{ key: DetailTabKey; label: string }> = [
{ key: 'contract', label: '合同信息' },
{ key: 'plans', label: '运输计划' },
{ key: 'payment', label: '回款记录' },
{ key: 'invoice', label: '开票记录' },
{ key: 'approval', label: '审批流程' },
{ key: 'operation', label: '操作记录' }
]
const detailTabs = computed(() => {
const tabs: Array<{ key: DetailTabKey; label: string }> = [
{ key: 'contract', label: '合同信息' },
{ key: 'plans', label: '运输计划' }
]
if (canViewQuotaCert.value) tabs.push({ key: 'quota', label: '客户证件与额度' })
tabs.push(
{ key: 'payment', label: '回款记录' },
{ key: 'invoice', label: '开票记录' },
{ key: 'approval', label: '审批流程' },
{ key: 'operation', label: '操作记录' }
)
return tabs
})
async function refreshQuotaCertPermission() {
try {
const permissions = await fetchGetMenuCodes()
const codes = permissions?.menuCodes || []
canViewQuotaCert.value =
!!permissions?.all || !!permissions?.isSuperAdmin || codes.includes('pl:quota-cert:list')
} catch {
canViewQuotaCert.value = false
}
if (!canViewQuotaCert.value && activeTab.value === 'quota') {
activeTab.value = 'contract'
}
}
const unreceivedAmount = computed(() => {
const amount = Number(contract.value?.amount) || 0
@@ -1573,7 +1610,7 @@
}
loading.value = true
try {
const res = await getContract(id)
const [res] = await Promise.all([getContract(id), refreshQuotaCertPermission()])
if (!isCurrentLoad(requestSeq, id)) return
const data = ((res as any)?.data ?? res) as ContractVO
if (String(data?.id || '') !== id) throw new Error('合同不存在或无权查看')
@@ -60,12 +60,12 @@
>编辑</button
>
<button
v-if="canEdit && row.status !== 'void' && row.status !== 'closed'"
v-if="canEdit && row.openingConfirmed && row.status !== 'void' && row.status !== 'closed'"
class="mp-wv-btn"
type="button"
@click="confirmRow = row"
>
{{ row.openingConfirmed ? '复核' : '期初确认' }}
复核
</button>
</div>
</article>
@@ -8,37 +8,21 @@
:close-on-click-overlay="!saving"
@update:show="onShow"
>
<van-nav-bar
:title="row?.openingConfirmed ? '复核证件' : '期初核对'"
left-text="关闭"
@click-left="!saving && emit('close')"
/>
<van-nav-bar title="复核证件" left-text="关闭" @click-left="!saving && emit('close')" />
<div v-if="row" class="mp-cert-confirm-sheet__body mp-cert-form">
<p class="plan-empty-hint">{{ row.certNo }} · {{ row.openingConfirmed ? '复核已启用的证件' : '核对历史用量后启用' }}</p>
<template v-if="!row.openingConfirmed">
<van-field v-model="opening.usedQty" label="历史已用量" label-align="top" type="number" :disabled="saving" />
<van-cell title="一次有效证已有使用记录" center>
<template #right-icon>
<van-switch v-model="opening.singleUseConsumed" :disabled="saving" size="20px" />
</template>
</van-cell>
<van-field v-model="opening.sourceNote" label="历史来源说明" label-align="top" type="textarea" rows="2" :disabled="saving" />
<p class="plan-empty-hint">核对凭据</p>
<AttachmentUploadField v-model="opening.evidenceUrls" folder="pl/quota-cert" :disabled="saving" hint="" />
</template>
<p class="plan-empty-hint">{{ row.certNo }} · 复核已启用的证件</p>
<van-field v-model="opening.reason" label="核对说明" label-align="top" type="textarea" rows="2" :disabled="saving" />
</div>
<footer>
<button class="mp-wv-btn" type="button" :disabled="saving" @click="emit('close')">取消</button>
<button class="mp-wv-btn mp-wv-btn--primary" type="button" :disabled="saving" @click="submit">
{{ saving ? '正在提交…' : '确认启用' }}
{{ saving ? '正在提交…' : '确认复核' }}
</button>
</footer>
</van-popup>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import { quotaCertApi, type CertType, type QuotaCert } from '@/api/pl/quota-cert'
import { randomOperationId } from '@/utils/random-id'
import './cert-mobile.scss'
@@ -76,19 +60,15 @@
opening.reason = opening.reason.trim()
saving.value = true
try {
if (props.row.openingConfirmed) {
await quotaCertApi.verify(kind, props.row.id, {
operationId: opening.operationId,
reason: opening.reason
})
} else {
await quotaCertApi.confirm(kind, props.row.id, { ...opening })
}
ElMessage.success(props.row.openingConfirmed ? '证件已复核' : '期初已确认')
await quotaCertApi.verify(kind, props.row.id, {
operationId: opening.operationId,
reason: opening.reason
})
ElMessage.success('证件已复核')
emit('saved')
emit('close')
} catch {
ElMessage.error('核对没有提交,请重试')
ElMessage.error('复核没有提交,请重试')
} finally {
saving.value = false
}
@@ -8,7 +8,10 @@
<template v-else-if="plan">
<section class="mp-wv-section mp-cert-quota">
<p class="mp-wv-card__meta">当前车号 {{ plan.licensePlate || '待安排' }}</p>
<p v-if="plan.canSaveConfiguration === false" class="plan-empty-hint">
<p v-if="plan.certQuotaRuleVersion === 'customer_category'" class="plan-empty-hint">
新规则计划由系统按客户+品类自动选证,本页仅查看占用;安排不提供手工选证。
</p>
<p v-else-if="plan.canSaveConfiguration === false" class="plan-empty-hint">
已安排计划请从调整运输提交用证变更;已归档计划仅查看占用。
</p>
<h3>全部装送段用证</h3>
@@ -122,8 +125,8 @@
</div>
</div>
<template v-if="face.kind === 'transport'">
<h3>证面引用</h3>
<p v-if="!face.cert.purchaseRefs?.length" class="plan-empty-hint">没有证面引用。</p>
<h3>关联购买证</h3>
<p v-if="!face.cert.purchaseRefs?.length" class="plan-empty-hint">没有关联购买证。</p>
<article
v-for="(refRow, index) in face.cert.purchaseRefs || []"
:key="refRow.id || index"
@@ -274,12 +277,32 @@
for (const [key, ids] of Object.entries(next.pairs)) pairs[key] = ids
}
function bound(row: QuotaSourceOption, kind: CertType) {
return (
const fromStop = (
plan.value?.stopProducts?.find(
(item) =>
item.sourceStopType === row.sourceStopType && item.sourceStopProductId === row.sourceStopProductId
)?.certificates || []
).filter((cert) => cert.certType === kind)
if (fromStop.length) return fromStop
// 新规则无合同绑证时,安排后从占用明细回填证号
const sourceId = `${row.sourceStopType}:${row.sourceStopProductId}`
const fromUsage: { certType: CertType; certId: string; certNo: string; quantityUnit: string }[] = []
for (const usage of plan.value?.usages || []) {
if (String(usage.certType || '') !== kind) continue
const items = Array.isArray(usage.items) ? usage.items : []
const hit = items.some((item: Record<string, unknown>) => {
const typed = `${String(item.sourceStopType || '')}:${String(item.sourceStopProductId || '')}`
return String(item.sourceId || '') === sourceId || typed === sourceId
})
if (!hit) continue
fromUsage.push({
certType: kind,
certId: String(usage.certId || ''),
certNo: String(usage.certNoSnapshot || ''),
quantityUnit: String(usage.quantityUnit || '')
})
}
return fromUsage
}
function display(cert: QuotaCert, key: string) {
if (key === 'status') return certStatusLabels[cert.status] || cert.status || '—'
@@ -1,129 +1,7 @@
<template>
<main class="mp-h5-list-content mp-plan-cert-list">
<p v-if="error" class="mp-wv-empty" role="alert">
{{ error }}
<button class="mp-wv-btn" type="button" @click="reload">重试</button>
</p>
<div v-else-if="authReady && !allowed" class="mp-wv-empty">
<div class="mp-wv-empty__title">无证件查看权限</div>
</div>
<van-list v-else v-model:loading="loading" :finished="finished" finished-text="" @load="load">
<article
v-for="row in rows"
:key="row.id"
class="mp-wv-card mp-wv-flat-card"
@click="open(row.id)"
>
<div class="mp-wv-card__head">
<div>
<div class="mp-wv-card__no">{{ row.contractNo || '未编号合同' }}</div>
<div class="mp-wv-card__sub">{{ row.contractName || '—' }}</div>
</div>
<span class="status-tag status-tag--info">{{ contractTypeLabel(row.contractType) }}</span>
</div>
<div class="mp-wv-card__kv">
<div class="mp-wv-card__kv-row">
<span>关联证件数</span><span>{{ row.certCount }}</span>
</div>
</div>
<div class="mp-wv-card-actions mp-wv-card-actions--single" @click.stop>
<button class="mp-wv-btn mp-wv-btn--primary" type="button" @click="open(row.id)">查看</button>
</div>
</article>
</van-list>
<van-empty
v-if="authReady && allowed && !loading && finished && !error && !rows.length"
description="暂无合同用证数据"
/>
</main>
<CustomerTab :keyword="keyword" />
</template>
<script setup lang="ts">
import { fetchGetMenuCodes } from '@/api/auth'
import { quotaStatisticsApi, type ContractStatisticsSummary } from '@/api/pl/quota-cert'
import { contractTypeLabel } from '@/views/pl/quota-statistics/labels'
const props = defineProps<{ keyword: string }>()
const router = useRouter()
const rows = ref<ContractStatisticsSummary[]>([])
const loading = ref(true)
const finished = ref(false)
const error = ref('')
const allowed = ref(false)
const authReady = ref(false)
const page = ref(1)
let epoch = 0
let checking = true
function open(id: string) {
void router.push(`/m/pl/plan-cert/contract/${id}`)
}
async function init() {
const current = epoch
try {
const permissions = await fetchGetMenuCodes()
if (current !== epoch) return
const codes = permissions?.menuCodes || []
allowed.value =
!!permissions?.all || !!permissions?.isSuperAdmin || codes.includes('pl:quota-cert:list')
authReady.value = true
checking = false
if (!allowed.value) {
finished.value = true
loading.value = false
return
}
await load()
} catch {
if (current !== epoch) return
checking = false
authReady.value = true
finished.value = true
loading.value = false
error.value = '权限加载失败,请重新打开页面'
}
}
function reload() {
if (!allowed.value) {
void init()
return
}
epoch++
page.value = 1
rows.value = []
loading.value = true
finished.value = false
error.value = ''
void load()
}
async function load() {
if (checking || !allowed.value) return
const current = epoch
loading.value = true
try {
const data = await quotaStatisticsApi.contracts({
page: page.value,
pageSize: 20,
keyword: props.keyword || undefined
})
if (current !== epoch) return
const list = data?.list || []
rows.value = page.value === 1 ? list : rows.value.concat(list)
finished.value = rows.value.length >= (data?.total || 0) || list.length < 20
page.value += 1
} catch {
if (current === epoch) {
error.value = '统计加载失败,请重试'
finished.value = true
}
} finally {
if (current === epoch) loading.value = false
}
}
watch(
() => props.keyword,
() => {
if (!checking && allowed.value) reload()
}
)
void init()
import CustomerTab from './CustomerTab.vue'
defineProps<{ keyword: string }>()
</script>
@@ -0,0 +1,143 @@
<template>
<main class="mp-h5-list-content mp-plan-cert-list">
<p class="mp-customer-quota-tip">按客户展示购买证、运输证;证件关联客户,不再按合同绑定。</p>
<p v-if="error" class="mp-wv-empty" role="alert">
{{ error }}
<button class="mp-wv-btn" type="button" @click="reload">重试</button>
</p>
<div v-else-if="authReady && !allowed" class="mp-wv-empty">
<div class="mp-wv-empty__title">无证件查看权限</div>
</div>
<van-list v-else v-model:loading="loading" :finished="finished" finished-text="" @load="load">
<article
v-for="row in rows"
:key="row.id"
class="mp-wv-card mp-wv-flat-card"
@click="open(row.id)"
>
<div class="mp-wv-card__head">
<div>
<div class="mp-wv-card__no">{{ row.customerName || '未命名客户' }}</div>
<div class="mp-wv-card__sub">{{ row.customerNo || '—' }}</div>
</div>
</div>
<div class="mp-wv-card__kv">
<div class="mp-wv-card__kv-row">
<span>关联证件数</span><span>{{ row.certCount }}</span>
</div>
</div>
<div class="mp-wv-card-actions mp-wv-card-actions--single" @click.stop>
<button class="mp-wv-btn mp-wv-btn--primary" type="button" @click="open(row.id)"
>查看</button
>
</div>
</article>
</van-list>
<van-empty
v-if="authReady && allowed && !loading && finished && !error && !rows.length"
description="暂无客户用证数据"
/>
</main>
</template>
<script setup lang="ts">
import { fetchGetMenuCodes } from '@/api/auth'
import { quotaStatisticsApi, type CustomerStatisticsSummary } from '@/api/pl/quota-cert'
const props = defineProps<{ keyword: string }>()
const router = useRouter()
const rows = ref<CustomerStatisticsSummary[]>([])
const loading = ref(true)
const finished = ref(false)
const error = ref('')
const allowed = ref(false)
const authReady = ref(false)
const page = ref(1)
let epoch = 0
let checking = true
function open(id: string) {
void router.push(`/m/pl/plan-cert/customer/${id}`)
}
async function init() {
const current = epoch
try {
const permissions = await fetchGetMenuCodes()
if (current !== epoch) return
const codes = permissions?.menuCodes || []
allowed.value =
!!permissions?.all || !!permissions?.isSuperAdmin || codes.includes('pl:quota-cert:list')
authReady.value = true
checking = false
if (!allowed.value) {
finished.value = true
loading.value = false
return
}
await load()
} catch {
if (current !== epoch) return
checking = false
authReady.value = true
finished.value = true
loading.value = false
error.value = '权限加载失败,请重新打开页面'
}
}
function reload() {
if (!allowed.value) {
void init()
return
}
epoch++
page.value = 1
rows.value = []
loading.value = true
finished.value = false
error.value = ''
void load()
}
async function load() {
if (checking || !allowed.value) return
const current = epoch
loading.value = true
try {
const data = await quotaStatisticsApi.customers({
page: page.value,
pageSize: 20,
keyword: props.keyword || undefined
})
if (current !== epoch) return
const list = data?.list || []
rows.value = page.value === 1 ? list : rows.value.concat(list)
finished.value = rows.value.length >= (data?.total || 0) || list.length < 20
page.value += 1
} catch {
if (current === epoch) {
error.value = '统计加载失败,请重试'
finished.value = true
}
} finally {
if (current === epoch) loading.value = false
}
}
watch(
() => props.keyword,
() => {
if (!checking && allowed.value) reload()
}
)
void init()
</script>
<style scoped>
.mp-customer-quota-tip {
margin: 8px 12px 0;
padding: 10px 12px;
border-radius: 8px;
background: #ecfdf5;
color: #065f46;
font-size: 12px;
line-height: 1.5;
}
</style>
+152 -19
View File
@@ -1,5 +1,24 @@
<template>
<main class="mp-h5-list-content mp-plan-cert-list">
<van-tabs v-model:active="listMode" class="mp-customer-view-tabs" shrink :ellipsis="false">
<van-tab name="mark" title="待标记齐备" />
<van-tab name="fulfill" title="已标记待完善" />
</van-tabs>
<p class="mp-cert-ready-tip">
新规则下证书齐备只放行安排;归档前须上传真证并执行「完善并关闭」。旧规则齐备不进待完善列表。
</p>
<div v-if="listMode === 'fulfill'" class="mp-cert-ready-actions">
<button class="mp-wv-btn" type="button" @click="goUpload('purchase')">上传购买证</button>
<button class="mp-wv-btn" type="button" @click="goUpload('transport')">上传运输证</button>
<button
class="mp-wv-btn mp-wv-btn--primary"
type="button"
:disabled="!selectedIds.length || batching"
@click="fulfillBatch"
>
{{ batching ? '批量完善中…' : `批量完善并关闭${selectedIds.length ? `(${selectedIds.length})` : ''}` }}
</button>
</div>
<p v-if="error" class="mp-wv-empty" role="alert">
{{ error }}
<button class="mp-wv-btn" type="button" @click="reload">重试</button>
@@ -7,14 +26,26 @@
<van-list v-model:loading="loading" :finished="finished" finished-text="" @load="load">
<article v-for="row in rows" :key="row.id" class="mp-wv-card mp-wv-flat-card">
<div class="mp-wv-card__head">
<div class="mp-cert-ready-select" v-if="listMode === 'fulfill'">
<van-checkbox :model-value="selectedIds.includes(row.id)" @click.stop="toggleSelect(row.id)" />
</div>
<div>
<div class="mp-wv-card__no">{{ row.planNo }}</div>
<div class="mp-wv-card__sub">
{{ row.licensePlate || '未安排车辆' }} · {{ row.planDate || '未定日期' }}
</div>
</div>
<span class="status-tag" :class="row.quotaCertWaived ? 'status-tag--success' : 'status-tag--warning'">
{{ row.quotaCertWaived ? '已齐备' : '未齐备' }}
<span
class="status-tag"
:class="row.quotaCertWaived ? 'status-tag--success' : 'status-tag--warning'"
>
{{
listMode === 'fulfill'
? '待完善'
: row.quotaCertWaived
? '已齐备'
: '未齐备'
}}
</span>
</div>
<div class="mp-wv-card__kv">
@@ -31,7 +62,10 @@
<span>确认时间</span><span>{{ formatCertReadyTime(row.quotaCertWaivedTime) }}</span>
</div>
</div>
<div v-if="!row.quotaCertWaived" class="mp-wv-card-actions mp-wv-card-actions--single">
<div
v-if="listMode === 'mark' && !row.quotaCertWaived"
class="mp-wv-card-actions mp-wv-card-actions--single"
>
<button
class="mp-wv-btn mp-wv-btn--primary"
type="button"
@@ -41,9 +75,22 @@
{{ markingId === row.id ? '正在确认…' : '证书齐备' }}
</button>
</div>
<div v-else-if="listMode === 'fulfill'" class="mp-wv-card-actions mp-wv-card-actions--single">
<button
class="mp-wv-btn mp-wv-btn--primary"
type="button"
:disabled="markingId === row.id"
@click="fulfillOne(row)"
>
{{ markingId === row.id ? '正在完善…' : '完善并关闭' }}
</button>
</div>
</article>
</van-list>
<van-empty v-if="!loading && finished && !error && !rows.length" description="暂无需要证书齐备的计划" />
<van-empty
v-if="!loading && finished && !error && !rows.length"
:description="listMode === 'fulfill' ? '暂无待完善证书的计划' : '暂无需要证书齐备的计划'"
/>
</main>
</template>
@@ -59,18 +106,23 @@
planDateFrom: string
planDateTo: string
}>()
const router = useRouter()
const listMode = ref<'mark' | 'fulfill'>('mark')
const rows = ref<CertReadyPlan[]>([])
const loading = ref(false)
const finished = ref(false)
const error = ref('')
const page = ref(1)
const markingId = ref('')
const batching = ref(false)
const selectedIds = ref<string[]>([])
let epoch = 0
function reload() {
epoch++
page.value = 1
rows.value = []
selectedIds.value = []
loading.value = true
finished.value = false
error.value = ''
@@ -81,15 +133,22 @@
const current = epoch
loading.value = true
try {
const data = await certReadyApi.list({
page: page.value,
pageSize: 20,
keyword: props.keyword || undefined,
waived: props.waived || undefined,
followStatusIn: props.followStatus || undefined,
planDateFrom: props.planDateFrom || undefined,
planDateTo: props.planDateTo || undefined
})
const data =
listMode.value === 'fulfill'
? await certReadyApi.listPendingFulfill({
page: page.value,
pageSize: 20,
keyword: props.keyword || undefined
})
: await certReadyApi.list({
page: page.value,
pageSize: 20,
keyword: props.keyword || undefined,
waived: props.waived || undefined,
followStatusIn: props.followStatus || undefined,
planDateFrom: props.planDateFrom || undefined,
planDateTo: props.planDateTo || undefined
})
if (current !== epoch) return
const list = data?.list || []
rows.value = page.value === 1 ? list : rows.value.concat(list)
@@ -108,9 +167,9 @@
async function mark(row: CertReadyPlan) {
try {
await ElMessageBox.confirm(
'确认后,这条运输计划在安排和归档时都不再校验购买证、运输证,也不扣减额度。',
'证书齐备',
{ type: 'warning', confirmButtonText: '确认齐备', cancelButtonText: '取消' }
'确认后可先安排运输;新规则下归档前仍须到「已标记待完善」上传证件并执行完善并关闭。确认人和时间会记在本行。',
'标记齐备',
{ type: 'warning', confirmButtonText: '确认标记', cancelButtonText: '取消' }
)
} catch {
return
@@ -127,11 +186,85 @@
}
}
async function fulfillOne(row: CertReadyPlan) {
markingId.value = row.id
try {
await certReadyApi.fulfill(row.id)
ElMessage.success('已完善并关闭')
reload()
} catch (reason) {
ElMessage.error(certReadyErrorText(reason, '完善并关闭失败'))
} finally {
markingId.value = ''
}
}
function toggleSelect(id: string) {
selectedIds.value = selectedIds.value.includes(id)
? selectedIds.value.filter((item) => item !== id)
: selectedIds.value.concat(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((item) => !item.ok)
if (failed.length) {
ElMessage.warning(`部分失败:${failed.map((f) => f.message || f.planId).join(';')}`)
} else {
ElMessage.success('批量完善完成')
}
reload()
} catch (reason) {
ElMessage.error(certReadyErrorText(reason, '批量完善失败'))
} finally {
batching.value = false
}
}
function goUpload(kind: 'purchase' | 'transport') {
void router.push(`/m/pl/plan-cert/archive/${kind}/create`)
}
watch(listMode, () => reload())
watch(
() =>
[props.keyword, props.waived, props.followStatus, props.planDateFrom, props.planDateTo].join(
'\0'
),
[
props.keyword,
listMode.value === 'mark' ? props.waived : '',
listMode.value === 'mark' ? props.followStatus : '',
listMode.value === 'mark' ? props.planDateFrom : '',
listMode.value === 'mark' ? props.planDateTo : ''
].join('\0'),
() => reload()
)
</script>
<style scoped>
.mp-cert-ready-tip {
margin: 8px 12px 0;
padding: 10px 12px;
border-radius: 8px;
background: #ecfdf5;
color: #065f46;
font-size: 12px;
line-height: 1.5;
}
.mp-cert-ready-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 10px 12px 0;
}
.mp-cert-ready-select {
margin-right: 8px;
display: flex;
align-items: flex-start;
padding-top: 2px;
}
.mp-wv-card__head {
align-items: flex-start;
}
</style>
@@ -60,8 +60,8 @@
</article>
</section>
<section v-if="kind === 'transport'" class="mp-wv-section">
<h3>证面引用</h3>
<p v-if="!(cert.purchaseRefs || []).length" class="plan-empty-hint">没有证面引用。</p>
<h3>关联购买证</h3>
<p v-if="!(cert.purchaseRefs || []).length" class="plan-empty-hint">没有关联购买证。</p>
<article v-for="(row, index) in cert.purchaseRefs || []" :key="row.id || index" class="mp-cert-block">
<div class="detail-field-grid">
<div class="mp-wv-kv">
@@ -49,7 +49,7 @@
<section class="mp-wv-section">
<h3>证件附件</h3>
<p class="plan-empty-hint"
>一次仅上传并解析一张证件图片或 PDF。解析只补充还没手工填写的空字段。删除证件附件后,证面信息、绑定合同产品和证面引用会清空。</p
>一次仅上传并解析一张证件图片或 PDF。解析只补充还没手工填写的空字段。删除证件附件后,证面信息、绑定合同产品和关联购买证会清空。</p
>
<AttachmentUploadField
ref="attachments"
@@ -154,17 +154,54 @@
/>
</section>
<section class="mp-wv-section">
<h3>客户与品类</h3>
<p class="plan-empty-hint">
新规则按系统客户选证;购买证还需绑定证用品类。运输证品类由引用的购买证自动汇总。安排时不再依赖合同产品绑定。
</p>
<van-field
:model-value="systemCustomerLabel"
label="系统客户"
label-align="top"
readonly
:is-link="form.status !== 'void' && form.status !== 'closed'"
placeholder="搜索并选择客户"
required
@click="form.status !== 'void' && form.status !== 'closed' && openSystemCustomer()"
/>
<van-field
v-if="kind === 'purchase'"
:model-value="categoryLabel"
label="证用品类"
label-align="top"
readonly
:is-link="form.status !== 'void' && form.status !== 'closed'"
placeholder="选择证用品类"
required
@click="form.status !== 'void' && form.status !== 'closed' && openCategories()"
/>
<van-field
v-else
:model-value="derivedCategoryLabel"
label="适用品类"
label-align="top"
readonly
placeholder="引用购买证并保存后自动汇总"
/>
</section>
<section class="mp-wv-section mp-plan-cert-list">
<h3>绑定合同产品</h3>
<h3>历史:绑定合同产品(旧规则,可选)</h3>
<p v-if="form.bindingsEditable === false" class="plan-empty-hint">
当前仅展示可见合同,绑定范围不可编辑。证面信息仍可保存。
</p>
<p v-else class="plan-empty-hint">
仅兼容旧合同绑证数据。新规则选证以客户+品类为准,可不填。
<template v-if="kind === 'purchase'"
>识别后按购买单位或个人的名称或姓名自动填入名称完全一致的客户。选择合同后带出全部产品。</template
>识别后仍可按购买单位自动匹配客户并带出合同产品。</template
>
<template v-else>运输证识别后,会把匹配购买证上的合同和产品带到这里。</template>
未保存的产品可以移除,已保存的只能设为不可用。点底部保存后写入。<template
未保存的产品可以移除,已保存的只能设为不可用。<template
v-if="hasLockedBinding"
>已有用证,不能增删产品。产品可以设为不可用。</template
>
@@ -172,7 +209,7 @@
<van-field
v-if="form.bindingsEditable !== false && form.status !== 'void' && form.status !== 'closed'"
:model-value="bindingCustomer.customerName"
label="客户名称"
label="合同客户"
label-align="top"
readonly
is-link
@@ -268,7 +305,7 @@
</section>
<section v-if="kind === 'transport'" class="mp-wv-section">
<h3>证面引用</h3>
<h3>关联购买证</h3>
<p v-if="form.referencesEditable === false" class="plan-empty-hint">
已有额度流水。尚未关联购买证档案的引用可以补全;已关联的引用不能修改或移除。
</p>
@@ -380,7 +417,11 @@
teleport="body"
class="mp-cert-form-sheet mp-cert-contract-sheet"
>
<van-nav-bar title="选择客户" left-text="关闭" @click-left="customerOpen = false" />
<van-nav-bar
:title="customerPickMode === 'system' ? '选择系统客户' : '选择合同客户'"
left-text="关闭"
@click-left="customerOpen = false"
/>
<van-search
v-model="customerKeyword"
placeholder="客户名称"
@@ -403,6 +444,31 @@
</button>
</div>
</van-popup>
<van-popup
v-model:show="categoryOpen"
position="bottom"
round
teleport="body"
class="mp-cert-form-sheet mp-cert-contract-sheet"
>
<van-nav-bar title="选择证用品类" left-text="关闭" @click-left="categoryOpen = false" />
<div class="mp-cert-contract-sheet__list">
<p v-if="categoryLoading" class="plan-empty-hint">正在加载品类…</p>
<p v-else-if="!categoryOptions.length" class="plan-empty-hint">暂无启用中的证用品类。</p>
<button
v-for="item in categoryOptions"
:key="item.id"
class="mp-cert-contract-card"
type="button"
@click="pickCategory(item)"
>
<span class="mp-cert-contract-card__no">{{ item.categoryName || '—' }}</span>
<span v-if="item.categoryCode" class="mp-cert-contract-card__meta">{{
item.categoryCode
}}</span>
</button>
</div>
</van-popup>
<van-popup
v-model:show="contractOpen"
position="bottom"
@@ -511,7 +577,7 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { fetchCustomerOptions } from '@/api/crm/customer'
import { fetchCustomerDetail, fetchCustomerOptions } from '@/api/crm/customer'
import { listContract } from '@/api/crm/contract'
import type { ContractVO } from '@/api/crm/contract/types'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
@@ -519,7 +585,9 @@
import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
import PlanDateField from '../plan/PlanDateField.vue'
import {
certCategoryApi,
quotaCertApi,
type CertCategory,
type CertType,
type CertificateContractBinding,
type ContractOption,
@@ -542,6 +610,7 @@
} from '@/views/pl/quota-cert/model'
import {
bindingsFromPurchaseCerts,
exactCategoryMatches,
exactCustomerMatches,
matchPurchaseCert,
scopeProductsFromLines
@@ -618,8 +687,24 @@
const contractOptions = ref<ContractOption[]>([])
const customerOptions = ref<{ id: string; customerName?: string; customerNo?: string }[]>([])
const bindingCustomer = ref<{ id: string; customerName: string }>({ id: '', customerName: '' })
const systemCustomer = ref<{ id: string; customerName: string }>({ id: '', customerName: '' })
const customerPickMode = ref<'system' | 'binding'>('binding')
const categoryOpen = ref(false)
const categoryLoading = ref(false)
const categoryOptions = ref<CertCategory[]>([])
const customerLoading = ref(false)
const linesLoading = ref(false)
const systemCustomerLabel = computed(() => systemCustomer.value.customerName || '')
const categoryLabel = computed(() => {
const id = String(form.value.categoryId || '')
return categoryOptions.value.find((item) => item.id === id)?.categoryName || ''
})
const derivedCategoryLabel = computed(() => {
const ids = (form.value.derivedCategoryIds || []) as string[]
if (!ids.length) return ''
const names = new Map(categoryOptions.value.map((item) => [item.id, item.categoryName]))
return ids.map((id) => names.get(id) || id).join('、')
})
const purchases = ref<QuotaCert[]>([])
const lines = ref<ContractProduct[]>([])
const productNames = ref(new Map<string, string>())
@@ -662,6 +747,7 @@
lockedPlates.value = []
linkedRefIds.value = []
clearBindingCustomer()
clearSystemCustomer()
pendingContract.value = undefined
picked.value = new Set()
lines.value = []
@@ -835,7 +921,7 @@
}
function addReference() {
if (form.value.referencesEditable === false) {
ElMessage.warning('已有额度流水,不能新增或移除证面引用')
ElMessage.warning('已有额度流水,不能新增或移除关联购买证')
return
}
form.value.purchaseRefs = [
@@ -887,6 +973,10 @@
function clearBindingCustomer() {
bindingCustomer.value = { id: '', customerName: '' }
}
function clearSystemCustomer() {
systemCustomer.value = { id: '', customerName: '' }
form.value.customerId = null
}
async function searchCustomers(keyword = '') {
const current = epoch
customerLoading.value = true
@@ -900,39 +990,99 @@
if (current === epoch) customerLoading.value = false
}
}
async function loadCategories() {
categoryLoading.value = true
try {
categoryOptions.value = (await certCategoryApi.list({ status: '1' })) || []
} catch {
categoryOptions.value = []
} finally {
categoryLoading.value = false
}
}
function openCustomers() {
if (saving.value || recognizing.value || linesLoading.value) return
customerPickMode.value = 'binding'
customerKeyword.value = ''
customerOpen.value = true
void searchCustomers('')
}
function openSystemCustomer() {
if (saving.value || recognizing.value) return
customerPickMode.value = 'system'
customerKeyword.value = ''
customerOpen.value = true
void searchCustomers('')
}
function openCategories() {
if (saving.value || recognizing.value) return
categoryOpen.value = true
if (!categoryOptions.value.length) void loadCategories()
}
function pickCustomer(item: { id: string; customerName?: string }) {
bindingCustomer.value = { id: item.id, customerName: item.customerName || '' }
if (customerPickMode.value === 'system') {
systemCustomer.value = { id: item.id, customerName: item.customerName || '' }
form.value.customerId = item.id
touch('customerId')
} else {
bindingCustomer.value = { id: item.id, customerName: item.customerName || '' }
}
customerOpen.value = false
}
async function matchBuyerCustomer(name: string, current: number) {
const buyer = name.trim()
if (!buyer || bindingCustomer.value.id || form.value.bindingsEditable === false) return
function pickCategory(item: CertCategory) {
form.value.categoryId = item.id
touch('categoryId')
categoryOpen.value = false
}
async function matchBuyerCustomer(name: string, current: number, partyLabel = '证面单位') {
const party = name.trim()
if (!party) return
try {
const result = await fetchCustomerOptions({ keyword: buyer, limit: 200 })
if (current !== epoch || bindingCustomer.value.id) return
const matches = exactCustomerMatches(extractCustomers(result), buyer)
const result = await fetchCustomerOptions({ keyword: party, limit: 200 })
if (current !== epoch) return
const matches = exactCustomerMatches(extractCustomers(result), party)
if (matches.length === 1) {
bindingCustomer.value = {
const matched = {
id: String(matches[0].id),
customerName: matches[0].customerName || buyer
customerName: matches[0].customerName || party
}
if (!systemCustomer.value.id) {
systemCustomer.value = matched
form.value.customerId = matched.id
touch('customerId')
}
if (!bindingCustomer.value.id && form.value.bindingsEditable !== false) {
bindingCustomer.value = matched
}
return
}
ElMessage.warning(
matches.length
? '有多个客户与购买单位同名,请手工选择'
: '未找到与购买单位完全一致的客户,请手工选择'
? `有多个客户与${partyLabel}同名,请手工选择`
: `未找到与${partyLabel}完全一致的客户,请手工选择`
)
} catch {
if (current === epoch) ElMessage.warning('客户匹配失败,请手工选择')
}
}
async function matchCertCategory(productName: string, current: number) {
const name = productName.trim()
if (!name || form.value.categoryId || edited.has('categoryId')) return
if (!categoryOptions.value.length) await loadCategories()
if (current !== epoch || form.value.categoryId || edited.has('categoryId')) return
const matches = exactCategoryMatches(categoryOptions.value, name)
if (matches.length === 1) {
form.value.categoryId = matches[0].id
touch('categoryId')
return
}
ElMessage.warning(
matches.length
? '有多个品类与证面品名同名,请手工选择证用品类'
: '未找到与证面品名完全一致的证用品类,请手工选择'
)
}
function toContractOption(row: ContractVO): ContractOption {
return {
id: String(row.id),
@@ -1044,6 +1194,31 @@
pendingContract.value = undefined
productOpen.value = false
}
async function syncSystemCustomerFromForm(cert?: QuotaCert | Record<string, any>) {
const id = String(cert?.customerId || form.value.customerId || '')
if (!id) {
clearSystemCustomer()
return
}
const certRecord = (cert || {}) as Record<string, any>
let name = ''
try {
const detail = (await fetchCustomerDetail({ id })) as Record<string, any>
const data = detail?.data || detail
name = String(data?.customerName || '').trim()
} catch {
name = ''
}
// 系统客户以 CRM 为准;禁止用证面收货单位等缓存名顶替。
if (!name) {
name =
String(certRecord.customerName || '').trim() ||
customerOptions.value.find((item) => item.id === id)?.customerName ||
id
}
systemCustomer.value = { id, customerName: name }
form.value.customerId = id
}
async function load() {
const current = ++epoch
loading.value = true
@@ -1052,6 +1227,7 @@
error.value = ''
originals.clear()
try {
void loadCategories()
if (creating.value) {
const entryMode = form.value.entryMode === 'manual' ? 'manual' : 'image_parse'
form.value = {
@@ -1065,6 +1241,7 @@
recognitionImage.value = ''
contractBriefs.value = new Map()
clearBindingCustomer()
clearSystemCustomer()
if (kind.value === 'transport')
purchases.value = await quotaCertApi.list('purchase').catch(() => [])
return
@@ -1074,7 +1251,9 @@
quotaCertApi.certificateContracts(kind.value, certId.value).catch(() => [])
])
if (current !== epoch) return
contractBriefs.value = new Map(contracts.map((item) => [item.id, item]))
contractBriefs.value = new Map(
contracts.map((item) => [item.id, item] as [string, ContractOption])
)
const names = new Map<string, string>()
await Promise.all(
(cert.contractBindings || []).map(async (binding) => {
@@ -1091,6 +1270,7 @@
...certPayload(kind.value, cert),
status: cert.status,
totalQty: inputQty(cert.totalQty),
derivedCategoryIds: (cert as QuotaCert & { derivedCategoryIds?: string[] }).derivedCategoryIds || [],
contractBindings: (cert.contractBindings || []).map((binding) => ({
...binding,
products: binding.products.map((product) => ({ ...product }))
@@ -1109,6 +1289,7 @@
.map((row) => String(row.id))
: []
clearBindingCustomer()
await syncSystemCustomerFromForm(cert)
if (kind.value === 'transport')
purchases.value = await quotaCertApi.list('purchase').catch(() => [])
} catch {
@@ -1132,6 +1313,14 @@
ElMessage.warning(`请填写${missing}`)
return
}
if (!form.value.customerId) {
ElMessage.warning('请选择系统客户')
return
}
if (kind.value === 'purchase' && !form.value.categoryId) {
ElMessage.warning('请选择证用品类')
return
}
if (attachmentCount(form.value.attachmentUrls) !== 1) {
ElMessage.warning('请保留且仅上传一张证件附件')
return
@@ -1188,6 +1377,7 @@
lockedPlates.value = []
linkedRefIds.value = []
clearBindingCustomer()
clearSystemCustomer()
ElMessage.success('证件已保存,请上传下一张证件')
} else if (props.popup) {
ElMessage.success('证件已保存')
@@ -1210,12 +1400,15 @@
].map((field) => field[0])
const hasContent =
keys.some((key) => form.value[key] != null && form.value[key] !== '') ||
form.value.customerId ||
form.value.categoryId ||
form.value.contractBindings?.length ||
form.value.purchaseRefs?.length ||
form.value.vehicleNumbersJson?.length
if (hasContent) {
try {
await showCrmConfirmDialog({
kind: 'default',
title: '切换证件类型',
message: '切换证件类型将清空证面信息,保留建档方式、附件和系统备注。是否继续?'
})
@@ -1238,6 +1431,7 @@
lockedPlates.value = []
linkedRefIds.value = []
clearBindingCustomer()
clearSystemCustomer()
if (value === 'transport') purchases.value = await quotaCertApi.list('purchase').catch(() => [])
}
function recognitionCurrent(current: number, targetKind: CertType, image: string) {
@@ -1350,9 +1544,15 @@
if (current !== epoch || kind.value !== targetKind || recognitionImage.value !== image) return
if (targetKind === 'purchase') {
const buyer = String(form.value.buyerName || '').trim()
if (buyer) await matchBuyerCustomer(buyer, current)
const productName = String(form.value.productNameOnCert || '').trim()
if (buyer) await matchBuyerCustomer(buyer, current, '购买单位')
if (productName) await matchCertCategory(productName, current)
}
if (targetKind === 'transport') {
const consignee = String(form.value.consigneeName || '').trim()
if (consignee) await matchBuyerCustomer(consignee, current, '收货单位')
await linkRecognizedPurchaseCerts(current, targetKind, image)
}
if (targetKind === 'transport') await linkRecognizedPurchaseCerts(current, targetKind, image)
if (current !== epoch || kind.value !== targetKind || recognitionImage.value !== image) return
ElMessage.success(
Object.keys(result.invalidFields || {}).length
@@ -0,0 +1,83 @@
<template>
<MpWebviewShell @ready="ready = true">
<MpBusinessHeader title="客户用证" back-label="返回运输证件管理" @back="router.back()" />
<main v-if="ready" class="mp-h5-list-content mp-h5-detail-page mp-plan-cert-list">
<p v-if="error" class="mp-wv-empty" role="alert">
{{ error }}
<button class="mp-wv-btn" type="button" @click="retry">重试</button>
</p>
<div v-else-if="!authReady || loading" class="mp-cert-page-loading"><van-loading /></div>
<div v-else-if="!allowed" class="mp-wv-empty">
<div class="mp-wv-empty__title">无证件查看权限</div>
</div>
<template v-else-if="detail">
<section class="mp-wv-section">
<div class="mp-cert-contract-head">
<div class="mp-cert-contract-head__main">
<h3>{{ detail.customerName || '未命名客户' }}</h3>
<p>{{ detail.customerNo || '—' }}</p>
</div>
</div>
<div class="mp-wv-card__kv">
<div class="mp-wv-card__kv-row">
<span>关联证件数</span><span>{{ detail.certCount ?? certificates.length }}</span>
</div>
</div>
</section>
<CustomerCertQuotaTab :customer-id="customerId" :active="true" />
</template>
</main>
</MpWebviewShell>
</template>
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { fetchGetMenuCodes } from '@/api/auth'
import { quotaStatisticsApi, type CustomerStatistics } from '@/api/pl/quota-cert'
import CustomerCertQuotaTab from '@/views/m/crm/contract/CustomerCertQuotaTab.vue'
const route = useRoute()
const router = useRouter()
const ready = ref(false)
const authReady = ref(false)
const allowed = ref(false)
const loading = ref(true)
const error = ref('')
const detail = ref<CustomerStatistics | null>(null)
const customerId = computed(() => String(route.params.id || '').trim())
const certificates = computed(() => detail.value?.certificates || [])
async function load() {
const id = customerId.value
if (!id) {
error.value = '缺少客户 ID'
loading.value = false
return
}
loading.value = true
error.value = ''
try {
const permissions = await fetchGetMenuCodes()
const codes = permissions?.menuCodes || []
allowed.value =
!!permissions?.all || !!permissions?.isSuperAdmin || codes.includes('pl:quota-cert:list')
authReady.value = true
if (!allowed.value) {
loading.value = false
return
}
detail.value = await quotaStatisticsApi.customer(id)
} catch (e: any) {
detail.value = null
error.value = e?.message || '加载失败'
} finally {
loading.value = false
}
}
function retry() {
void load()
}
watch(customerId, () => void load(), { immediate: true })
</script>
@@ -44,7 +44,16 @@ export function certFaceGroups(
: display(cert, field[0]),
wide: longFaceKeys.has(field[0]) || field[0] === 'vehicleNumbersJson'
}))
if (index === 0) fields.unshift({ label: '状态', value: display(cert, 'status') })
if (index === 0) {
fields.unshift(
{ label: '状态', value: display(cert, 'status') },
{
label: '系统客户',
value: String(cert.customerName || '').trim() || '—',
wide: true
}
)
}
return { title: group.title, fields }
})
groups[groups.length - 1].fields.push({
+1 -1
View File
@@ -183,7 +183,7 @@
const committed = ref('')
const CERT_TABS = [
{ name: 'archive', title: '证件建档', perm: 'mini:pl:quota-cert:list' },
{ name: 'contract', title: '合同用证', perm: 'mini:pl:contract-quota:list' },
{ name: 'contract', title: '客户用证', perm: 'mini:pl:contract-quota:list' },
{ name: 'usage', title: '证件用量', perm: 'mini:pl:certificate-usage:list' },
{ name: 'plan', title: '计划段次用证', perm: 'mini:pl:plan-cert:segment' },
{ name: 'ready', title: '证书齐备', perm: 'mini:pl:cert-ready:list' }
@@ -19,6 +19,10 @@
<div
><small>联系方式</small><span>{{ stop.contact || '—' }}</span></div
>
<div
><small>是否同市</small
><span>{{ Number(stop.sameCity) === 1 ? '是(同市只需购买证)' : '否' }}</span></div
>
<div class="wide"
><small>{{ title }}地址</small><span>{{ displayAddress || '—' }}</span></div
>
+117 -2
View File
@@ -248,7 +248,37 @@
</section>
<section id="plan-sec-cert" class="mp-wv-section mp-h5-anchor">
<h3>危化品证书信息</h3>
<h3>关联购买证 / 运输证</h3>
<p v-if="plan.quotaCertPendingFulfill" class="plan-empty-hint">
证书齐备(待完善):请到「证书齐备 → 已标记待完善证书」挂证并完善并关闭
</p>
<div v-else-if="linkedCertRows.length" class="plan-linked-certs">
<div v-for="item in linkedCertRows" :key="item.key" class="plan-linked-cert-block">
<div class="plan-linked-cert-block__meta">
<span
class="status-tag"
:class="
item.kind === 'purchase' ? 'status-tag--success' : 'status-tag--warning'
"
>{{ item.kind === 'purchase' ? '购买证' : '运输证' }}</span
>
<span class="plan-linked-cert-block__no">{{ item.certNo || item.certId }}</span>
<span class="plan-vehicle-card__meta"
>预占 {{ item.reservedQty }} / 已用 {{ item.usedQty }}</span
>
</div>
<div class="plan-linked-cert-block__slot">
<div class="plan-subcard__label">证件附件</div>
<AttachmentUploadField
v-if="item.attachmentUrls"
:model-value="item.attachmentUrls"
disabled
/>
<p v-else class="plan-linked-cert-block__missing">未上传</p>
</div>
</div>
</div>
<p v-else class="plan-empty-hint">暂无关联证件</p>
<div class="plan-cert-actions">
<van-button block plain type="primary" @click="quotaOpen = true">查看用证</van-button>
<van-button
@@ -536,10 +566,40 @@
const errorText = ref('')
const plan = ref<Plan | null>(null)
const quotaOpen = ref(false)
const linkedCertRows = ref<
Array<{
key: string
kind: string
certId: string
certNo?: string
reservedQty: string | number
usedQty: string | number
attachmentUrls: string
}>
>([])
const needsLegalUseDocument = computed(() =>
(plan.value?.products || []).some((row) => resolveProductHazardFlags(row).isExplosive)
)
const quotaTitle = computed(() => `计划用证 · ${plan.value?.planNo || ''}`)
async function loadLinkedCerts(planId: string) {
linkedCertRows.value = []
try {
const { planQuotaApi } = await import('@/api/pl/quota-cert')
const data = await planQuotaApi.get(planId)
linkedCertRows.value = (data?.usages || []).map((u: any, index: number) => ({
key: `${u.certType || u.cert_type}-${u.certId || u.cert_id}-${index}`,
kind: String(u.certType || u.cert_type || ''),
certId: String(u.certId || u.cert_id || ''),
certNo: u.certNo || u.cert_no || u.certNoSnapshot || u.cert_no_snapshot,
reservedQty: u.reservedQty ?? u.reserved_qty ?? '—',
usedQty: u.usedQty ?? u.used_qty ?? '—',
attachmentUrls: String(u.attachmentUrls || u.attachment_urls || '').trim()
}))
} catch {
linkedCertRows.value = []
}
}
const deliveryIndex = ref(0)
const moreOpen = ref(false)
const voidOpen = ref(false)
@@ -568,7 +628,7 @@
{ key: 'base', label: '基础信息', anchor: 'plan-sec-base' },
{ key: 'transport', label: '运输信息', anchor: 'plan-sec-transport' },
{ key: 'load', label: '装送货', anchor: 'plan-sec-load' },
{ key: 'cert', label: '危化品证', anchor: 'plan-sec-cert' },
{ key: 'cert', label: '关联证件', anchor: 'plan-sec-cert' },
{ key: 'freight', label: '运费', anchor: 'plan-sec-freight' },
{ key: 'remark', label: '备注', anchor: 'plan-sec-remark' }
]
@@ -751,6 +811,7 @@
loadVersion++
actionVersion++
plan.value = null
linkedCertRows.value = []
readDetail.value = null
partialErrors.value = []
errorText.value = ''
@@ -807,9 +868,12 @@
readDetail.value = detail
partialErrors.value = [...detail.errors, ...(arrangeFailed ? ['安排信息'] : [])]
plan.value = data
await loadLinkedCerts(id)
if (!current()) return
} catch {
if (!current()) return
plan.value = null
linkedCertRows.value = []
resetArrangeActions()
errorText.value = '计划详情加载失败或记录不存在,请重试'
} finally {
@@ -1516,6 +1580,57 @@
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 12px;
}
.plan-linked-certs {
display: flex;
flex-direction: column;
gap: 12px;
}
.plan-linked-cert-block {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border: 1px solid #d9e8e2;
border-radius: 10px;
background: #fff;
}
.plan-linked-cert-block__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
}
.plan-linked-cert-block__no {
font-size: 14px;
font-weight: 600;
color: #1e293b;
word-break: break-all;
}
.plan-linked-cert-block__slot {
display: flex;
flex-direction: column;
gap: 8px;
}
.plan-linked-cert-block__missing {
display: flex;
align-items: center;
justify-content: center;
width: 140px;
height: 96px;
margin: 0;
border: 1px dashed #d9e8e2;
border-radius: 8px;
background: #f8fafc;
color: #64748b;
font-size: 12px;
}
.mp-plan-quota-sheet {
@@ -0,0 +1,319 @@
<!-- 证用品类 — 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-icon><Edit /></el-icon>编辑
</el-button>
<el-button link type="danger" @click="remove(row)">
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
</el-table-column>
</ListTable>
</div>
</section>
<el-dialog
v-model="visible"
class="dialog-form-layout"
:title="editingId ? '编辑证用品类' : '新增证用品类'"
width="720px"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<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="关联系统产品">
<div class="cert-category-products">
<div class="cert-category-products__toolbar">
<span class="selected-hint">已选 {{ linkedProducts.length }} 项</span>
<el-button type="primary" @click="productSelectorVisible = true">
<el-icon><Plus /></el-icon>添加产品
</el-button>
</div>
<el-table
v-if="linkedProducts.length"
:data="linkedProducts"
stripe
class="list-table"
row-key="productId"
max-height="280"
>
<el-table-column prop="productNo" label="产品编号" min-width="120">
<template #default="{ row }">{{ row.productNo || '—' }}</template>
</el-table-column>
<el-table-column prop="productName" label="产品名称" min-width="160">
<template #default="{ row }">{{ row.productName || row.productId }}</template>
</el-table-column>
<el-table-column prop="model" label="规格型号" min-width="120">
<template #default="{ row }">{{ row.model || '—' }}</template>
</el-table-column>
<el-table-column label="操作" min-width="100" fixed="right">
<template #default="{ $index }">
<el-button link type="danger" @click="removeLinked($index)">
<el-icon><Delete /></el-icon>移除
</el-button>
</template>
</el-table-column>
</el-table>
<p v-else class="form-tip">暂未关联系统产品</p>
</div>
</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>
<ProductSelector
:visible="productSelectorVisible"
@update:visible="productSelectorVisible = $event"
@confirm="onProductsConfirm"
/>
</div>
</template>
<script setup lang="ts">
import { certCategoryApi, type CertCategory } from '@/api/pl/quota-cert'
import { fetchProductDetail } from '@/api/crm/product'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ProductSelector from '@/components/business/product-selector/index.vue'
import { Check, Close, Delete, Edit, Plus, Refresh } from '@element-plus/icons-vue'
type LinkedProduct = {
id?: string
productId: string
productNo?: string | null
productName?: string | null
model?: string | null
scopeStatus: 'active' | 'inactive'
}
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 linkedProducts = ref<LinkedProduct[]>([])
const productSelectorVisible = ref(false)
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 enrichMissingNames(items: LinkedProduct[]) {
const missing = items.filter((item) => item.productId && !item.productName)
if (!missing.length) return items
await Promise.all(
missing.map(async (item) => {
try {
const res = (await fetchProductDetail({ id: item.productId })) as Record<string, any>
const data = res?.data || res
item.productNo = data?.productNo || item.productNo
item.productName = data?.productName || item.productName
item.model = data?.model || item.model
} catch {
/* 保留 productId 回显 */
}
})
)
return items
}
async function openEdit(row?: CertCategory) {
editingId.value = row?.id || ''
form.categoryName = row?.categoryName || ''
form.categoryCode = row?.categoryCode || ''
form.status = row?.status || '1'
linkedProducts.value = []
if (row?.id) {
const detail = await certCategoryApi.get(row.id)
const products = (detail.products || [])
.filter((p) => p.scopeStatus !== 'inactive')
.map((p) => ({
id: p.id,
productId: p.productId,
productNo: p.productNo,
productName: p.productName,
model: p.model,
scopeStatus: 'active' as const
}))
linkedProducts.value = await enrichMissingNames(products)
}
visible.value = true
}
function onProductsConfirm(products: Array<Record<string, any>>) {
const existing = new Map(linkedProducts.value.map((item) => [item.productId, item]))
let added = 0
let skipped = 0
for (const product of products || []) {
const productId = String(product.id || '').trim()
if (!productId) continue
if (existing.has(productId)) {
skipped += 1
continue
}
const row: LinkedProduct = {
productId,
productNo: product.productNo || null,
productName: product.productName || null,
model: product.model || null,
scopeStatus: 'active'
}
existing.set(productId, row)
linkedProducts.value.push(row)
added += 1
}
productSelectorVisible.value = false
if (added) ElMessage.success(`已添加 ${added} 个产品`)
else if (skipped) ElMessage.warning('所选产品均已关联')
}
function removeLinked(index: number) {
linkedProducts.value.splice(index, 1)
}
async function save() {
if (!form.categoryName.trim()) {
ElMessage.warning('请填写品类名称')
return
}
const body = {
categoryName: form.categoryName.trim(),
categoryCode: form.categoryCode || null,
status: form.status,
products: linkedProducts.value.map((item) => ({
...(item.id ? { id: item.id } : {}),
productId: item.productId,
scopeStatus: item.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>
<style scoped lang="scss">
.cert-category-products {
width: 100%;
}
.cert-category-products__toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
}
.selected-hint {
color: var(--el-text-color-secondary);
font-size: 13px;
}
</style>
+133 -19
View File
@@ -2,6 +2,10 @@
<div class="page-container">
<section class="panel">
<div class="panel-top"><h2 class="title-bar-heading">证书齐备</h2></div>
<el-tabs v-model="listMode" class="cert-ready-tabs" @tab-change="onModeChange">
<el-tab-pane label="待标记齐备" name="mark" />
<el-tab-pane label="已标记待完善证书" name="fulfill" />
</el-tabs>
<div class="list-toolbar">
<div class="list-toolbar-row">
<ListQuickSearchInput
@@ -10,6 +14,21 @@
@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="listMode === 'fulfill'" @click="goUploadCert('purchase')">
<el-icon><Upload /></el-icon>上传购买证
</el-button>
<el-button v-if="listMode === 'fulfill'" @click="goUploadCert('transport')">
<el-icon><Upload /></el-icon>上传运输证
</el-button>
<el-button v-if="keyword || hasActiveFilters" @click="reset">
<el-icon><RefreshLeft /></el-icon>清除筛选
</el-button>
@@ -29,6 +48,7 @@
row-key="id"
stripe
class="list-table"
@selection-change="onSelectionChange"
>
<el-table-column
v-for="column in columns"
@@ -73,17 +93,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>
@@ -117,13 +154,25 @@
getPlPlanFollowStatusLabel,
getPlPlanFollowStatusTagType
} from '@/utils/pl-display'
import { CircleCheck, Refresh, RefreshLeft } from '@element-plus/icons-vue'
import { CircleCheck, Refresh, RefreshLeft, Upload } from '@element-plus/icons-vue'
const router = useRouter()
function goUploadCert(kind: 'purchase' | 'transport') {
void router.push({
path: '/pl/cert-upload/quota-cert',
query: { kind, create: '1' }
})
}
const keyword = ref('')
const page = ref(1)
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 +252,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 +282,12 @@
void load()
}
function onModeChange() {
page.value = 1
selectedIds.value = []
void load()
}
function reset() {
keyword.value = ''
if (hasActiveFilters.value) clearAllFilters()
@@ -234,9 +297,9 @@
async function mark(row: CertReadyPlan) {
try {
await ElMessageBox.confirm(
'确认后,这条运输计划在安排和归档时都不再校验购买证、运输证,也不扣减额度。确认人和时间会记在本行。',
'证书齐备',
{ type: 'warning', confirmButtonText: '确认齐备', cancelButtonText: '取消' }
'确认后可先安排运输;新规则下归档前仍须到「已标记待完善证书」页上传证件并执行完善并关闭。确认人和时间会记在本行。',
'标记齐备',
{ type: 'warning', confirmButtonText: '确认标记', cancelButtonText: '取消' }
)
} catch {
return
@@ -253,6 +316,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()
@@ -267,3 +366,18 @@
void load()
})
</script>
<style scoped lang="scss">
.cert-ready-tabs {
margin: 0 0 4px;
padding: 0 24px;
:deep(> .el-tabs__header) {
margin-bottom: 0;
}
:deep(> .el-tabs__content) {
display: none;
}
}
</style>
@@ -1,8 +1,8 @@
<template><StatisticsPanel mode="contract" v-bind="props" /></template>
<template><CustomerQuotaPanel v-bind="props" /></template>
<script setup lang="ts">
import StatisticsPanel from '../quota-statistics/StatisticsPanel.vue'
import CustomerQuotaPanel from './CustomerQuotaPanel.vue'
const props = withDefaults(
defineProps<{ contractId?: string; embedded?: boolean; active?: boolean }>(),
defineProps<{ customerId?: string; embedded?: boolean; active?: boolean }>(),
{ active: true }
)
</script>
@@ -0,0 +1,297 @@
<template>
<div class="dialog-list-panel customer-cert-quota-panel">
<div class="list-toolbar">
<div class="list-toolbar-row contract-quota-toolbar">
<span class="contract-quota-toolbar__tip"
>展示该客户全部购买证、运输证(证件关联客户,不再按合同绑定)。额度按单张证件核算,多个合同共用共享余额。</span
>
<el-button :loading="loading" @click="load"
><el-icon><Refresh /></el-icon>刷新</el-button
>
</div>
</div>
<el-alert v-if="error" :title="error" type="error" :closable="false" class="mb-3"
><el-button @click="load"><el-icon><Refresh /></el-icon>重试</el-button></el-alert
>
<div v-loading="loading">
<el-empty
v-if="!customerId"
description="合同未关联客户,无法展示证件"
/>
<el-empty
v-else-if="!loading && !error && !rows.length"
description="该客户暂无购买证或运输证"
/>
<ListTable
v-else-if="rows.length"
table-key="crm-customer-cert-quota"
:data="rows"
class="list-table"
stripe
empty-text="该客户暂无购买证或运输证"
>
<el-table-column type="index" label="序号" width="68" align="center" header-align="center" />
<el-table-column label="证种" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag size="small" :type="certKindTagType(row.certType)">{{
certKindLabel(row.certType)
}}</el-tag>
</template>
</el-table-column>
<el-table-column label="证号" min-width="160" align="center" header-align="center">
<template #default="{ row }">
<el-link type="primary" @click="openDetail(row)">{{ row.certNo || '—' }}</el-link>
</template>
</el-table-column>
<el-table-column
prop="productNameOnCert"
label="证面品名"
min-width="140"
:show-overflow-tooltip="true"
/>
<el-table-column label="有效次数" min-width="120" align="center" header-align="center">
<template #default="{ row }">
<span>{{ row.validUseText || '—' }}</span>
<el-tag
v-if="row.singleUseConsumed"
class="ml-1"
type="danger"
size="small"
>已使用不可再用</el-tag
>
</template>
</el-table-column>
<el-table-column label="核定" min-width="110" align="right" header-align="center">
<template #default="{ row }">{{ qtyText(row.totalQty, row.quantityUnit) }}</template>
</el-table-column>
<el-table-column label="预占" min-width="110" align="right" header-align="center">
<template #default="{ row }">{{ qtyText(row.reservedQty, row.quantityUnit) }}</template>
</el-table-column>
<el-table-column label="已用" min-width="110" align="right" header-align="center">
<template #default="{ row }">{{ qtyText(row.usedQty, row.quantityUnit) }}</template>
</el-table-column>
<el-table-column label="余量" min-width="110" align="right" header-align="center">
<template #default="{ row }">{{ qtyText(row.availableQty, row.quantityUnit) }}</template>
</el-table-column>
<el-table-column label="有效期" min-width="200" align="center" header-align="center">
<template #default="{ row }"
>{{ row.validFrom || '—' }} 至 {{ row.validTo || '—' }}</template
>
</el-table-column>
<el-table-column label="状态" min-width="110" align="center" header-align="center">
<template #default="{ row }">
<el-tag size="small" :type="statusTagType(row.status)">{{
certStatusLabel(row.status)
}}</el-tag>
</template>
</el-table-column>
</ListTable>
</div>
<el-dialog
v-model="detailVisible"
class="dialog-form-layout"
title="证件详情"
width="720px"
align-center
destroy-on-close
>
<div v-loading="detailLoading" class="customer-cert-detail">
<template v-if="detail">
<div class="customer-cert-detail__tags">
<el-tag size="small" :type="certKindTagType(detail.certType)">{{
certKindLabel(detail.certType)
}}</el-tag>
<el-tag size="small" :type="statusTagType(detail.status)">{{
certStatusLabel(detail.status)
}}</el-tag>
<el-tag v-if="detail.singleUseConsumed" size="small" type="danger"
>已使用不可再用</el-tag
>
</div>
<dl class="customer-cert-detail__kv">
<div v-for="item in detailFields" :key="item.label">
<dt>{{ item.label }}</dt>
<dd>{{ item.value }}</dd>
</div>
</dl>
</template>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { Refresh } from '@element-plus/icons-vue'
import { useDictStore } from '@/store/modules/dict'
import { getProductUnitLabel } from '@/utils/crm-product-display'
import { quotaCertApi, type CertType, type QuotaCert } from '@/api/pl/quota-cert'
import { certKindLabel, certKindTagType } from '@/views/pl/quota-cert/cert-list-face'
import { formatCertQty } from '@/views/pl/quota-cert/model'
type CustomerCertRow = QuotaCert & { certType: CertType }
const props = withDefaults(
defineProps<{ customerId?: string | number | null; active?: boolean }>(),
{ active: true }
)
const dictStore = useDictStore()
const loading = ref(false)
const error = ref('')
const rows = ref<CustomerCertRow[]>([])
const detailVisible = ref(false)
const detailLoading = ref(false)
const detail = ref<CustomerCertRow | null>(null)
let loadEpoch = 0
const detailFields = computed(() => {
const row = detail.value
if (!row) return []
return [
{ label: '证号', value: row.certNo || '—' },
{ label: '证面品名', value: row.productNameOnCert || '—' },
{ label: '有效次数', value: row.validUseText || '—' },
{ label: '核定', value: qtyText(row.totalQty, row.quantityUnit) },
{ label: '预占', value: qtyText(row.reservedQty, row.quantityUnit) },
{ label: '已用', value: qtyText(row.usedQty, row.quantityUnit) },
{ label: '余量', value: qtyText(row.availableQty, row.quantityUnit) },
{ label: '有效期起', value: row.validFrom || '—' },
{ label: '有效期止', value: row.validTo || '—' },
{ label: '状态', value: certStatusLabel(row.status) }
]
})
function unitLabel(unit?: string | null) {
const label = getProductUnitLabel(dictStore.getLabel, unit)
return !label || label === '—' ? '' : label
}
function qtyText(value: unknown, unit?: string | null) {
const text = formatCertQty(value)
const name = unitLabel(unit)
return name ? `${text} ${name}` : text
}
function certStatusLabel(status?: string | null) {
return (
{
draft: '待核对',
active: '证件已启用',
suspended: '证件已停用',
closed: '证件已核销',
void: '证件已作废'
}[status || ''] ||
status ||
'—'
)
}
function statusTagType(status?: string | null) {
if (status === 'active') return 'success'
if (status === 'suspended') return 'warning'
if (status === 'closed' || status === 'void') return 'danger'
return 'info'
}
async function load() {
const customerId = props.customerId == null ? '' : String(props.customerId).trim()
const epoch = ++loadEpoch
if (!customerId) {
rows.value = []
error.value = ''
loading.value = false
return
}
loading.value = true
error.value = ''
try {
const [purchase, transport] = await Promise.all([
quotaCertApi.list('purchase', { customerId }),
quotaCertApi.list('transport', { customerId })
])
if (epoch !== loadEpoch) return
rows.value = [
...(purchase || []).map((item) => ({ ...item, certType: 'purchase' as const })),
...(transport || []).map((item) => ({ ...item, certType: 'transport' as const }))
].sort((a, b) => String(b.createTime || '').localeCompare(String(a.createTime || '')))
} catch (e: any) {
if (epoch !== loadEpoch) return
rows.value = []
error.value = e?.message || '加载客户证件失败'
} finally {
if (epoch === loadEpoch) loading.value = false
}
}
async function openDetail(row: CustomerCertRow) {
detailVisible.value = true
detailLoading.value = true
detail.value = null
try {
const data = await quotaCertApi.get(row.certType, row.id)
detail.value = { ...data, certType: row.certType }
} catch (e: any) {
detailVisible.value = false
ElMessage.error(e?.message || '加载证件详情失败')
} finally {
detailLoading.value = false
}
}
watch(
() => [props.customerId, props.active],
([, active]) => {
if (active) void load()
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.contract-quota-toolbar {
width: 100%;
justify-content: space-between;
gap: 12px;
}
.contract-quota-toolbar__tip {
color: var(--mint-text-muted, #64748b);
font-size: 12px;
line-height: 1.5;
}
.customer-cert-detail__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.customer-cert-detail__kv {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
margin: 0;
div {
display: grid;
gap: 4px;
}
dt {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
}
dd {
margin: 0;
font-size: 14px;
color: var(--mint-text, #0f172a);
}
}
.ml-1 {
margin-left: 4px;
}
</style>
@@ -0,0 +1,296 @@
<template>
<section :class="embedded ? 'dialog-list-panel' : 'panel'">
<div v-if="!embedded" class="panel-top"><h2 class="title-bar-heading">客户用证</h2></div>
<div v-if="!customerId" class="list-toolbar">
<div class="list-toolbar-row cert-list-toolbar-row">
<ListQuickSearchInput
v-model="keyword"
placeholder="客户编号 / 名称"
@search="search"
@clear="search"
/>
<el-button v-if="keyword || hasActiveFilters" @click="reset"
><el-icon><RefreshLeft /></el-icon>清除筛选</el-button
>
<ListToolbarEndActions
:loading="loading"
:column-defs="columns"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="load"
@reset-columns="resetToDefault"
/>
</div>
</div>
<div v-if="customerId" class="list-toolbar">
<div class="list-toolbar-row contract-quota-toolbar">
<span class="contract-quota-toolbar__tip"
>展示该客户全部购买证、运输证;额度按单张证件核算,多个合同共用共享余额。</span
>
</div>
</div>
<el-alert v-if="error" :title="error" type="error" :closable="false"
><el-button @click="allowed ? load() : init()"
><el-icon><Refresh /></el-icon>重试</el-button
></el-alert
>
<div v-if="customerId" v-loading="loading">
<CustomerCertQuotaPanel
v-if="fixedCustomerId"
:customer-id="fixedCustomerId"
:active="active && !loading"
/>
<el-empty
v-else-if="!loading && !error"
:description="allowed ? '客户不存在或无权查看' : '无证件查看权限'"
/>
</div>
<div v-else class="list-body list-wrap">
<div ref="tableWrapRef" class="table-scroll">
<ListTable
ref="tableRef"
table-key="pl-customer-quota-statistics"
:height="tableHeight"
:data="rows"
row-key="id"
stripe
class="list-table"
v-loading="loading"
@expand-change="expand"
>
<el-table-column type="expand">
<template #default="{ row }">
<div
v-loading="details[row.id]?.loading"
class="view-section statistics-expansion"
:style="expansionStyle"
>
<el-alert
v-if="details[row.id]?.error"
title="详情加载失败"
type="error"
:closable="false"
><el-button @click="loadDetail(row)"
><el-icon><Refresh /></el-icon>重试</el-button
></el-alert
>
<CustomerCertQuotaPanel
v-else-if="details[row.id]?.ready"
:customer-id="row.id"
:active="true"
/>
</div>
</template>
</el-table-column>
<el-table-column
v-for="column in visibleDefs"
:key="column.prop"
:prop="column.prop"
:label="column.label"
:min-width="column.type === 'text' ? 170 : 130"
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">{{ column.label }}</span>
<ColumnHeaderFilter
:prop="column.prop"
:label="column.label"
:type="column.type"
:enable-sort="false"
:options="column.enumOptions || []"
:model-value="filters[column.prop] || null"
:is-active="isFilterActive(column.prop)"
@update:model-value="(v) => setFilterState(column.prop, v)"
/>
</div>
</template>
<template #default="{ row }">
<span>{{ row[column.prop] ?? '—' }}</span>
</template>
</el-table-column>
</ListTable>
</div>
<div ref="pagerRef" class="pagination list-pager-bar">
<el-pagination
v-model:current-page="page"
:page-size="20"
:total="total"
layout="total, prev, pager, next"
@current-change="load"
/>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { Refresh, RefreshLeft } from '@element-plus/icons-vue'
import { fetchGetMenuCodes } from '@/api/auth'
import { useUserStore } from '@/store/modules/user'
import { quotaStatisticsApi } from '@/api/pl/quota-cert'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { useServerColumnFilters } from '@/composables/useServerColumnFilters'
import { useListTableViewportHeight } from '@/composables/useListTableViewportHeight'
import type { ColumnFilterDef } from '@/composables/useClientColumnFilters'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ColumnHeaderFilter from '@/components/business/column-header-filter/index.vue'
import CustomerCertQuotaPanel from './CustomerCertQuotaPanel.vue'
const props = withDefaults(
defineProps<{ customerId?: string; embedded?: boolean; active?: boolean }>(),
{ active: true }
)
const user = useUserStore()
const allowed = ref(false)
const loading = ref(false)
const error = ref('')
const keyword = ref('')
const page = ref(1)
const total = ref(0)
const rows = ref<Record<string, any>[]>([])
const fixedCustomerId = ref('')
const details = reactive<Record<string, { loading: boolean; error?: boolean; ready?: boolean }>>(
{}
)
let epoch = 0
const text = (prop: string, label: string): ColumnFilterDef => ({
prop,
label,
type: 'text',
queryKey: prop
})
const number = (prop: string, label: string): ColumnFilterDef => ({
prop,
label,
type: 'number',
queryKey: prop
})
const columns: ColumnFilterDef[] = [
text('customerNo', '客户编号'),
text('customerName', '客户名称'),
number('certCount', '关联证件数')
]
const {
filters,
hasActiveFilters,
isFilterActive,
setFilterState,
clearAllFilters,
toQueryParams,
watchVisibleColumns
} = useServerColumnFilters(columns, { onChange: search })
const { visibleColumns, checkAll, isIndeterminate, visibleDefs, resetToDefault } =
usePersistedListColumns('pl-customer-quota-columns-v1', columns)
watchVisibleColumns(visibleColumns)
const tableWrapRef = ref<HTMLElement>()
const pagerRef = ref<HTMLElement>()
const tableRef = ref()
const { tableHeight, expansionStyle } = useListTableViewportHeight({
wrapRef: tableWrapRef,
pagerRef,
tableRef
})
async function init() {
try {
const p = await fetchGetMenuCodes()
allowed.value =
!!p?.all || !!p?.isSuperAdmin || (p?.menuCodes || []).includes('pl:quota-cert:list')
if (allowed.value) await load()
else error.value = '无证件查看权限'
} catch {
error.value = '权限加载失败'
}
}
function search() {
page.value = 1
void load()
}
function reset() {
keyword.value = ''
clearAllFilters()
search()
}
async function load() {
if (!props.active) return
const current = ++epoch
loading.value = true
error.value = ''
try {
if (props.customerId) {
const data = await quotaStatisticsApi.customer(String(props.customerId))
if (current !== epoch) return
fixedCustomerId.value = data?.id || String(props.customerId)
rows.value = []
total.value = 0
} else {
fixedCustomerId.value = ''
const data = await quotaStatisticsApi.customers({
page: page.value,
pageSize: 20,
keyword: keyword.value || undefined,
...toQueryParams()
})
if (current !== epoch) return
rows.value = data?.list || []
total.value = data?.total || 0
}
} catch (e: any) {
if (current !== epoch) return
error.value = e?.message || '加载失败'
rows.value = []
total.value = 0
fixedCustomerId.value = ''
} finally {
if (current === epoch) loading.value = false
}
}
function expand(row: { id: string }, expanded: unknown[]) {
const open = Array.isArray(expanded)
? expanded.some((item: any) => item?.id === row.id)
: Boolean(expanded)
if (open) void loadDetail(row)
}
async function loadDetail(row: { id: string }) {
details[row.id] = { loading: true, error: false, ready: false }
try {
await quotaStatisticsApi.customer(row.id)
details[row.id] = { loading: false, ready: true }
} catch {
details[row.id] = { loading: false, error: true }
}
}
watch(
() => [props.customerId, props.active, JSON.stringify([user.accessToken, user.getUserInfo])],
() => {
if (props.active) void init()
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.contract-quota-toolbar {
width: 100%;
}
.contract-quota-toolbar__tip {
color: var(--mint-text-muted, #64748b);
font-size: 12px;
line-height: 1.5;
}
.statistics-expansion {
padding: 8px 12px 16px;
}
</style>
@@ -3,6 +3,7 @@ import { test } from 'node:test'
import {
bindingsFromPurchaseCerts,
contractsForCustomer,
exactCategoryMatches,
exactCustomerMatches,
matchPurchaseCert,
scopeProductsFromLines
@@ -22,6 +23,19 @@ test('购买单位只命中名称完全一致的客户', () => {
assert.equal(exactCustomerMatches(customers, '腾龙源').length, 0)
})
test('证面品名只命中名称完全一致的证用品类', () => {
const categories = [
{ id: 'acid98', categoryName: '98% 工业硫酸' },
{ id: 'acid', categoryName: ' 硫酸 ' },
{ id: 'other', categoryName: '盐酸' }
]
assert.deepEqual(
exactCategoryMatches(categories, '硫酸').map((item) => item.id),
['acid']
)
assert.deepEqual(exactCategoryMatches(categories, ' '), [])
assert.equal(exactCategoryMatches(categories, '工业硫酸').length, 0)
})
test('选定客户后只保留该客户的合同', () => {
const contracts = [
{ id: 'a', customerId: 'c1', customerName: '安庆市腾龙源科技有限公司' },
@@ -22,6 +22,19 @@ export function exactCustomerMatches<T extends NamedCustomer>(
return customers.filter((item) => (item.customerName || '').trim() === name)
}
export interface NamedCategory {
categoryName?: string | null
}
/** 证用品类名与证面品名去首尾空白后完全一致才算命中。 */
export function exactCategoryMatches<T extends NamedCategory>(
categories: T[],
productNameOnCert: string
): T[] {
const name = productNameOnCert.trim()
if (!name) return []
return categories.filter((item) => (item.categoryName || '').trim() === name)
}
export interface NamedContract {
customerId?: string | null
customerName?: string | null
+324 -118
View File
@@ -93,14 +93,13 @@
><el-icon><Edit /></el-icon>编辑</el-button
>
<el-button
v-if="canEdit && row.status !== 'void' && row.status !== 'closed'"
v-if="canEdit && row.openingConfirmed && row.status !== 'void' && row.status !== 'closed'"
size="small"
@click="openConfirm(row)"
><el-icon><Check /></el-icon
>{{ row.openingConfirmed ? '复核' : '期初确认' }}</el-button
><el-icon><Check /></el-icon>复核</el-button
>
<el-button size="small" @click="showContracts(row)"
><el-icon><Connection /></el-icon>关联合同</el-button
<el-button size="small" @click="view(row)"
><el-icon><View /></el-icon>查看</el-button
>
</template></el-table-column
>
@@ -172,7 +171,7 @@
<template #beside>
<div class="cert-upload-notes">
<p>一次仅上传并解析一张证件图片或 PDF;保存成功后自动清空,继续处理下一张</p>
<p>删除证件附件后,证面信息、绑定合同产品和证面引用会清空</p>
<p>删除证件附件后,证面信息、绑定合同产品和关联购买证会清空</p>
<p v-if="form.entryMode === 'image_parse' || !!form.attachmentUrls"
>请核对证号、数量、单位、有效次数和引用关系。解析只补充未编辑的空字段。</p
>
@@ -309,81 +308,119 @@
><el-input v-model="form.remark" type="textarea" /></el-form-item></section></div
><div class="cert-relations-panel">
<section class="view-section">
<h3 class="dialog-section-title">绑定合同产品</h3>
<p v-if="form.bindingsEditable === false" class="form-tip">
当前仅展示可见合同,绑定范围不可编辑;证面信息仍可保存。
<h3 class="dialog-section-title">客户与品类</h3>
<p class="form-tip">
新规则按系统客户选证;购买证还需绑定证用品类。运输证品类由引用的购买证自动汇总。安排时不再依赖合同产品绑定。
</p>
<p v-else class="form-tip">
<template v-if="formKind === 'purchase'"
>购买证识别后,按购买单位或个人的名称或姓名精确匹配客户。</template
<el-form-item label="系统客户" required>
<el-select
v-model="form.customerId"
filterable
remote
clearable
:remote-method="searchCustomers"
:loading="customerLoading"
placeholder="搜索并选择客户"
class="w-full"
@change="onCustomerChange"
>
<template v-else
>运输证识别后,购买许可/备案/出口许可证号与购买证证号完全一致时,自动关联购买证档案,并带出该购买证已绑定的合同和产品。</template
>
选择合同后带出全部产品,可删除不需要的行。已保存的产品不能删除,只能设为不可用。点下方保存会一并写入合同产品和证面引用。
</p>
<CertContractBinder
v-if="form.bindingsEditable !== false"
ref="purchaseBinder"
:key="`${bindingEpoch}-${formBindingKey}`"
mode="create"
editable
hide-save
:saving="saving"
:taken-contract-ids="formContractIds"
@save="acceptFormBinding"
/>
<div
v-if="(form.contractBindings || []).length"
class="cert-relation-list cert-contract-list"
>
<article
v-for="(row, index) in form.contractBindings || []"
:key="row.id || row.contractId || index"
class="cert-relation-card"
>
<header class="cert-relation-card__head">
<span class="cert-relation-card__title">{{ contractHeading(row) }}</span>
<el-tag
:type="row.linkStatus === 'inactive' ? 'info' : 'success'"
size="small"
>{{ row.linkStatus === 'inactive' ? '已停用' : '启用' }}</el-tag
>
<el-button
v-if="form.bindingsEditable !== false"
class="cert-relation-card__remove"
link
type="danger"
title="移除合同"
aria-label="移除合同"
:disabled="saving || row.editLocked"
@click="removeContractBinding(index)"
><el-icon><Delete /></el-icon
></el-button>
</header>
<p v-if="row.editLocked" class="form-tip">
已有用证,不能增删产品。产品可以设为不可用,历史用量仍保留。
</p>
<CertContractBinder
v-if="row.contractId"
:ref="(el) => registerBoundBinder(row.contractId, el)"
mode="bound"
hide-save
:contract-id="row.contractId"
:contract-no="bindingContract(row.contractId)?.contractNo"
:contract-name="bindingContract(row.contractId)?.contractName"
:saved-products="row.products"
:editable="form.bindingsEditable !== false"
:availability-only="!!row.editLocked"
:allow-restore="form.status !== 'void' && form.status !== 'closed'"
:saving="saving"
@save="acceptFormBinding"
<el-option
v-for="item in customerOptions"
:key="item.id"
:label="item.customerName"
:value="item.id"
/>
</article>
</div>
</el-select>
</el-form-item>
<el-form-item v-if="formKind === 'purchase'" label="证用品类" required>
<el-select
v-model="form.categoryId"
filterable
clearable
placeholder="选择证用品类"
class="w-full"
>
<el-option
v-for="item in categoryOptions"
:key="item.id"
:label="item.categoryName"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item v-else label="适用品类">
<span v-if="derivedCategoryLabels.length">{{
derivedCategoryLabels.join('、')
}}</span>
<span v-else class="form-tip">引用购买证并保存后,按购买证品类自动汇总</span>
</el-form-item>
</section>
<el-collapse class="cert-legacy-contract-collapse">
<el-collapse-item title="历史:绑定合同产品(旧规则,可选)" name="legacy">
<p class="form-tip">仅兼容旧合同绑证数据。新规则选证以客户+品类为准,可不填。</p>
<CertContractBinder
v-if="form.bindingsEditable !== false"
ref="purchaseBinder"
:key="`${bindingEpoch}-${formBindingKey}`"
mode="create"
editable
hide-save
:saving="saving"
:taken-contract-ids="formContractIds"
@save="acceptFormBinding"
/>
<div
v-if="(form.contractBindings || []).length"
class="cert-relation-list cert-contract-list"
>
<article
v-for="(row, index) in form.contractBindings || []"
:key="row.id || row.contractId || index"
class="cert-relation-card"
>
<header class="cert-relation-card__head">
<span class="cert-relation-card__title">{{ contractHeading(row) }}</span>
<el-tag
:type="row.linkStatus === 'inactive' ? 'info' : 'success'"
size="small"
>{{ row.linkStatus === 'inactive' ? '已停用' : '启用' }}</el-tag
>
<el-button
v-if="form.bindingsEditable !== false"
class="cert-relation-card__remove"
link
type="danger"
title="移除合同"
aria-label="移除合同"
:disabled="saving || row.editLocked"
@click="removeContractBinding(index)"
><el-icon><Delete /></el-icon
></el-button>
</header>
<p v-if="row.editLocked" class="form-tip">
已有用证,不能增删产品。产品可以设为不可用,历史用量仍保留。
</p>
<CertContractBinder
v-if="row.contractId"
:ref="(el) => registerBoundBinder(row.contractId, el)"
mode="bound"
hide-save
:contract-id="row.contractId"
:contract-no="bindingContract(row.contractId)?.contractNo"
:contract-name="bindingContract(row.contractId)?.contractName"
:saved-products="row.products"
:editable="form.bindingsEditable !== false"
:availability-only="!!row.editLocked"
:allow-restore="form.status !== 'void' && form.status !== 'closed'"
:saving="saving"
@save="acceptFormBinding"
/>
</article>
</div>
</el-collapse-item>
</el-collapse>
<section v-if="formKind === 'transport'" class="view-section">
<h3 class="dialog-section-title">证面引用</h3>
<h3 class="dialog-section-title">关联购买证</h3>
<el-button
class="mb-3"
type="primary"
@@ -395,7 +432,7 @@
>已有额度流水。尚未关联购买证档案的引用可以补全;已关联的引用不能修改或移除。</p
>
<p v-else class="form-tip"
>购买许可/备案/出口许可证号与购买证证号完全一致时自动选中对应购买证,并带出该证已绑定的合同和产品。没有唯一匹配则保持为空。</p
>购买许可/备案/出口许可证号与购买证证号完全一致时自动选中对应购买证。适用品类随引用购买证汇总。没有唯一匹配则保持为空。</p
>
<div class="cert-relation-list">
<article
@@ -553,35 +590,12 @@
</SystemViewDialog>
<el-dialog
v-model="confirmVisible"
:title="confirmRow?.openingConfirmed ? '复核证件' : '期初核对'"
title="复核证件"
width="640px"
align-center
class="dialog-form-layout quota-cert-confirm-dialog"
>
<el-form label-position="top">
<template v-if="!confirmRow?.openingConfirmed">
<div class="form-grid-2">
<el-form-item>
<template #label>
历史已用量(证面单位)<span v-if="confirmUnit" class="form-label-tip">{{
confirmUnit
}}</span>
</template>
<el-input v-model="opening.usedQty">
<template v-if="confirmUnit" #append>{{ confirmUnit }}</template>
</el-input>
</el-form-item>
<el-form-item label="一次有效证是否已有使用记录">
<el-switch v-model="opening.singleUseConsumed" />
</el-form-item>
</div>
<el-form-item label="历史来源说明">
<el-input v-model="opening.sourceNote" type="textarea" />
</el-form-item>
<el-form-item label="核对凭据">
<AttachmentUploadField v-model="opening.evidenceUrls" folder="pl/quota-cert" />
</el-form-item>
</template>
<el-form-item label="核对说明"
><el-input v-model="opening.reason" type="textarea"
/></el-form-item>
@@ -590,7 +604,7 @@
><el-button @click="confirmVisible = false"
><el-icon><Close /></el-icon>取消</el-button
><el-button type="primary" :loading="saving" @click="confirm"
><el-icon><Check /></el-icon>确认启用</el-button
><el-icon><Check /></el-icon>确认复核</el-button
></template
>
</el-dialog>
@@ -611,7 +625,7 @@
import QuotaHistoryDialog from './QuotaHistoryDialog.vue'
const historyDialog = ref<InstanceType<typeof QuotaHistoryDialog>>()
import { Check, Close, Connection, Delete, Document, Edit, Plus } from '@element-plus/icons-vue'
import { Check, Close, Delete, Document, Edit, Plus, View } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/modules/user'
import { useDictStore } from '@/store/modules/dict'
import { getProductUnitLabel } from '@/utils/crm-product-display'
@@ -620,15 +634,23 @@
import { fetchGetMenuCodes } from '@/api/auth'
import {
quotaCertApi,
certCategoryApi,
type CertType,
type QuotaCert,
type CertCategory,
type CertificateContractBinding,
type ContractOption,
type ScopeProduct
} from '@/api/pl/quota-cert'
import { fetchCustomerDetail, fetchCustomerOptions } from '@/api/crm/customer'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import CertContractBinder from './CertContractBinder.vue'
import { bindingsFromPurchaseCerts, matchPurchaseCert } from './contract-binder-rules'
import {
bindingsFromPurchaseCerts,
exactCategoryMatches,
exactCustomerMatches,
matchPurchaseCert
} from './contract-binder-rules'
import {
buyPartyName,
certKindLabel,
@@ -680,7 +702,141 @@
lockedPlates = ref<string[]>([]),
linkedRefIds = ref<string[]>([]),
recognitionImage = ref('')
const customerLoading = ref(false)
const customerOptions = ref<Array<{ id: string; customerName: string }>>([])
const categoryOptions = ref<CertCategory[]>([])
const derivedCategoryLabels = computed(() => {
const ids = (form.value.derivedCategoryIds || []) as string[]
if (!ids.length) return []
const names = new Map(categoryOptions.value.map((c) => [c.id, c.categoryName]))
return ids.map((id) => names.get(id) || id)
})
const certAttachments = ref<InstanceType<typeof AttachmentUploadField>>()
async function searchCustomers(keyword = '') {
customerLoading.value = true
try {
const result = await fetchCustomerOptions(
keyword ? { keyword, limit: 50 } : { limit: 30 }
)
const list = Array.isArray(result)
? result
: (result as any)?.list || (result as any)?.data || []
customerOptions.value = (list || [])
.map((row: any) => ({
id: String(row.id || row.customerId || ''),
customerName: String(row.customerName || row.name || '')
}))
.filter((row: { id: string }) => row.id)
} catch {
customerOptions.value = []
} finally {
customerLoading.value = false
}
}
async function ensureCustomerOption(id?: string | null, nameHint = '') {
if (!id) return
let name = ''
try {
const detail = (await fetchCustomerDetail({ id })) as Record<string, any>
const data = detail?.data || detail
name = String(data?.customerName || data?.name || '').trim()
} catch {
name = ''
}
if (!name) {
try {
const result = await fetchCustomerOptions({ keyword: nameHint || '', limit: 200 })
const list = Array.isArray(result)
? result
: (result as any)?.list || (result as any)?.data || []
const hit = (list || []).find((row: any) => String(row.id) === id)
name = String(hit?.customerName || hit?.name || '').trim()
} catch {
name = ''
}
}
// 系统客户名称以 CRM 为准;nameHint 仅作 CRM 不可用时的兜底(应为接口返回的 customerName)。
const label = name || String(nameHint || '').trim() || id
customerOptions.value = [
{ id, customerName: label },
...customerOptions.value.filter((c) => c.id !== id)
]
}
async function loadCategories() {
try {
categoryOptions.value = (await certCategoryApi.list({ status: '1' })) || []
} catch {
categoryOptions.value = []
}
}
function extractCustomerRows(result: unknown): Array<{ id: string; customerName: string }> {
const list = Array.isArray(result)
? result
: (result as { list?: unknown[]; data?: unknown[] })?.list ||
(result as { data?: unknown[] })?.data ||
[]
return (list || [])
.map((row: any) => ({
id: String(row.id || row.customerId || ''),
customerName: String(row.customerName || row.name || '')
}))
.filter((row: { id: string }) => row.id)
}
/** 解析后回填【客户与品类】系统客户:名称完全一致且唯一才选中。 */
async function matchSystemCustomer(
name: string,
protectedFields: Set<string>,
partyLabel = '证面单位'
) {
const party = name.trim()
if (!party || form.value.customerId || protectedFields.has('customerId')) return
try {
const result = await fetchCustomerOptions({ keyword: party, limit: 200 })
if (form.value.customerId || protectedFields.has('customerId')) return
const matches = exactCustomerMatches(extractCustomerRows(result), party)
if (matches.length === 1) {
await ensureCustomerOption(matches[0].id, matches[0].customerName || party)
form.value.customerId = matches[0].id
edited.add('customerId')
return
}
ElMessage.warning(
matches.length
? `有多个客户与${partyLabel}同名,请手工选择系统客户`
: `未找到与${partyLabel}完全一致的客户,请手工选择系统客户`
)
} catch {
ElMessage.warning('系统客户匹配失败,请手工选择')
}
}
/** 解析后回填证用品类:证面品名与品类名完全一致且唯一才选中。 */
async function matchCertCategory(productName: string, protectedFields: Set<string>) {
const name = productName.trim()
if (!name || form.value.categoryId || protectedFields.has('categoryId')) return
if (!categoryOptions.value.length) await loadCategories()
if (form.value.categoryId || protectedFields.has('categoryId')) return
const matches = exactCategoryMatches(categoryOptions.value, name)
if (matches.length === 1) {
form.value.categoryId = matches[0].id
edited.add('categoryId')
return
}
ElMessage.warning(
matches.length
? '有多个品类与证面品名同名,请手工选择证用品类'
: '未找到与证面品名完全一致的证用品类,请手工选择'
)
}
function onCustomerChange() {
edited.add('customerId')
}
type BindingDraft = {
contractId: string
contractNo: string
@@ -872,8 +1028,17 @@
value: statusLabels[detail.value.status],
tagType: tagType(detail.value.status)
}
const systemCustomerField: ViewField = {
label: '系统客户',
value: String(detail.value.customerName || '').trim() || '—',
span: 'full'
}
const groups = certFormGroups(kind.value).map((group, index) =>
detailGroup(group.title, group.fields, index === 0 ? [statusField] : [])
detailGroup(
group.title,
group.fields,
index === 0 ? [statusField, systemCustomerField] : []
)
)
groups[groups.length - 1].fields.push({
label: '系统备注',
@@ -887,7 +1052,7 @@
{ title: '证件附件', fields: [], slot: 'attachment' }
)
if (kind.value === 'transport') {
sections.push({ title: '证面引用', fields: [], slot: 'references' })
sections.push({ title: '关联购买证', fields: [], slot: 'references' })
}
} else {
sections.push({ title: '', fields: [], slot: 'contracts' })
@@ -1173,6 +1338,10 @@
function applyCertForm(targetKind: CertType, cert: QuotaCert) {
const value = {
...certPayload(targetKind, cert),
// 运输证适用品类为只读展示字段,不进保存 payload
...(targetKind === 'transport'
? { derivedCategoryIds: [...(cert.derivedCategoryIds || [])] }
: {}),
contractBindings: (cert.contractBindings || []).map((binding) => ({
...binding,
products: binding.products.map((product) => ({ ...product }))
@@ -1215,6 +1384,9 @@
form.value = row
? {
...certPayload(targetKind, row),
...(targetKind === 'transport'
? { derivedCategoryIds: [...(row.derivedCategoryIds || [])] }
: {}),
totalQty: inputQty(row.totalQty),
contractBindings: [],
bindingsEditable: row.bindingsEditable !== false,
@@ -1224,6 +1396,8 @@
if (row && row.referencesEditable === false)
lockedPlates.value = plateList(row.vehicleNumbersJson)
formVisible.value = true
void loadCategories()
void searchCustomers('')
if (!row) {
formLoading.value = false
return
@@ -1237,6 +1411,10 @@
if (session !== editSession) return
bindingContracts.value = contracts
applyCertForm(targetKind, cert)
await ensureCustomerOption(
form.value.customerId,
String((cert as QuotaCert).customerName || '').trim()
)
if (targetKind === 'transport') purchases.value = purchaseOptions()
} catch (error) {
if (session === editSession) {
@@ -1316,6 +1494,14 @@
ElMessage.warning('请保留且仅上传一张证件附件')
return
}
if (!form.value.customerId) {
ElMessage.warning('请选择系统客户')
return
}
if (formKind.value === 'purchase' && !form.value.categoryId) {
ElMessage.warning('请选择证用品类')
return
}
if (
form.value.bindingsEditable !== false &&
form.value.contractBindings?.some((b: CertificateContractBinding) => !b.contractId)
@@ -1430,12 +1616,20 @@
form.value.referencesEditable === false ? new Set([...edited, 'purchaseRefs']) : edited
mergeCandidates(form.value as any, result.candidates, protectedFields)
if (!protectedFields.has('totalQty')) form.value.totalQty = inputQty(form.value.totalQty)
if (targetKind === 'purchase' && form.value.bindingsEditable !== false) {
if (targetKind === 'purchase') {
const buyer = String(form.value.buyerName || '').trim()
if (buyer) await purchaseBinder.value?.matchBuyerCustomer(buyer)
const productName = String(form.value.productNameOnCert || '').trim()
if (buyer) await matchSystemCustomer(buyer, protectedFields, '购买单位')
if (productName) await matchCertCategory(productName, protectedFields)
if (form.value.bindingsEditable !== false && buyer) {
await purchaseBinder.value?.matchBuyerCustomer(buyer)
}
}
if (targetKind === 'transport')
if (targetKind === 'transport') {
const consignee = String(form.value.consigneeName || '').trim()
if (consignee) await matchSystemCustomer(consignee, protectedFields, '收货单位')
await linkRecognizedPurchaseCerts(session, targetKind, targetImage)
}
if (
session !== editSession ||
!formVisible.value ||
@@ -1543,12 +1737,12 @@
form.value.referencesEditable === false && !!row.id && linkedRefIds.value.includes(row.id)
)
}
function onPurchaseArchiveChange() {
function onPurchaseArchiveChange(_row?: { purchaseCertId?: string | null }) {
edited.add('purchaseRefs')
}
function referencesLocked() {
if (form.value.referencesEditable !== false) return false
ElMessage.warning('已有额度流水,不能新增或移除证面引用')
ElMessage.warning('已有额度流水,不能新增或移除关联购买证')
return true
}
function addReference() {
@@ -1579,7 +1773,6 @@
linkedContracts.value = links
detailVisible.value = true
}
const showContracts = view
async function refreshDetails() {
if (detail.value && detailVisible.value) await view(detail.value)
}
@@ -1601,12 +1794,10 @@
saving.value = true
try {
const targetKind = rowCertType(confirmRow.value)
if (confirmRow.value.openingConfirmed)
await quotaCertApi.verify(targetKind, confirmRow.value.id, {
operationId: opening.operationId,
reason: opening.reason
})
else await quotaCertApi.confirm(targetKind, confirmRow.value.id, opening)
await quotaCertApi.verify(targetKind, confirmRow.value.id, {
operationId: opening.operationId,
reason: opening.reason
})
confirmVisible.value = false
await load()
} finally {
@@ -1625,9 +1816,15 @@
if (current === authEpoch && !disposed && typeof route.query.contractId === 'string')
scopeDialog.value?.open(route.query.contractId)
if (current === authEpoch && !disposed && route.query.create === '1' && canEdit.value) {
const createKind =
route.query.kind === 'transport' || route.query.kind === 'purchase'
? (route.query.kind as CertType)
: kind.value
kind.value = createKind
await edit()
const nextQuery = { ...route.query }
delete nextQuery.create
delete nextQuery.kind
router.replace({ query: nextQuery })
}
}
@@ -2033,4 +2230,13 @@
pointer-events: none;
}
}
.cert-legacy-contract-collapse {
margin-bottom: 16px;
:deep(.el-collapse-item__header) {
font-size: 13px;
color: #64748b;
}
}
</style>
+9 -3
View File
@@ -153,7 +153,9 @@ export function newCert(kind: CertType): CertBody {
entryMode: 'image_parse',
attachmentUrls: '',
remark: '',
orderNum: null
orderNum: null,
customerId: null,
categoryId: null
}
for (const field of [
...commonFields,
@@ -168,7 +170,7 @@ export function newCert(kind: CertType): CertBody {
return body as unknown as CertBody
}
/** 删除当前证件附件后,清掉证面、合同绑定和证面引用,保留备注与录入方式。 */
/** 删除当前证件附件后,清掉证面、合同绑定和关联购买证,保留备注与录入方式。 */
export function clearCertAfterAttachmentRemoved<T extends Record<string, any>>(
kind: CertType,
form: T
@@ -194,6 +196,9 @@ export function certPayload(kind: CertType, form: Record<string, any>): CertBody
for (const key of Object.keys(result)) if (key in form) result[key] = form[key]
if (result.quantityUnit) result.quantityUnit = normalizeProductUnitValue(result.quantityUnit)
if (['draft', 'suspended', 'closed', 'void'].includes(form.status)) result.status = form.status
result.customerId = form.customerId || null
if (kind === 'purchase') result.categoryId = form.categoryId || null
else delete result.categoryId
if (kind === 'transport')
result.purchaseRefs = (form.purchaseRefs || []).map((r: Record<string, unknown>) =>
Object.fromEntries(
@@ -202,12 +207,13 @@ export function certPayload(kind: CertType, form: Record<string, any>): CertBody
.map((k) => [k, r[k]])
)
)
// 新规则以客户+品类为准;合同产品绑定仅兼容旧数据,有提交时仍透传
if (form.bindingsEditable !== false && Array.isArray(form.contractBindings)) {
const pick = (row: Record<string, unknown>, keys: string[]) =>
Object.fromEntries(keys.filter((key) => key in row).map((key) => [key, row[key]]))
result.contractBindings = form.contractBindings.map((binding: Record<string, any>) => ({
...pick(binding, ['id', 'contractId', 'linkStatus', 'remark', 'orderNum']),
products: binding.products.map((product: Record<string, unknown>) =>
products: (binding.products || []).map((product: Record<string, unknown>) =>
pick(product, ['id', 'contractProductId', 'scopeStatus', 'remark', 'orderNum'])
)
}))
@@ -3,6 +3,13 @@
<div v-if="!embedded" class="panel-top"
><h2 class="title-bar-heading">{{ mode === 'contract' ? '合同用证' : '证件用量' }}</h2></div
>
<el-alert
v-if="!embedded && mode === 'contract' && customerRuleActive"
class="mb-3"
type="warning"
:closable="false"
title="新规则已生效:安排选证改为客户+证用品类,本页合同绑证仅供历史查看与旧规则计划,请勿再作为新计划的用证操作入口。"
/>
<div v-if="!contractId" class="list-toolbar"
><div class="list-toolbar-row cert-list-toolbar-row">
<ListQuickSearchInput
@@ -188,6 +195,7 @@
import { fetchGetMenuCodes } from '@/api/auth'
import { useUserStore } from '@/store/modules/user'
import {
certQuotaRuleApi,
quotaCertApi,
quotaStatisticsApi,
type ContractStatistics,
@@ -219,6 +227,7 @@
)
const user = useUserStore()
const router = useRouter()
const customerRuleActive = ref(false)
const allowed = ref(false),
canEdit = ref(false),
loading = ref(false),
@@ -530,6 +539,7 @@
clear()
allowed.value = false
canEdit.value = false
customerRuleActive.value = false
sessionKey.value++
if (!user.accessToken || !props.active) return
const current = epoch
@@ -541,6 +551,11 @@
canEdit.value =
allowed.value &&
(!!p.all || !!p.isSuperAdmin || (p.menuCodes || []).includes('pl:quota-cert:edit'))
if (props.mode === 'contract' && !props.embedded) {
const rule = await certQuotaRuleApi.get().catch(() => null)
if (current !== epoch) return
customerRuleActive.value = !!rule?.configured
}
await load()
} catch {
if (current === epoch) error.value = '权限加载失败,请重新打开页面'
@@ -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>