Compare commits

..
Author SHA1 Message Date
wuzilongandCursor 697075462a fix(pl): 证件建档后自动启用,取消期初确认门槛。
保存即写入期初已用0并置为active;Web/H5仅保留复核入口,存量未确认证在读取或选证时自动补期初。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 10:04:57 +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
wuzilongandCursor 43e240ad6a feat(web-admin): add copy-to-clipboard for attachment images
Let users copy images next to download on shared attachment cards, preview toolbars, and list cells so they can paste into chat or docs without saving files first.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 23:18:34 +08:00
wuzilongandCursor 53987bdf22 fix(web-admin): keep cancelled plan stop feedback and refresh plan detail from API
作废/取消计划仍展示已有分段回传;打开计划详情始终回源,避免列表 seed 滞后。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 21:16:44 +08:00
wuzilongandCursor 85f5f29895 fix(pl): clear plan-cert conversion queue after saved ratio or waiver
证书齐备豁免或已填写完整人工换算的计划不再标「需单位换算」,避免已归档豁免单长期挂在待办列表。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 21:10:34 +08:00
wuzilongandCursor 8bffb1cbf5 fix(web-admin): restore fold icon in production and toggle archive planNo.
Remove Tailwind inline from el-icon so production CSS no longer collapses the header fold control, and expose 计划编号 in the plan-archive column picker.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 21:10:15 +08:00
wuzilongandCursor a4d213231c feat(web-admin): split recharge summary by fuel/ETC and fix pnpm workspace
Show fuel-only cards on fuel routes and ETC-only cards on ETC routes with a 5-column layout, and declare packages in pnpm-workspace.yaml so pnpm 9+ can build.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 20:39:48 +08:00
wuzilongandCursor 7ebbaa2784 docs: add recharge 发放中 status design and plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:48:55 +08:00
wuzilongandCursor 553c1ef316 feat(web-admin): show recharge pending status as 发放中
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:48:04 +08:00
wuzilongandCursor f75d36e1ff fix(pl): revert disbursed recharge apply to pending and rename label
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:46:57 +08:00
wuzilongandCursor a86ded9ea6 fix(pl): include pending recharge applies in approval menu badge
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:45:51 +08:00
wuzilongandCursor c8174ab3f2 feat(pl): set recharge apply to pending when entering disburse node
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:43:57 +08:00
wuzilongandCursor 01d607d766 feat(web-admin): open cert maintain dialog on reminder page
Keep users on the document reminder list by reusing vehicle/personnel
form dialogs instead of navigating away to base-info pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:29:37 +08:00
wuzilongandCursor fba478dcca docs: add document reminder edit-jump design and plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:24:37 +08:00
wuzilongandCursor c4852bd488 feat(web-admin): open personnel edit dialog from editId query
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:24:27 +08:00
wuzilongandCursor 64f14814bd feat(web-admin): open vehicle edit dialog from editId query
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:24:24 +08:00
wuzilongandCursor 7f54923acf feat(web-admin): add maintain-cert action on document reminder list
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:23:25 +08:00
wuzilongandCursor 0f1446b5d7 feat(web-admin): add useRouteEditIdOpen for deep-link edit dialogs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:23:23 +08:00
wuzilongandCursor c0fa3ccfd9 feat(web-admin): add document reminder jump path resolver
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:22:49 +08:00
wuzilongandCursor ed5784d1f7 docs: add ETC/fuel recharge split design and implementation plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:15:14 +08:00
wuzilongandCursor a4a8c2de58 chore(sql): split ETC and fuel recharge menus with role sync
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:11:49 +08:00
wuzilongandCursor 0a7eb97d14 fix(web-admin): redirect legacy etc-recharge recharge paths to fuel
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:06:08 +08:00
wuzilongandCursor efd7462e35 feat(web-admin): split etc and fuel recharge router groups
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:05:43 +08:00
wuzilongandCursor 345149dd6b feat(web-admin): add dedicated ETC vehicle recharge pages
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:03:00 +08:00
wuzilongandCursor 95a9c04fa6 fix(web-admin): restrict legacy recharge pages to non-ETC types
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 18:56:19 +08:00
wuzilongandCursor a7938a3b80 fix(web-admin): show recharge summary only on fuel-recharge routes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 18:53:29 +08:00
wuzilongandCursor 738f76c486 feat(pl): add rechargeTypeNe filter for vehicle recharge lists
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 18:50:41 +08:00
wuzilong 9f65cd447f merge: 合入证件建档按客户查询合同修复(#24)。 2026-11-12 17:48:18 +08:00
wuzilongandCursor eae5654f01 fix(pl): 证件建档按客户查询可添加合同,避免列表为空。
改用 CRM listContract 按 customerId 拉取,不再只取最近合同再前端过滤。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 17:48:17 +08:00
wuzilong 71927bedef merge: 合入文档库多选批量设置分享(#25)。 2026-11-12 17:44:52 +08:00
131 changed files with 14441 additions and 808 deletions

No files matched your search

+6
View File
@@ -2206,6 +2206,8 @@ def create_plan(
create_by=user.id,
create_dept_id=user.dept_id,
)
from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_version
stamp_plan_rule_version(db, user, plan)
db.add(plan)
# 添加产品明细
@@ -2377,6 +2379,8 @@ def create_plan_full(
create_by=user.id,
create_dept_id=user.dept_id,
)
from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_version
stamp_plan_rule_version(db, user, plan)
db.add(plan)
db.flush()
@@ -2527,6 +2531,8 @@ def create_from_contract(
create_by=user.id,
create_dept_id=user.dept_id,
)
from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_version
stamp_plan_rule_version(db, user, plan)
db.add(plan)
# 查询合同产品明细
+94
View File
@@ -137,6 +137,100 @@ def mark_cert_ready(plan_id: str, db: Session = Depends(get_db), user: DbUserCon
return _write(db, lambda: mark_ready(db, user, plan_id))
@cert_ready_router.get('/plans/cert-ready/pending-fulfill')
def list_pending_fulfill(keyword: str = '', page: int = Query(1, ge=1),
page_size: int = Query(20, alias='pageSize', ge=1, le=100),
db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
from app.services.pl_quota_waiver import list_pending_fulfill_plans
return _wire(list_pending_fulfill_plans(db, user, page, page_size, keyword))
@cert_ready_router.post('/plans/{plan_id}/fulfill-quota-certs')
def fulfill_quota_certs(plan_id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
from app.services.pl_quota_waiver import fulfill_plan_certs
return _write(db, lambda: fulfill_plan_certs(db, user, plan_id))
class FulfillBatchBody(InputBody):
plan_ids: list = Field(default_factory=list, alias='planIds')
@cert_ready_router.post('/plans/fulfill-quota-certs/batch')
def fulfill_quota_certs_batch(body: FulfillBatchBody, db: Session = Depends(get_db),
user: DbUserContext = Depends(current_user)):
from app.services.pl_quota_waiver import fulfill_plans_batch
ids = [str(x) for x in (body.plan_ids or []) if x]
if not ids:
raise HTTPException(400, '请选择要完善的计划')
return _write(db, lambda: fulfill_plans_batch(db, user, ids))
@router.get('/rule-config')
def get_rule_config(db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
from app.services.pl_cert_quota_rule_config_service import read_config
return _wire(read_config(db, user.tenant_no))
class RuleConfigBody(InputBody):
effective_at: Optional[str] = Field(None, alias='effectiveAt')
@router.put('/rule-config', dependencies=[Depends(require_admin_or_super_admin)])
@oper_log('运输证件规则切点', business_type=2)
def put_rule_config(body: RuleConfigBody, db: Session = Depends(get_db),
user: DbUserContext = Depends(current_user)):
from datetime import datetime as dt
from app.services.pl_cert_quota_rule_config_service import save_effective_at
value = None
if body.effective_at:
text = body.effective_at.replace('T', ' ').strip()
try:
value = dt.strptime(text[:19], '%Y-%m-%d %H:%M:%S')
except ValueError:
try:
value = dt.strptime(text[:16], '%Y-%m-%d %H:%M')
except ValueError as exc:
raise HTTPException(400, '生效时间格式无效') from exc
return _write(db, lambda: save_effective_at(db, user, value))
@router.get('/categories')
def list_categories(keyword: Optional[str] = None, status: Optional[str] = None,
db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
from app.services import pl_cert_category_service as categories
return _wire(categories.list_categories(db, user, keyword, status))
@router.get('/categories/{category_id}')
def get_category(category_id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
from app.services import pl_cert_category_service as categories
return _wire(categories.get_category(db, user, category_id))
@router.post('/categories', dependencies=[Depends(require_menu_code('pl:quota-cert:edit'))])
@oper_log('证用品类', business_type=1)
def create_category(body: dict = Body(...), db: Session = Depends(get_db),
user: DbUserContext = Depends(current_user)):
from app.services import pl_cert_category_service as categories
return _write(db, lambda: categories.save_category(db, user, body))
@router.put('/categories/{category_id}', dependencies=[Depends(require_menu_code('pl:quota-cert:edit'))])
@oper_log('证用品类', business_type=2, path_param='category_id')
def update_category(category_id: str, body: dict = Body(...), db: Session = Depends(get_db),
user: DbUserContext = Depends(current_user)):
from app.services import pl_cert_category_service as categories
return _write(db, lambda: categories.save_category(db, user, body, category_id))
@router.delete('/categories/{category_id}', dependencies=[Depends(require_menu_code('pl:quota-cert:edit'))])
@oper_log('证用品类', business_type=3, path_param='category_id')
def delete_category(category_id: str, db: Session = Depends(get_db),
user: DbUserContext = Depends(current_user)):
from app.services import pl_cert_category_service as categories
return _write(db, lambda: categories.delete_category(db, user, category_id) or {'ok': True})
@router.get('/plans/{plan_id}/usage')
def plan_quota(plan_id: str, arrange_check: bool = Query(False, alias='arrangeCheck'),
db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
+2
View File
@@ -725,6 +725,8 @@ def arrange_transport(
raise HTTPException(status_code=404, detail="计划不存在")
assert_pl_row_in_scope(db, plan, PlPlan, user, tenant_no)
assert_plan_can_arrange(plan)
from app.services.pl_cert_quota_rule_config_service import assert_cert_quota_rule_configured
assert_cert_quota_rule_configured(db, tenant_no)
assert_no_pending_stop_products(db, tenant_no, plan_id)
+11 -1
View File
@@ -39,7 +39,7 @@ record_router = APIRouter(prefix="/vehicle-recharge-record", tags=["运输计划
RECHARGE_TYPE_LABELS = {"etc": "ETC", "fuel": "油卡", "shanxia": "山峡", "bailian": "百链"}
RECHARGE_APPLY_STATUS_LABELS = {
"draft": "草稿",
"pending": "待发放",
"pending": "发放中",
"in_approval": "审批中",
"rejected": "已驳回",
"disbursed": "已发放",
@@ -113,6 +113,7 @@ def _apply_list_kwargs(params: dict) -> dict:
license_plate=_param_get(params, "licensePlate", "license_plate"),
recharge_type=_param_get(params, "rechargeType", "recharge_type"),
recharge_type_in=_param_get(params, "rechargeTypeIn", "recharge_type_in"),
recharge_type_ne=_param_get(params, "rechargeTypeNe", "recharge_type_ne"),
status=_param_get(params, "status"),
status_in=_param_get(params, "statusIn", "status_in"),
apply_date_from=_param_get(params, "applyDateFrom", "apply_date_from"),
@@ -139,6 +140,7 @@ def _disburse_list_kwargs(params: dict) -> dict:
license_plate=_param_get(params, "licensePlate", "license_plate"),
recharge_type=_param_get(params, "rechargeType", "recharge_type"),
recharge_type_in=_param_get(params, "rechargeTypeIn", "recharge_type_in"),
recharge_type_ne=_param_get(params, "rechargeTypeNe", "recharge_type_ne"),
apply_status=_param_get(params, "applyStatus", "apply_status"),
apply_status_in=_param_get(params, "applyStatusIn", "apply_status_in"),
apply_date_from=_param_get(params, "applyDateFrom", "apply_date_from"),
@@ -170,6 +172,7 @@ def list_applies(
request: Request,
q: Optional[str] = Query(default=None),
recharge_type: Optional[str] = Query(default=None, alias="rechargeType"),
recharge_type_ne: Optional[str] = Query(default=None, alias="rechargeTypeNe"),
status: Optional[str] = Query(default=None),
apply_date_from: Optional[date] = Query(default=None, alias="applyDateFrom"),
apply_date_to: Optional[date] = Query(default=None, alias="applyDateTo"),
@@ -185,6 +188,7 @@ def list_applies(
request,
q=q,
rechargeType=recharge_type,
rechargeTypeNe=recharge_type_ne,
status=status,
applyDateFrom=apply_date_from,
applyDateTo=apply_date_to,
@@ -233,6 +237,7 @@ RECHARGE_APPLY_EXPORT_COLUMNS = [
class RechargeApplyExportBody(BaseModel):
q: Optional[str] = None
recharge_type: Optional[str] = Field(default=None, alias="rechargeType")
recharge_type_ne: Optional[str] = Field(default=None, alias="rechargeTypeNe")
status: Optional[str] = None
apply_date_from: Optional[date] = Field(default=None, alias="applyDateFrom")
apply_date_to: Optional[date] = Field(default=None, alias="applyDateTo")
@@ -422,6 +427,7 @@ def delete_applies(
def list_records(
q: Optional[str] = Query(default=None),
recharge_type: Optional[str] = Query(default=None, alias="rechargeType"),
recharge_type_ne: Optional[str] = Query(default=None, alias="rechargeTypeNe"),
recharge_date_from: Optional[date] = Query(default=None, alias="rechargeDateFrom"),
recharge_date_to: Optional[date] = Query(default=None, alias="rechargeDateTo"),
gas_station: Optional[str] = Query(default=None, alias="gasStation"),
@@ -438,6 +444,7 @@ def list_records(
user,
q=q,
recharge_type=recharge_type,
recharge_type_ne=recharge_type_ne,
recharge_date_from=recharge_date_from,
recharge_date_to=recharge_date_to,
gas_station=gas_station,
@@ -455,6 +462,7 @@ def list_disburse_board(
request: Request,
q: Optional[str] = Query(default=None),
recharge_type: Optional[str] = Query(default=None, alias="rechargeType"),
recharge_type_ne: Optional[str] = Query(default=None, alias="rechargeTypeNe"),
apply_status: Optional[str] = Query(default=None, alias="applyStatus"),
apply_date_from: Optional[date] = Query(default=None, alias="applyDateFrom"),
apply_date_to: Optional[date] = Query(default=None, alias="applyDateTo"),
@@ -470,6 +478,7 @@ def list_disburse_board(
request,
q=q,
rechargeType=recharge_type,
rechargeTypeNe=recharge_type_ne,
applyStatus=apply_status,
applyDateFrom=apply_date_from,
applyDateTo=apply_date_to,
@@ -513,6 +522,7 @@ RECHARGE_DISBURSE_EXPORT_COLUMNS = [
class RechargeDisburseExportBody(BaseModel):
q: Optional[str] = None
recharge_type: Optional[str] = Field(default=None, alias="rechargeType")
recharge_type_ne: Optional[str] = Field(default=None, alias="rechargeTypeNe")
apply_status: Optional[str] = Field(default=None, alias="applyStatus")
apply_date_from: Optional[date] = Field(default=None, alias="applyDateFrom")
apply_date_to: Optional[date] = Field(default=None, alias="applyDateTo")
@@ -42,6 +42,7 @@ def _approval_list_kwargs(params: dict) -> dict:
license_plate=_param_get(params, "licensePlate", "license_plate"),
recharge_type=_param_get(params, "rechargeType", "recharge_type"),
recharge_type_in=_param_get(params, "rechargeTypeIn", "recharge_type_in"),
recharge_type_ne=_param_get(params, "rechargeTypeNe", "recharge_type_ne"),
status=_param_get(params, "status"),
status_in=_param_get(params, "statusIn", "status_in"),
apply_date_from=_param_get(params, "applyDateFrom", "apply_date_from"),
@@ -61,6 +62,7 @@ def _approval_list_kwargs(params: dict) -> dict:
@approval_router.get("/pending", response_model=VehicleRechargeApplyListResponse)
def list_pending(
request: Request,
recharge_type_ne: Optional[str] = Query(default=None, alias="rechargeTypeNe"),
apply_date_from: Optional[date] = Query(default=None, alias="applyDateFrom"),
apply_date_to: Optional[date] = Query(default=None, alias="applyDateTo"),
page: int = Query(default=1, ge=1),
@@ -71,6 +73,7 @@ def list_pending(
tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
params = _merge_query(
request,
rechargeTypeNe=recharge_type_ne,
applyDateFrom=apply_date_from,
applyDateTo=apply_date_to,
)
@@ -90,6 +93,7 @@ def list_pending(
@approval_router.get("/done", response_model=VehicleRechargeApplyListResponse)
def list_done(
request: Request,
recharge_type_ne: Optional[str] = Query(default=None, alias="rechargeTypeNe"),
apply_date_from: Optional[date] = Query(default=None, alias="applyDateFrom"),
apply_date_to: Optional[date] = Query(default=None, alias="applyDateTo"),
page: int = Query(default=1, ge=1),
@@ -100,6 +104,7 @@ def list_done(
tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
params = _merge_query(
request,
rechargeTypeNe=recharge_type_ne,
applyDateFrom=apply_date_from,
applyDateTo=apply_date_to,
)
+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
+1 -1
View File
@@ -6,7 +6,7 @@ from typing import List, Optional
from pydantic import BaseModel, Field, field_validator
RECHARGE_TYPES = {"etc", "fuel", "shanxia", "bailian"}
APPLY_STATUSES = {"draft", "in_approval", "rejected", "disbursed", "cancelled"}
APPLY_STATUSES = {"draft", "in_approval", "pending", "rejected", "disbursed", "cancelled"}
NODE_ACTION_TYPES = {"approve", "disburse"}
APPROVAL_STATUSES = {"approved", "rejected", "disbursed"}
@@ -25,6 +25,12 @@ def _split_csv(raw: Optional[str]) -> List[str]:
return [s.strip() for s in str(raw).split(",") if s.strip()]
def _append_ne_filter(filters: list, col, raw: Optional[str]) -> None:
value = (raw or "").strip()
if value:
filters.append(col != value)
def _append_enum_filter(
filters: list,
col,
@@ -148,6 +154,7 @@ def build_recharge_apply_column_filters(
license_plate: Optional[str] = None,
recharge_type: Optional[str] = None,
recharge_type_in: Optional[str] = None,
recharge_type_ne: Optional[str] = None,
status: Optional[str] = None,
status_in: Optional[str] = None,
apply_date_from: DateLike = None,
@@ -175,6 +182,7 @@ def build_recharge_apply_column_filters(
single=recharge_type,
multi=recharge_type_in,
)
_append_ne_filter(filters, PlVehicleRechargeApply.recharge_type, recharge_type_ne)
_append_enum_filter(
filters, PlVehicleRechargeApply.status, single=status, multi=status_in
)
@@ -223,6 +231,7 @@ def build_recharge_disburse_board_column_filters(
license_plate: Optional[str] = None,
recharge_type: Optional[str] = None,
recharge_type_in: Optional[str] = None,
recharge_type_ne: Optional[str] = None,
apply_status: Optional[str] = None,
apply_status_in: Optional[str] = None,
apply_date_from: DateLike = None,
@@ -256,6 +265,7 @@ def build_recharge_disburse_board_column_filters(
single=recharge_type,
multi=recharge_type_in,
)
_append_ne_filter(filters, PlVehicleRechargeApply.recharge_type, recharge_type_ne)
_append_enum_filter(
filters,
PlVehicleRechargeApply.status,
@@ -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
@@ -164,7 +164,7 @@ def count_recharge_approval_pending(db: Session, tenant_no: str, user_id: str) -
.filter(
PlVehicleRechargeApply.tenant_no == tenant_no,
PlVehicleRechargeApply.del_flag == 0,
PlVehicleRechargeApply.status == "in_approval",
PlVehicleRechargeApply.status.in_(("in_approval", "pending")),
BxWorkflowNode.tenant_no == tenant_no,
BxWorkflowNode.del_flag == 0,
BxWorkflowNode.approver_id == user_id,
+13 -1
View File
@@ -120,10 +120,22 @@ def apply_plan_quota(db, user, plan, action, payload=None):
evidenceUrls=getattr(payload, 'quota_evidence_urls', None),
sourceNote=getattr(payload, 'quota_source_note', None))
from app.services.pl_quota_waiver import assert_waived_sources, quota_cert_waived
if quota_cert_waived(plan) and action in ('reserve', 'resize', 'settle', 'actual_adjust'):
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
# 新规则:齐备仅放行安排/改派预占;归档与实际调整必须真证结转。
# 旧规则(legacy):齐备仍跳过安排与归档(祖父兼容)。
skip_actions = ('reserve', 'resize')
if not plan_uses_customer_category(plan):
skip_actions = ('reserve', 'resize', 'settle', 'actual_adjust')
if quota_cert_waived(plan) and action in skip_actions:
if action in ('reserve', 'resize'):
assert_waived_sources(db, user, plan, lock=True)
return {'operationId': ledger_id, 'usageIds': [], 'waived': True}
if (
plan_uses_customer_category(plan)
and action in ('settle', 'actual_adjust')
and int(getattr(plan, 'quota_cert_pending_fulfill', 0) or 0) == 1
):
raise HTTPException(409, '该计划仍处于证书齐备待完善,请先在证书齐备中完善并关闭后再归档')
request, config = build_usage_request(
db, user, plan.id, op, action, lock=True, check_conditions=explicit_configuration,
)
@@ -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)
# 用最终证面状态校验作废证不可扩大,不修改持久对象。
+174 -10
View File
@@ -63,8 +63,33 @@ def _refs(db, user, cert_id, lock=False):
return query.order_by(PlTransportCertPurchaseRef.create_time.desc()).all()
def _transport_derived_categories(db, user, transport_cert_id):
from app.models.pl_quota_models import PlPurchaseCert
purchase_ids = [
r.purchase_cert_id
for r in _refs(db, user, transport_cert_id)
if r.purchase_cert_id
]
if not purchase_ids:
return []
rows = (
db.query(PlPurchaseCert.category_id)
.filter(
PlPurchaseCert.tenant_no == user.tenant_no,
PlPurchaseCert.del_flag == 0,
PlPurchaseCert.id.in_(purchase_ids),
PlPurchaseCert.category_id.isnot(None),
)
.all()
)
return list(dict.fromkeys(r.category_id for r in rows if r.category_id))
def get_cert(db, user, cert_type, cert_id, current=False):
row = _get_row(db, user, cert_type, cert_id, lock=current)
# 存量未期初确认的证件在读取时自动补期初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(
@@ -76,7 +101,10 @@ def get_cert(db, user, cert_type, cert_id, current=False):
result['availableQty'] = row.total_qty - row.used_qty - row.reserved_qty
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
@@ -94,7 +122,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 +174,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 +186,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 +248,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 +278,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 +319,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 +367,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 +505,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, '期初已用量不能超过核定量')
+52 -13
View File
@@ -1,4 +1,6 @@
"""计划段次是否需要把计划单位换算成证面单位。"""
from decimal import Decimal, InvalidOperation
from app.models.pl_models import (
PlPlan, PlPlanDelivery, PlPlanDeliveryProduct, PlPlanLoad, PlPlanLoadProduct, PlPlanProduct,
)
@@ -63,12 +65,45 @@ def line_needs_conversion(unit, cert_units):
)
def plan_needs_unit_conversion(plan, lines, cert_units, snapshots):
def _positive_decimal(value):
try:
number = Decimal(str(value).strip())
except (InvalidOperation, AttributeError, TypeError):
return False
return number > 0
def _saved_manual_keys(configuration):
"""已填写完整人工换算的 (sourceId, permitKind, permitId)。"""
rows = (configuration or {}).get('manualAllocations') or (configuration or {}).get('manual_allocations') or []
keys = set()
for row in rows:
if not isinstance(row, dict):
continue
source_id = row.get('sourceId') or row.get('source_id')
kind = row.get('permitKind') or row.get('permit_kind')
permit_id = row.get('permitId') or row.get('permit_id')
source_qty = row.get('sourceQuantity') if 'sourceQuantity' in row else row.get('source_quantity')
permit_qty = row.get('permitQuantity') if 'permitQuantity' in row else row.get('permit_quantity')
if source_id and kind and permit_id and _positive_decimal(source_qty) and _positive_decimal(permit_qty):
keys.add((str(source_id), str(kind), str(permit_id)))
return keys
def plan_needs_unit_conversion(plan, lines, cert_bindings, snapshots):
"""证书齐备豁免或异单位段已保存完整人工换算时,不再进入待换算待办。"""
if int(getattr(plan, 'quota_cert_waived', 0) or 0) == 1:
return False
version = measurement_version(plan, snapshots)
saved = _saved_manual_keys(plan.cert_configuration)
for line in measured_lines(version, lines, plan.cert_configuration):
units = cert_units.get(line['contractProductId'], [])
if line_needs_conversion(line['unit'], units):
return True
bindings = cert_bindings.get(line['contractProductId'], [])
source_id = f"{line['side']}:{line['id']}"
for binding in bindings:
if not line_needs_conversion(line['unit'], [binding['unit']]):
continue
if (source_id, binding['kind'], binding['certId']) not in saved:
return True
return False
@@ -104,12 +139,14 @@ def _stop_lines(db, tenant_no, plan_ids):
return grouped
def _cert_units(db, tenant_no, contract_product_ids):
units = {}
def _cert_bindings(db, tenant_no, contract_product_ids):
bindings = {}
if not contract_product_ids:
return units
return bindings
for kind, cert_model in (('purchase', PlPurchaseCert), ('transport', PlTransportCert)):
rows = db.query(PlContractCertProduct.contract_product_id, cert_model.quantity_unit).join(
rows = db.query(
PlContractCertProduct.contract_product_id, cert_model.id, cert_model.quantity_unit,
).join(
PlContractCertLink, PlContractCertLink.id == PlContractCertProduct.contract_cert_link_id,
).join(cert_model, cert_model.id == PlContractCertLink.cert_id).filter(
PlContractCertLink.tenant_no == tenant_no, PlContractCertLink.del_flag == 0,
@@ -119,9 +156,11 @@ def _cert_units(db, tenant_no, contract_product_ids):
cert_model.tenant_no == tenant_no, cert_model.del_flag == 0,
PlContractCertProduct.contract_product_id.in_(contract_product_ids),
).all()
for product_id, unit in rows:
units.setdefault(product_id, []).append(unit)
return units
for product_id, cert_id, unit in rows:
bindings.setdefault(product_id, []).append({
'kind': kind, 'certId': cert_id, 'unit': unit,
})
return bindings
def plans_needing_unit_conversion(db, tenant_no, plan_ids):
@@ -137,7 +176,7 @@ def plans_needing_unit_conversion(db, tenant_no, plan_ids):
product_ids = {
line['contractProductId'] for rows in lines.values() for line in rows if line['contractProductId']
}
cert_units = _cert_units(db, tenant_no, product_ids)
cert_bindings = _cert_bindings(db, tenant_no, product_ids)
snapshots = {plan_id: [] for plan_id in ids}
usage_rows = db.query(PlPlanCertUsage.plan_id, PlPlanCertUsage.confirmation_snapshot).filter(
PlPlanCertUsage.tenant_no == tenant_no, PlPlanCertUsage.del_flag == 0,
@@ -146,6 +185,6 @@ def plans_needing_unit_conversion(db, tenant_no, plan_ids):
for plan_id, snapshot in usage_rows:
snapshots[plan_id].append(snapshot)
for plan in plans:
if plan_needs_unit_conversion(plan, lines.get(plan.id, []), cert_units, snapshots.get(plan.id, [])):
if plan_needs_unit_conversion(plan, lines.get(plan.id, []), cert_bindings, snapshots.get(plan.id, [])):
needed.add(plan.id)
return needed
@@ -0,0 +1,235 @@
"""新规则:按段次客户+品类整笔选证(不拆购买证)。"""
from datetime import date
from decimal import Decimal
from fastapi import HTTPException
from app.models.pl_quota_models import (
PlPurchaseCert,
PlTransportCert,
PlTransportCertPurchaseRef,
PlCertQtyLog,
)
from app.schemas.pl.quota_planning import ManualQuantity
from app.services.pl_cert_category_service import category_ids_for_product
from app.services.pl_quota_units import same_quantity_unit, standard_conversion_ratio
def _available(cert):
return Decimal(cert.total_qty) - Decimal(cert.used_qty) - Decimal(cert.reserved_qty)
def _in_force(cert, on_date: date) -> bool:
if cert.valid_from and on_date < cert.valid_from:
return False
if cert.valid_to and on_date > cert.valid_to:
return False
return True
def _has_opening(db, user, cert_type, cert_id) -> bool:
return (
db.query(PlCertQtyLog.id)
.filter_by(
tenant_no=user.tenant_no,
cert_type=cert_type,
cert_id=cert_id,
event_type='opening',
del_flag=0,
)
.first()
is not None
)
def _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 purchase.opening_single_use_consumed:
continue
try:
need = _permit_qty_for(Decimal(source.quantity), source.unit, purchase.quantity_unit)
except HTTPException:
continue
if _available(purchase) < need:
continue
if not source.requires_transport:
chosen_purchase = purchase
purchase_permit_qty = need
break
refs = (
db.query(PlTransportCertPurchaseRef)
.filter_by(
tenant_no=user.tenant_no,
purchase_cert_id=purchase.id,
del_flag=0,
)
.all()
)
transport_cert_ids = [r.transport_cert_id for r in refs if r.transport_cert_id]
if not transport_cert_ids:
continue
transports = (
db.query(PlTransportCert)
.filter(
PlTransportCert.tenant_no == user.tenant_no,
PlTransportCert.del_flag == 0,
PlTransportCert.status == 'active',
PlTransportCert.customer_id == customer_id,
PlTransportCert.id.in_(transport_cert_ids),
)
.order_by(
PlTransportCert.valid_to.is_(None).asc(),
PlTransportCert.valid_to.asc(),
PlTransportCert.id.asc(),
)
.all()
)
for transport in transports:
if not transport.verified_by or not transport.verified_time:
continue
_ensure_opening_quiet(db, user, 'transport', transport.id, transport)
if not _has_opening(db, user, 'transport', transport.id):
continue
if not _in_force(transport, on_date):
continue
if transport.opening_single_use_consumed:
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)
+51 -16
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,12 +335,20 @@ 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)
+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:
+42 -8
View File
@@ -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('证件尚未启用复核')
@@ -216,11 +220,17 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
if c.opening_single_use_consumed or any(u.plan_id!=plan.id and (u.usage_status in ('reserved','in_transit','settled','exception') or (u.confirmation_snapshot or {}).get('executed')) for u in previous):
raise ValueError('一次有效证件已使用或已占用')
eligible=set()
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 +289,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 +321,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 +372,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:
+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}
@@ -58,8 +58,8 @@ def _current_node(
user: DbUserContext,
tenant_no: str,
) -> BxWorkflowNode:
if apply.status != "in_approval" or not apply.current_node:
raise HTTPException(status_code=400, detail="申请单不在审批中")
if apply.status not in ("in_approval", "pending") or not apply.current_node:
raise HTTPException(status_code=400, detail="申请单不在可审批/发放状态")
node = (
db.query(BxWorkflowNode)
.filter(
@@ -119,6 +119,7 @@ def list_pending(
license_plate: Optional[str] = None,
recharge_type: Optional[str] = None,
recharge_type_in: Optional[str] = None,
recharge_type_ne: Optional[str] = None,
status: Optional[str] = None,
status_in: Optional[str] = None,
apply_date_from=None,
@@ -144,7 +145,7 @@ def list_pending(
.filter(
PlVehicleRechargeApply.tenant_no == tenant_no,
PlVehicleRechargeApply.del_flag == 0,
PlVehicleRechargeApply.status == "in_approval",
PlVehicleRechargeApply.status.in_(("in_approval", "pending")),
BxWorkflowNode.tenant_no == tenant_no,
BxWorkflowNode.del_flag == 0,
BxWorkflowNode.approver_id == user.id,
@@ -155,6 +156,7 @@ def list_pending(
license_plate=license_plate,
recharge_type=recharge_type,
recharge_type_in=recharge_type_in,
recharge_type_ne=recharge_type_ne,
status=status,
status_in=status_in,
apply_date_from=apply_date_from,
@@ -192,6 +194,7 @@ def list_done(
license_plate: Optional[str] = None,
recharge_type: Optional[str] = None,
recharge_type_in: Optional[str] = None,
recharge_type_ne: Optional[str] = None,
status: Optional[str] = None,
status_in: Optional[str] = None,
apply_date_from=None,
@@ -233,6 +236,7 @@ def list_done(
license_plate=license_plate,
recharge_type=recharge_type,
recharge_type_in=recharge_type_in,
recharge_type_ne=recharge_type_ne,
status=status,
status_in=status_in,
apply_date_from=apply_date_from,
@@ -290,7 +294,11 @@ def approve_apply(
opinion=opinion,
now=now,
)
apply.current_node = nodes[idx + 1].id
next_node = nodes[idx + 1]
apply.current_node = next_node.id
next_action = (next_node.action_type or "approve").strip() or "approve"
if next_action == "disburse":
apply.status = "pending"
apply.update_by = user.id
apply.update_time = now
sync_approval_notifications(
@@ -481,7 +481,7 @@ def _resolve_apply_for_record(
detail="审批中的申请请在「ETC/油卡充值审批」中完成发放登记",
)
if apply.status != "pending":
raise HTTPException(status_code=400, detail="仅待发放申请可关联")
raise HTTPException(status_code=400, detail="仅发放中申请可关联")
existing = (
db.query(PlVehicleRechargeRecord)
.filter(
@@ -513,6 +513,7 @@ def list_applies(
license_plate: Optional[str] = None,
recharge_type: Optional[str] = None,
recharge_type_in: Optional[str] = None,
recharge_type_ne: Optional[str] = None,
status: Optional[str] = None,
status_in: Optional[str] = None,
apply_date_from: Optional[date] = None,
@@ -555,6 +556,7 @@ def list_applies(
license_plate=license_plate,
recharge_type=recharge_type,
recharge_type_in=recharge_type_in,
recharge_type_ne=recharge_type_ne,
status=status,
status_in=status_in,
apply_date_from=apply_date_from,
@@ -854,6 +856,7 @@ def list_records(
*,
q: Optional[str] = None,
recharge_type: Optional[str] = None,
recharge_type_ne: Optional[str] = None,
recharge_date_from: Optional[date] = None,
recharge_date_to: Optional[date] = None,
gas_station: Optional[str] = None,
@@ -875,6 +878,10 @@ def list_records(
)
if recharge_type:
query = query.filter(PlVehicleRechargeRecord.recharge_type == recharge_type)
if recharge_type_ne and str(recharge_type_ne).strip():
query = query.filter(
PlVehicleRechargeRecord.recharge_type != str(recharge_type_ne).strip()
)
if recharge_date_from:
query = query.filter(PlVehicleRechargeRecord.recharge_date >= recharge_date_from)
if recharge_date_to:
@@ -937,6 +944,7 @@ def list_disburse_board(
license_plate: Optional[str] = None,
recharge_type: Optional[str] = None,
recharge_type_in: Optional[str] = None,
recharge_type_ne: Optional[str] = None,
apply_status: Optional[str] = None,
apply_status_in: Optional[str] = None,
apply_date_from: Optional[date] = None,
@@ -997,6 +1005,7 @@ def list_disburse_board(
license_plate=license_plate,
recharge_type=recharge_type,
recharge_type_in=recharge_type_in,
recharge_type_ne=recharge_type_ne,
apply_status=apply_status,
apply_status_in=apply_status_in,
apply_date_from=apply_date_from,
@@ -1124,7 +1133,7 @@ def update_record(
),
nodes[-1] if nodes else None,
)
old_apply.status = "in_approval"
old_apply.status = "pending"
old_apply.current_node = disburse.id if disburse else None
else:
old_apply.status = "pending"
@@ -1193,7 +1202,7 @@ def delete_records(
),
nodes[-1] if nodes else None,
)
apply.status = "in_approval"
apply.status = "pending"
apply.current_node = disburse.id if disburse else None
else:
apply.status = "pending"
@@ -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,465 @@
"""客户+品类证件额度:切点、列表默认过滤、齐备语义、整笔选证。"""
from datetime import date, datetime
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
def test_assert_rule_not_configured_blocks_arrange():
from app.services import pl_cert_quota_rule_config_service as cfg
db = MagicMock()
db.query.return_value.filter_by.return_value.first.return_value = None
with pytest.raises(HTTPException) as exc:
cfg.assert_cert_quota_rule_configured(db, 't1')
assert exc.value.status_code == 409
assert '系统信息' in str(exc.value.detail)
def test_resolve_rule_version_by_effective_at():
from app.services import pl_cert_quota_rule_config_service as cfg
db = MagicMock()
row = SimpleNamespace(effective_at=datetime(2026, 11, 14, 0, 0, 0))
db.query.return_value.filter_by.return_value.first.return_value = row
assert cfg.resolve_rule_version_for_create(db, 't', datetime(2026, 11, 13)) == 'legacy'
assert cfg.resolve_rule_version_for_create(db, 't', datetime(2026, 11, 14)) == 'customer_category'
db.query.return_value.filter_by.return_value.first.return_value = None
assert cfg.resolve_rule_version_for_create(db, 't', datetime(2026, 11, 20)) == 'legacy'
def test_apply_plan_quota_new_rule_waive_skips_reserve_not_settle(monkeypatch):
from app.services import pl_quota_business as biz
plan = SimpleNamespace(
id='p1',
quota_cert_waived=1,
quota_cert_pending_fulfill=1,
cert_quota_rule_version='customer_category',
cert_configuration={},
)
called = {}
def fake_assert(*a, **k):
called['assert'] = True
monkeypatch.setattr('app.services.pl_quota_waiver.assert_waived_sources', fake_assert)
monkeypatch.setattr(
'app.services.pl_cert_quota_rule_config_service.plan_uses_customer_category',
lambda p: True,
)
result = biz.apply_plan_quota(MagicMock(), SimpleNamespace(id='u'), plan, 'reserve', None)
assert result.get('waived') is True
assert called.get('assert') is True
with pytest.raises(HTTPException) as exc:
biz.apply_plan_quota(MagicMock(), SimpleNamespace(id='u'), plan, 'settle', None)
assert '待完善' in str(exc.value.detail)
def test_list_certs_default_excludes_closed_void(monkeypatch):
from app.services import pl_quota_cert_service as svc
class Col:
def __init__(self, name):
self.name = name
class FakeModel:
__tablename__ = 'pl_purchase_cert'
tenant_no = SimpleNamespace()
del_flag = 0
status = SimpleNamespace(notin_=lambda *a, **k: 'STATUS_FILTER')
create_time = SimpleNamespace(desc=lambda: 'DESC')
cert_no = seller_name = buyer_name = SimpleNamespace(contains=lambda *a, **k: True)
__table__ = SimpleNamespace(columns=[Col('id'), Col('status')])
@staticmethod
def id():
return 'id'
class Query:
def __init__(self):
self.filters = []
def filter(self, *a, **k):
self.filters.extend(a)
return self
def filter_by(self, **k):
return self
def order_by(self, *a):
return self
def all(self):
return []
q = Query()
db = MagicMock()
db.query.return_value = q
monkeypatch.setattr(svc, '_model', lambda t: FakeModel)
monkeypatch.setattr(svc, 'apply_pl_scope', lambda query, *a, **k: query)
monkeypatch.setattr(svc, 'get_cert', lambda *a, **k: {})
svc.list_certs(db, SimpleNamespace(tenant_no='t'), 'purchase')
assert any(f == 'STATUS_FILTER' for f in q.filters)
def test_whole_cover_skips_insufficient_earlier_purchase():
"""规则 A:早到期 10 吨不够则跳过,选用晚到期够量的证。"""
from app.services import pl_quota_customer_select as sel
from app.schemas.pl.quota_usage import UsageSource
source = UsageSource(
source_stop_type='delivery',
source_stop_id='s1',
source_stop_product_id='sp1',
plan_product_id='pp1',
contract_id='c1',
contract_product_id='cp1',
product_id='prod1',
quantity=Decimal('50'),
unit='吨',
fingerprint='x' * 64,
snapshot={'customerId': 'cust1'},
requires_transport=False,
)
early = SimpleNamespace(
id='buy-early', customer_id='cust1', category_id='cat1', status='active',
verified_by='u', verified_time=datetime.now(), valid_from=None, valid_to=date(2026, 12, 1),
total_qty=Decimal('10'), used_qty=Decimal('0'), reserved_qty=Decimal('0'),
quantity_unit='吨', opening_single_use_consumed=0,
)
late = SimpleNamespace(
id='buy-late', customer_id='cust1', category_id='cat1', status='active',
verified_by='u', verified_time=datetime.now(), valid_from=None, valid_to=date(2027, 6, 1),
total_qty=Decimal('100'), used_qty=Decimal('0'), reserved_qty=Decimal('0'),
quantity_unit='吨', opening_single_use_consumed=0,
)
class PurchaseQuery:
def filter(self, *a, **k):
return self
def order_by(self, *a, **k):
return self
def all(self):
return [early, late]
class LogQuery:
def filter_by(self, **k):
return self
def first(self):
return SimpleNamespace(id='opening')
db = MagicMock()
def query(model):
name = getattr(model, '__name__', str(model))
if 'Purchase' in name or model is early.__class__:
return PurchaseQuery()
return LogQuery()
# route by model class name via side_effect
from app.models.pl_quota_models import PlPurchaseCert, PlCertQtyLog
def q_side(model):
if model is PlPurchaseCert:
return PurchaseQuery()
if model is PlCertQtyLog:
return LogQuery()
return MagicMock()
db.query.side_effect = q_side
user = SimpleNamespace(tenant_no='t')
import app.services.pl_quota_customer_select as mod
orig_cat = mod.category_ids_for_product
mod.category_ids_for_product = lambda db, tenant, pid: {'cat1'}
try:
purchase_ids, transport_ids, manuals = sel.select_whole_cover_certs(
db, user, [source], vehicle_no='皖A12345', on_date=date(2026, 11, 14),
)
finally:
mod.category_ids_for_product = orig_cat
assert purchase_ids == ['buy-late']
assert transport_ids == []
assert len(manuals) == 1
assert manuals[0].permit_id == 'buy-late'
assert manuals[0].permit_quantity == Decimal('50')
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'})
@@ -75,6 +75,7 @@ def test_recharge_approval_badge_counts_current_approver_pending(monkeypatch):
add_apply("a3", status="in_approval", current_node="node-other")
add_apply("a4", status="rejected", current_node="node-me")
add_apply("a5", status="draft", current_node=None)
add_apply("a6", status="pending", current_node="node-me")
db.commit()
monkeypatch.setattr(
@@ -89,4 +90,4 @@ def test_recharge_approval_badge_counts_current_approver_pending(monkeypatch):
user_id="u-me",
menu_codes=[MENU_CODE_RECHARGE_APPROVAL],
)
assert counts[MENU_CODE_RECHARGE_APPROVAL] == 2
assert counts[MENU_CODE_RECHARGE_APPROVAL] == 3
+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': '重新启用'}),
+40 -10
View File
@@ -23,47 +23,77 @@ def line(line_id, side, unit, contract_product_id='cp', customer='客户', toxic
}
def bind(unit, kind='purchase', cert_id='buy'):
return {'kind': kind, 'certId': cert_id, 'unit': unit}
def plan(**values):
return SimpleNamespace(
cert_configuration=values.get('configuration'),
transport_status=values.get('transport_status', 'pending'),
archive_status=values.get('archive_status', 'unarchived'),
quota_cert_waived=values.get('quota_cert_waived', 0),
)
def test_same_label_and_code_do_not_need_conversion():
current = plan()
assert plan_needs_unit_conversion(current, [line('L', 'load', '吨')], {'cp': ['dun']}, []) is False
assert plan_needs_unit_conversion(current, [line('L', 'load', '吨', toxic=False)], {'cp': ['kg']}, []) is False
assert plan_needs_unit_conversion(current, [line('L', 'load', '吨')], {'cp': [bind('dun')]}, []) is False
assert plan_needs_unit_conversion(current, [line('L', 'load', '吨', toxic=False)], {'cp': [bind('kg')]}, []) is False
assert plan_needs_unit_conversion(
current, [line('L', 'load', '吨', customer='安庆市山峡化工有限公司')], {'cp': ['ping']}, [],
current, [line('L', 'load', '吨', customer='安庆市山峡化工有限公司')], {'cp': [bind('ping')]}, [],
) is True
assert plan_needs_unit_conversion(current, [line('L', 'load', '吨')], {'cp': ['kg']}, []) is False
assert plan_needs_unit_conversion(current, [line('L', 'load', '升')], {'cp': ['毫升']}, []) is False
assert plan_needs_unit_conversion(current, [line('L', 'load', '吨')], {'cp': [bind('kg')]}, []) is False
assert plan_needs_unit_conversion(current, [line('L', 'load', '升')], {'cp': [bind('毫升')]}, []) is False
def test_unbound_and_unselected_legacy_sides_are_display_only():
both = [line('L', 'load', '吨'), line('D', 'delivery', '吨')]
arranged = plan(transport_status='arranged')
assert plan_needs_unit_conversion(arranged, both, {'cp': ['kg']}, []) is False
assert plan_needs_unit_conversion(arranged, both, {'cp': [bind('kg')]}, []) is False
chosen = plan(transport_status='arranged', configuration={'sourceMapping': [
{'sourceStopType': 'load', 'sourceStopProductId': 'L', 'relatedStopProductIds': ['D']},
]})
assert plan_needs_unit_conversion(chosen, both, {'cp': ['瓶']}, []) is True
assert plan_needs_unit_conversion(chosen, both, {'cp': ['kg']}, []) is False
assert plan_needs_unit_conversion(chosen, both, {'cp': [bind('瓶')]}, []) is True
assert plan_needs_unit_conversion(chosen, both, {'cp': [bind('kg')]}, []) is False
def test_new_plan_counts_either_stop_when_a_bound_certificate_uses_another_unit():
current = plan()
assert plan_needs_unit_conversion(
current, [line('L', 'load', 'dun'), line('D', 'delivery', 'dun')], {'cp': ['kg']}, [],
current, [line('L', 'load', 'dun'), line('D', 'delivery', 'dun')], {'cp': [bind('kg')]}, [],
) is False
assert plan_needs_unit_conversion(
current, [line('L', 'load', 'dun'), line('D', 'delivery', 'dun')], {'cp': ['ping']}, [],
current, [line('L', 'load', 'dun'), line('D', 'delivery', 'dun')], {'cp': [bind('ping')]}, [],
) is True
assert plan_needs_unit_conversion(current, [line('L', 'load', 'dun')], {}, []) is False
def test_waived_or_saved_manual_conversion_clears_pending_flag():
current = plan(quota_cert_waived=1)
assert plan_needs_unit_conversion(
current, [line('L', 'load', 'ping')], {'cp': [bind('sheng', cert_id='buy')]}, [],
) is False
saved = plan(configuration={
'manualAllocations': [{
'permitKind': 'purchase', 'sourceId': 'delivery:D', 'permitId': 'buy',
'sourceQuantity': '10', 'permitQuantity': '2.5', 'note': '1瓶=0.25升',
}],
})
assert plan_needs_unit_conversion(
saved, [line('D', 'delivery', 'ping')], {'cp': [bind('sheng', cert_id='buy')]}, [],
) is False
incomplete = plan(configuration={
'manualAllocations': [{
'permitKind': 'purchase', 'sourceId': 'delivery:D', 'permitId': 'buy',
'sourceQuantity': '10', 'permitQuantity': '', 'note': '',
}],
})
assert plan_needs_unit_conversion(
incomplete, [line('D', 'delivery', 'ping')], {'cp': [bind('sheng', cert_id='buy')]}, [],
) is True
def test_list_and_badge_count_only_plans_with_a_real_unit_mismatch(monkeypatch):
engine = create_engine('sqlite://')
models = (
@@ -119,6 +119,20 @@ def test_maintenance_process_board_filters():
assert "handler_id" in joined
def test_recharge_apply_type_ne_filter():
filters = build_recharge_apply_column_filters(recharge_type_ne="etc")
assert len(filters) == 1
sql = _compile(filters[0])
assert "recharge_type" in sql
assert "!=" in sql or "<>" in sql
assert "etc" in sql
def test_recharge_disburse_board_type_ne_filter():
filters = build_recharge_disburse_board_column_filters(recharge_type_ne="etc")
assert any("recharge_type" in _compile(f) for f in filters)
def test_empty():
assert build_recharge_apply_column_filters() == []
assert build_recharge_disburse_board_column_filters() == []
@@ -20,7 +20,7 @@ from app.schemas.pl.vehicle_recharge import APPLY_STATUSES, NODE_ACTION_TYPES, R
def test_apply_statuses_include_workflow_states():
assert {"draft", "in_approval", "rejected", "disbursed", "cancelled"} <= APPLY_STATUSES
assert {"draft", "in_approval", "pending", "rejected", "disbursed", "cancelled"} <= APPLY_STATUSES
assert {"approve", "disburse"} <= NODE_ACTION_TYPES
assert {"etc", "fuel"} <= RECHARGE_TYPES
@@ -98,6 +98,40 @@ def _seed_workflow(db) -> tuple[str, str, str]:
return wf_id, node_gm, node_fin
def _seed_three_node_workflow(db) -> tuple[str, str, str, str]:
wf_id = "wf-3"
n1, n2, n3 = "node-a1", "node-a2", "node-dis"
db.add(
BxWorkflowDef(
id=wf_id,
biz_type="vehicle_recharge",
wf_name="三级充值流程",
status=1,
tenant_no="t1",
create_dept_id="d1",
)
)
for nid, name, order, uid, uname, action in (
(n1, "一审", 1, "u-a1", "一审", "approve"),
(n2, "二审", 2, "u-a2", "二审", "approve"),
(n3, "发放", 3, "u-fin", "财务", "disburse"),
):
db.add(
BxWorkflowNode(
id=nid,
wf_id=wf_id,
node_name=name,
node_order=order,
approver_id=uid,
approver_name=uname,
action_type=action,
tenant_no="t1",
create_dept_id="d1",
)
)
return wf_id, n1, n2, n3
def _patch_scope(monkeypatch, *modules) -> None:
for mod in modules:
monkeypatch.setattr(mod, "apply_pl_scope", lambda query, *_a, **_k: query)
@@ -272,7 +306,7 @@ def test_approve_advances_to_next_node(monkeypatch):
result = approval_svc.approve_apply(
db, "t1", _user("u-gm", "总经理"), "apply-1", opinion="同意"
)
assert result.status == "in_approval"
assert result.status == "pending"
assert result.current_node == node_fin
assert any(a.approval_status == "approved" for a in result.approvals)
assert len(result.approval_flow) == 2
@@ -285,6 +319,79 @@ def test_approve_advances_to_next_node(monkeypatch):
assert result.approval_history[0].approval_status == "approved"
def test_mid_approve_stays_in_approval_final_approve_sets_pending(monkeypatch):
from app.services import pl_vehicle_recharge_approval_service as approval_svc
from app.services import pl_vehicle_recharge_service as svc
db = _make_session()
_seed_vehicle(db)
wf_id, n1, n2, n3 = _seed_three_node_workflow(db)
db.add(
PlVehicleRechargeApply(
id="apply-1",
apply_no="YK2026092601",
license_plate="鄂A00001",
recharge_type="fuel",
apply_date=date(2026, 9, 26),
applicant_id="u-applicant",
status="in_approval",
wf_id=wf_id,
current_node=n1,
approval_round=1,
tenant_no="t1",
create_dept_id="d1",
)
)
db.commit()
_patch_scope(monkeypatch, svc)
mid = approval_svc.approve_apply(db, "t1", _user("u-a1", "一审"), "apply-1", opinion="ok")
assert mid.status == "in_approval"
assert mid.current_node == n2
final = approval_svc.approve_apply(db, "t1", _user("u-a2", "二审"), "apply-1", opinion="ok")
assert final.status == "pending"
assert final.current_node == n3
def test_disburse_accepts_pending_status(monkeypatch):
from app.services import pl_vehicle_recharge_approval_service as approval_svc
from app.services import pl_vehicle_recharge_service as svc
from app.schemas.pl.vehicle_recharge import VehicleRechargeRecordCreate
db = _make_session()
_seed_vehicle(db)
wf_id, _gm, node_fin = _seed_workflow(db)
db.add(
PlVehicleRechargeApply(
id="apply-1",
apply_no="ETC2026092601",
license_plate="鄂A00001",
recharge_type="etc",
apply_date=date(2026, 9, 26),
applicant_id="u-applicant",
status="pending",
wf_id=wf_id,
current_node=node_fin,
approval_round=1,
tenant_no="t1",
create_dept_id="d1",
)
)
db.commit()
_patch_scope(monkeypatch, svc)
payload = VehicleRechargeRecordCreate(
rechargeDate=date(2026, 9, 26),
rechargeType="etc",
amount=Decimal("50.00"),
applyId="apply-1",
)
result = approval_svc.disburse_apply(
db, "t1", _user("u-fin", "财务"), "apply-1", payload
)
assert result.status == "disbursed"
def test_reject_sets_rejected_and_appends_history(monkeypatch):
from app.services import pl_vehicle_recharge_approval_service as approval_svc
from app.services import pl_vehicle_recharge_service as svc
@@ -508,3 +615,49 @@ def test_withdraw_rejects_non_in_approval(monkeypatch):
with pytest.raises(HTTPException) as ei:
svc.withdraw_apply(db, "t1", _user("u-applicant"), "a-wd3")
assert ei.value.status_code == 400
def test_delete_record_with_workflow_reverts_to_pending(monkeypatch):
from app.services import pl_vehicle_recharge_service as svc
db = _make_session()
_seed_vehicle(db)
wf_id, _gm, node_fin = _seed_workflow(db)
db.add(
PlVehicleRechargeApply(
id="apply-1",
apply_no="ETC2026092601",
license_plate="鄂A00001",
recharge_type="etc",
apply_date=date(2026, 9, 26),
applicant_id="u-applicant",
status="disbursed",
wf_id=wf_id,
current_node=None,
approval_round=1,
tenant_no="t1",
create_by="u-applicant",
create_dept_id="d1",
)
)
db.add(
PlVehicleRechargeRecord(
id="rec-1",
recharge_order_no="RO1",
recharge_date=date(2026, 9, 26),
recharge_type="etc",
amount=Decimal("10.00"),
apply_id="apply-1",
license_plate="鄂A00001",
tenant_no="t1",
create_by="u-fin",
create_dept_id="d1",
del_flag=0,
)
)
db.commit()
_patch_scope(monkeypatch, svc)
svc.delete_records(db, "t1", _user("u-fin", "财务"), ["rec-1"])
apply = db.query(PlVehicleRechargeApply).filter_by(id="apply-1").one()
assert apply.status == "pending"
assert apply.current_node == node_fin
@@ -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,210 @@
-- 油卡充值与 ETC 充值菜单拆分,并按已有油卡菜单同步角色。
-- MySQL 5.7 / utf8mb4。可重复执行。仅租户 shanxia2.0 的有效菜单。
-- 不移动 PL_VEH_FUEL_CARD,车油卡信息仍挂在油卡分组下。
-- 不改 docs/sql/shanxia2.0.sql 全量快照;快照仍是本迁移之前的数据,不要为这条脚本重导整库。
-- 审批菜单 PL_VEH_RECHARGE_APPROVAL 在快照与迁移中没有 F 按钮,本脚本不为 ETC 审批补按钮。
SET NAMES utf8mb4;
START TRANSACTION;
-- A. 原「ETC/油卡」分组改为油卡,path 让给下面新建的 ETC 分组。
UPDATE `sys_menu`
SET `menu_name` = '油卡充值管理',
`path` = 'fuel-recharge',
`remark` = '油卡充值分组',
`update_time` = NOW()
WHERE `id` = 'PL_GROUP_VEH_RECHARGE'
AND `tenant_no` = 'shanxia2.0'
AND `del_flag` = 0;
UPDATE `sys_menu`
SET `menu_name` = '油卡充值申请', `update_time` = NOW()
WHERE `id` = 'PL_VEH_RECHARGE_APPLY'
AND `tenant_no` = 'shanxia2.0'
AND `del_flag` = 0;
UPDATE `sys_menu`
SET `menu_name` = '油卡充值审批', `update_time` = NOW()
WHERE `id` = 'PL_VEH_RECHARGE_APPROVAL'
AND `tenant_no` = 'shanxia2.0'
AND `del_flag` = 0;
UPDATE `sys_menu`
SET `menu_name` = '油卡充值发放管理', `update_time` = NOW()
WHERE `id` = 'PL_VEH_RECHARGE_RECORD'
AND `tenant_no` = 'shanxia2.0'
AND `del_flag` = 0;
UPDATE `sys_menu`
SET `menu_name` = '油卡充值审批人设置', `update_time` = NOW()
WHERE `id` = 'PL_VEH_RECHARGE_WF'
AND `tenant_no` = 'shanxia2.0'
AND `del_flag` = 0;
-- B. ETC 分组插在油卡分组原位置:先记下油卡 order_num,再把车辆管理下
-- order_num >= 该值的有效同级(含油卡本身,以及加油登记、维保等)全部 +1,
-- 然后用记下的序号插入 ETC。这样 ETC 紧挨在油卡之前,且不会和油卡同号。
-- MySQL 5.7 的 UPDATE 不能在子查询里再读 sys_menu,所以序号先放进用户变量。
-- 仅当 ETC 分组尚不存在、且车辆管理与油卡分组都有效时才顺延;重复执行不再 +1。
-- 请在同一会话整文件执行,@oil_order 等用户变量不能跨连接。
SET @etc_group_exists := (
SELECT COUNT(*) FROM `sys_menu` WHERE `id` = 'PL_GROUP_VEH_ETC_RECHARGE'
);
SET @vehicle_exists := (
SELECT COUNT(*) FROM `sys_menu`
WHERE `id` = 'PL_GROUP_VEHICLE' AND `tenant_no` = 'shanxia2.0' AND `del_flag` = 0
);
SET @oil_order := (
SELECT `order_num` FROM `sys_menu`
WHERE `id` = 'PL_GROUP_VEH_RECHARGE' AND `tenant_no` = 'shanxia2.0' AND `del_flag` = 0
LIMIT 1
);
UPDATE `sys_menu`
SET `order_num` = `order_num` + 1,
`update_time` = NOW()
WHERE `parent_id` = 'PL_GROUP_VEHICLE'
AND `tenant_no` = 'shanxia2.0'
AND `del_flag` = 0
AND @etc_group_exists = 0
AND @vehicle_exists = 1
AND @oil_order IS NOT NULL
AND `order_num` >= @oil_order;
INSERT INTO `sys_menu`
(`id`,`menu_name`,`parent_id`,`menu_type`,`path`,`component`,`perms`,
`is_frame`,`visible`,`status`,`tenant_no`,`order_num`,`remark`,`create_by`,`create_dept_id`)
SELECT 'PL_GROUP_VEH_ETC_RECHARGE', 'ETC充值管理', vehicle.id, 'M', 'etc-recharge',
NULL, NULL, 1, '1', '1', vehicle.tenant_no, COALESCE(@oil_order, 1),
'ETC充值分组', 'system', ''
FROM `sys_menu` vehicle
WHERE vehicle.id = 'PL_GROUP_VEHICLE'
AND vehicle.tenant_no = 'shanxia2.0'
AND vehicle.del_flag = 0
AND NOT EXISTS (
SELECT 1 FROM `sys_menu` existing WHERE existing.id = 'PL_GROUP_VEH_ETC_RECHARGE'
);
-- C. 四个页面菜单。审批人设置复用油卡流程权限,因此只按 id 判重,不能按 perms 判重。
INSERT INTO `sys_menu`
(`id`,`menu_name`,`parent_id`,`menu_type`,`path`,`component`,`perms`,
`is_frame`,`visible`,`status`,`tenant_no`,`order_num`,`create_by`,`create_dept_id`)
SELECT item.id, item.menu_name, parent.id, 'C', item.path, item.component, item.perms,
1, '1', '1', parent.tenant_no, item.order_num, 'system', ''
FROM `sys_menu` parent
JOIN (
SELECT 'PL_ETC_VEH_RECHARGE_APPLY' AS id, 'ETC充值申请' AS menu_name,
'etc-vehicle-recharge-apply' AS path, 'pl/etc-vehicle-recharge-apply/index' AS component,
'pl:etc-vehicle-recharge-apply:list' AS perms, 1 AS order_num
UNION ALL
SELECT 'PL_ETC_VEH_RECHARGE_APPROVAL', 'ETC充值审批',
'etc-vehicle-recharge-approval', 'pl/etc-vehicle-recharge-approval/index',
'pl:etc-vehicle-recharge-approval:list', 2
UNION ALL
SELECT 'PL_ETC_VEH_RECHARGE_RECORD', 'ETC发放管理',
'etc-vehicle-recharge-record', 'pl/etc-vehicle-recharge-record/index',
'pl:etc-vehicle-recharge-record:list', 3
UNION ALL
SELECT 'PL_ETC_VEH_RECHARGE_WF', 'ETC充值审批人设置',
'etc-vehicle-recharge-workflow', 'pl/etc-vehicle-recharge-workflow/index',
'pl:vehicle-recharge-workflow:list', 4
) item
WHERE parent.id = 'PL_GROUP_VEH_ETC_RECHARGE'
AND parent.tenant_no = 'shanxia2.0'
AND parent.del_flag = 0
AND NOT EXISTS (
SELECT 1 FROM `sys_menu` existing WHERE existing.id = item.id
);
-- D. 申请按钮,对照 PL_VRA_Q/A/E/R/C。
INSERT INTO `sys_menu`
(`id`,`menu_name`,`parent_id`,`menu_type`,`path`,`component`,`perms`,
`is_frame`,`visible`,`status`,`tenant_no`,`order_num`,`create_by`,`create_dept_id`)
SELECT btn.id, btn.menu_name, parent.id, 'F', '', '', btn.perms,
1, '1', '1', parent.tenant_no, btn.order_num, 'system', ''
FROM `sys_menu` parent
JOIN (
SELECT 'PL_ETC_VRA_Q' AS id, '查询' AS menu_name, 'pl:etc-vehicle-recharge-apply:query' AS perms, 1 AS order_num
UNION ALL SELECT 'PL_ETC_VRA_A', '新增', 'pl:etc-vehicle-recharge-apply:add', 2
UNION ALL SELECT 'PL_ETC_VRA_E', '编辑', 'pl:etc-vehicle-recharge-apply:edit', 3
UNION ALL SELECT 'PL_ETC_VRA_R', '删除', 'pl:etc-vehicle-recharge-apply:remove', 4
UNION ALL SELECT 'PL_ETC_VRA_C', '作废', 'pl:etc-vehicle-recharge-apply:cancel', 5
) btn
WHERE parent.id = 'PL_ETC_VEH_RECHARGE_APPLY'
AND parent.tenant_no = 'shanxia2.0'
AND parent.del_flag = 0
AND NOT EXISTS (
SELECT 1 FROM `sys_menu` existing WHERE existing.id = btn.id
);
-- E. 发放按钮,对照 PL_VRR_Q/A/E/R。审批侧没有对应 F 按钮,这里不补。
INSERT INTO `sys_menu`
(`id`,`menu_name`,`parent_id`,`menu_type`,`path`,`component`,`perms`,
`is_frame`,`visible`,`status`,`tenant_no`,`order_num`,`create_by`,`create_dept_id`)
SELECT btn.id, btn.menu_name, parent.id, 'F', '', '', btn.perms,
1, '1', '1', parent.tenant_no, btn.order_num, 'system', ''
FROM `sys_menu` parent
JOIN (
SELECT 'PL_ETC_VRR_Q' AS id, '查询' AS menu_name, 'pl:etc-vehicle-recharge-record:query' AS perms, 1 AS order_num
UNION ALL SELECT 'PL_ETC_VRR_A', '新增', 'pl:etc-vehicle-recharge-record:add', 2
UNION ALL SELECT 'PL_ETC_VRR_E', '编辑', 'pl:etc-vehicle-recharge-record:edit', 3
UNION ALL SELECT 'PL_ETC_VRR_R', '删除', 'pl:etc-vehicle-recharge-record:remove', 4
) btn
WHERE parent.id = 'PL_ETC_VEH_RECHARGE_RECORD'
AND parent.tenant_no = 'shanxia2.0'
AND parent.del_flag = 0
AND NOT EXISTS (
SELECT 1 FROM `sys_menu` existing WHERE existing.id = btn.id
);
-- G. 角色同步。拥有油卡页面(del_flag=0)且角色仍有效的,授予对等 ETC 页面、
-- 其下 F 按钮,以及最多三层祖先(ETC 分组、车辆管理、Web 根)。
-- 同一条 INSERT 里用 DISTINCT,避免一个角色同时拥有多个油卡页面时把祖先插重。
-- 已有任意 role_menu 行(含 del_flag=1)则跳过:不把软删除改回有效,也不另插一条把授权加回来。
INSERT INTO `sys_role_menu`
(`id`,`role_id`,`menu_id`,`tenant_no`,`create_by`,`create_dept_id`)
SELECT UUID(), grant_row.role_id, grant_row.menu_id, grant_row.tenant_no, 'system', ''
FROM (
SELECT DISTINCT rm.role_id, target.id AS menu_id, rm.tenant_no
FROM (
SELECT 'PL_VEH_RECHARGE_APPLY' AS source_id, 'PL_ETC_VEH_RECHARGE_APPLY' AS target_id
UNION ALL SELECT 'PL_VEH_RECHARGE_APPROVAL', 'PL_ETC_VEH_RECHARGE_APPROVAL'
UNION ALL SELECT 'PL_VEH_RECHARGE_RECORD', 'PL_ETC_VEH_RECHARGE_RECORD'
UNION ALL SELECT 'PL_VEH_RECHARGE_WF', 'PL_ETC_VEH_RECHARGE_WF'
) mapping
JOIN `sys_role_menu` rm
ON rm.menu_id = mapping.source_id
AND rm.tenant_no = 'shanxia2.0'
AND rm.del_flag = 0
JOIN `sys_role` r
ON r.id = rm.role_id
AND r.tenant_no = rm.tenant_no
AND r.del_flag = 0
AND r.status = '1'
JOIN `sys_menu` child
ON child.id = mapping.target_id
AND child.tenant_no = rm.tenant_no
AND child.del_flag = 0
LEFT JOIN `sys_menu` p1
ON p1.id = child.parent_id AND p1.tenant_no = rm.tenant_no AND p1.del_flag = 0
LEFT JOIN `sys_menu` p2
ON p2.id = p1.parent_id AND p2.tenant_no = rm.tenant_no AND p2.del_flag = 0
LEFT JOIN `sys_menu` p3
ON p3.id = p2.parent_id AND p3.tenant_no = rm.tenant_no AND p3.del_flag = 0
LEFT JOIN `sys_menu` btn
ON btn.parent_id = child.id
AND btn.tenant_no = rm.tenant_no
AND btn.del_flag = 0
AND btn.menu_type = 'F'
JOIN `sys_menu` target
ON target.tenant_no = rm.tenant_no
AND target.del_flag = 0
AND target.id IN (child.id, p1.id, p2.id, p3.id, btn.id)
) grant_row
WHERE NOT EXISTS (
SELECT 1 FROM `sys_role_menu` existing
WHERE existing.role_id = grant_row.role_id
AND existing.menu_id = grant_row.menu_id
AND existing.tenant_no = grant_row.tenant_no
);
COMMIT;
@@ -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,413 @@
# 证件到期提醒跳转编辑弹窗 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在证件到期提醒列表增加「维护证件」操作列,按车辆/人员及自有/三方跳到基础信息库对应页并自动打开编辑弹窗。
**Architecture:** 纯函数根据主体类型与归属字段解析目标 path;提醒页 get 详情后 `router.push({ path, query: { editId } })`;车辆/自有车辆/人员页监听 `editId` 调用现有 `handleEdit`,成功后 `replace` 清除 query。后端不动。
**Tech Stack:** Vue 3 / TypeScript / vue-router / Element Plus;`node:test` + `tsx`。
**Spec:** `docs/superpowers/specs/2026-11-12-document-reminder-edit-jump-design.md`
---
## File map
| File | Responsibility |
| --- | --- |
| `web-admin/src/utils/pl-document-reminder-jump.ts` | 解析目标 path;错误文案常量 |
| `web-admin/src/utils/pl-document-reminder-jump.test.ts` | 路径解析单测 |
| `web-admin/src/composables/useRouteEditIdOpen.ts` | 监听 `editId`、调用 opener、清 query |
| `web-admin/src/views/pl/inventory/document-reminder.vue` | 操作列 + 跳转 |
| `web-admin/src/views/pl/vehicle/index.vue` | 接入 `editId` 打开编辑 |
| `web-admin/src/views/pl/self-vehicle/index.vue` | 同上 |
| `web-admin/src/views/pl/personnel/index.vue` | 同上(覆盖 self / carrier 两路由) |
说明:提交步骤在已获用户授权时执行;否则暂存改动待授权再 commit。
---
### Task 1: 路径解析纯函数 + 单测
**Files:**
- Create: `web-admin/src/utils/pl-document-reminder-jump.ts`
- Create: `web-admin/src/utils/pl-document-reminder-jump.test.ts`
- [ ] **Step 1: 写失败测试**
```ts
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
resolveDocumentReminderEditPath,
DOCUMENT_REMINDER_JUMP_UNSUPPORTED
} from './pl-document-reminder-jump'
describe('证件提醒跳转路径', () => {
it('车辆按 vehicleType 分流自有与三方', () => {
assert.equal(
resolveDocumentReminderEditPath({ ownerType: 'vehicle', vehicleType: 'self' }),
'/vehicle-mgmt/base-info/self-vehicle'
)
assert.equal(
resolveDocumentReminderEditPath({ ownerType: 'vehicle', vehicleType: 'carrier' }),
'/vehicle-mgmt/base-info/vehicle'
)
assert.equal(
resolveDocumentReminderEditPath({ ownerType: 'vehicle', vehicleType: '' }),
'/vehicle-mgmt/base-info/vehicle'
)
})
it('人员按 personnelAffiliation 分流', () => {
assert.equal(
resolveDocumentReminderEditPath({
ownerType: 'personnel',
personnelAffiliation: 'self'
}),
'/vehicle-mgmt/base-info/personnel'
)
assert.equal(
resolveDocumentReminderEditPath({
ownerType: 'personnel',
personnelAffiliation: 'carrier'
}),
'/vehicle-mgmt/base-info/personnel-carrier'
)
assert.equal(
resolveDocumentReminderEditPath({
ownerType: 'personnel',
personnelAffiliation: ''
}),
'/vehicle-mgmt/base-info/personnel'
)
})
it('不支持的主体返回 null', () => {
assert.equal(resolveDocumentReminderEditPath({ ownerType: 'carrier' }), null)
assert.equal(resolveDocumentReminderEditPath({ ownerType: '' }), null)
assert.equal(DOCUMENT_REMINDER_JUMP_UNSUPPORTED, '暂不支持该主体类型的证件维护跳转')
})
})
```
- [ ] **Step 2: 跑测确认失败**
```bash
cd web-admin && ./node_modules/.bin/tsx --test src/utils/pl-document-reminder-jump.test.ts
```
Expected: FAIL(模块不存在)
- [ ] **Step 3: 实现**
```ts
export const DOCUMENT_REMINDER_JUMP_UNSUPPORTED = '暂不支持该主体类型的证件维护跳转'
export type ReminderJumpInput = {
ownerType?: string | null
vehicleType?: string | null
personnelAffiliation?: string | null
}
export function resolveDocumentReminderEditPath(input: ReminderJumpInput): string | null {
const ownerType = String(input.ownerType || '').trim()
if (ownerType === 'vehicle') {
return String(input.vehicleType || '').trim() === 'self'
? '/vehicle-mgmt/base-info/self-vehicle'
: '/vehicle-mgmt/base-info/vehicle'
}
if (ownerType === 'personnel') {
return String(input.personnelAffiliation || '').trim() === 'carrier'
? '/vehicle-mgmt/base-info/personnel-carrier'
: '/vehicle-mgmt/base-info/personnel'
}
return null
}
```
- [ ] **Step 4: 跑测确认通过**
同 Step 2,Expected: PASS
- [ ] **Step 5: Commit(需授权)**
```bash
git add web-admin/src/utils/pl-document-reminder-jump.ts \
web-admin/src/utils/pl-document-reminder-jump.test.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add document reminder jump path resolver
EOF
)"
```
---
### Task 2: `useRouteEditIdOpen` composable
**Files:**
- Create: `web-admin/src/composables/useRouteEditIdOpen.ts`
- [ ] **Step 1: 实现 composable**
```ts
import { watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
/**
* 监听 route.query.editId,调用 openById;结束后清除 editId,避免刷新重复弹窗。
* openById 应自行处理加载失败提示。
*/
export function useRouteEditIdOpen(openById: (id: string) => void | Promise<void>) {
const route = useRoute()
const router = useRouter()
async function consumeEditId() {
const raw = route.query.editId
const id = Array.isArray(raw) ? String(raw[0] || '') : String(raw || '')
if (!id.trim()) return
try {
await openById(id.trim())
} finally {
if (route.query.editId == null) return
const query = { ...route.query }
delete query.editId
await router.replace({ path: route.path, query })
}
}
watch(
() => route.query.editId,
() => {
void consumeEditId()
},
{ immediate: true }
)
}
```
- [ ] **Step 2: Commit(需授权)**
```bash
git add web-admin/src/composables/useRouteEditIdOpen.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add useRouteEditIdOpen for deep-link edit dialogs
EOF
)"
```
---
### Task 3: 提醒列表操作列与跳转
**Files:**
- Modify: `web-admin/src/views/pl/inventory/document-reminder.vue`
- [ ] **Step 1: 模板 — 在 `</ListTable>` 前增加操作列**
在状态/备注列之后、`</ListTable>` 之前插入:
```vue
<el-table-column label="操作" fixed="right" min-width="140" align="center">
<template #default="{ row }">
<el-button
link
type="primary"
:loading="maintainLoadingId === row.id"
@click="handleMaintainCert(row)"
>
<el-icon><EditPen /></el-icon>维护证件
</el-button>
</template>
</el-table-column>
```
- [ ] **Step 2: script — 导入与跳转逻辑**
1. 图标:`import { CircleCheck, Filter, EditPen } from '@element-plus/icons-vue'`
2. API:`import { inventoryApi, vehicleApi, personnelApi, type DocumentReminder } from '@/api/pl'`
3. `import { useRouter } from 'vue-router'`(若尚未)
4. `import { pickBxData } from '@/utils/http/bx'`
5. `import { resolveDocumentReminderEditPath, DOCUMENT_REMINDER_JUMP_UNSUPPORTED } from '@/utils/pl-document-reminder-jump'`
6. `import type { VehicleDetail, PersonnelDetail } from '@/api/pl'`(若类型从同文件导出则合并 import)
```ts
const router = useRouter()
const maintainLoadingId = ref<string | null>(null)
async function handleMaintainCert(row: DocumentReminder) {
const ownerId = String(row.ownerId || '').trim()
const ownerType = String(row.ownerType || '').trim()
if (!ownerId || !ownerType) {
ElMessage.warning('缺少主体信息,无法跳转维护')
return
}
maintainLoadingId.value = row.id
try {
let path: string | null = null
if (ownerType === 'vehicle') {
const res = await vehicleApi.get(ownerId)
const detail = pickBxData<VehicleDetail>(res) || (res as any)?.data || res
const vehicleType = String((detail as VehicleDetail)?.vehicleType || '')
path = resolveDocumentReminderEditPath({ ownerType, vehicleType })
} else if (ownerType === 'personnel') {
const res = await personnelApi.get(ownerId)
const detail = pickBxData<PersonnelDetail>(res) || (res as any)?.data || res
const personnelAffiliation = String(
(detail as PersonnelDetail)?.personnelAffiliation || ''
)
path = resolveDocumentReminderEditPath({ ownerType, personnelAffiliation })
} else {
ElMessage.warning(DOCUMENT_REMINDER_JUMP_UNSUPPORTED)
return
}
if (!path) {
ElMessage.warning(DOCUMENT_REMINDER_JUMP_UNSUPPORTED)
return
}
await router.push({ path, query: { editId: ownerId } })
} catch {
ElMessage.error('无法打开主体档案,请确认数据存在且有权限')
} finally {
maintainLoadingId.value = null
}
}
```
确保页面已导入 `ElMessage`(项目若自动导入则无需手写)。
- [ ] **Step 3: Commit(需授权)**
```bash
git add web-admin/src/views/pl/inventory/document-reminder.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): add maintain-cert action on document reminder list
EOF
)"
```
---
### Task 4: 车辆 / 自有车辆页响应 `editId`
**Files:**
- Modify: `web-admin/src/views/pl/vehicle/index.vue`
- Modify: `web-admin/src/views/pl/self-vehicle/index.vue`
- [ ] **Step 1: vehicle/index.vue**
在 `handleEdit` 定义之后加入:
```ts
import { useRouteEditIdOpen } from '@/composables/useRouteEditIdOpen'
useRouteEditIdOpen(async (id) => {
await handleEdit({ id } as Vehicle)
})
```
确认 `handleEdit` 仅依赖 `row.id` 拉详情(现有实现用 `vehicleApi.get(row.id)`)。若 TypeScript 抱怨,使用 `as Vehicle` 或最小对象即可。
- [ ] **Step 2: self-vehicle/index.vue**
同样在 `handleEdit` 之后:
```ts
import { useRouteEditIdOpen } from '@/composables/useRouteEditIdOpen'
useRouteEditIdOpen(async (id) => {
await handleEdit({ id } as Vehicle)
})
```
(若自有车页 Vehicle 类型同名,用该页已有类型。)
- [ ] **Step 3: Commit(需授权)**
```bash
git add web-admin/src/views/pl/vehicle/index.vue \
web-admin/src/views/pl/self-vehicle/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): open vehicle edit dialog from editId query
EOF
)"
```
---
### Task 5: 人员页响应 `editId`
**Files:**
- Modify: `web-admin/src/views/pl/personnel/index.vue`
- [ ] **Step 1: 接入 composable**
在 `handleEdit` 之后:
```ts
import { useRouteEditIdOpen } from '@/composables/useRouteEditIdOpen'
useRouteEditIdOpen(async (id) => {
await handleEdit({ id } as Personnel)
})
```
该组件同时挂 `personnel` 与 `personnel-carrier`,两路由都会响应 `editId`。提醒页已按 affiliation 选对 path;若进错页,`handleEdit`/列表数据范围会失败——保持现有错误提示即可,必要时在 `openById` 外包一层 try/catch 用 `ElMessage.error`。
- [ ] **Step 2: Commit(需授权)**
```bash
git add web-admin/src/views/pl/personnel/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): open personnel edit dialog from editId query
EOF
)"
```
---
### Task 6: 验证
**Files:** 无必须改动。
- [ ] **Step 1: 单元测试**
```bash
cd web-admin && ./node_modules/.bin/tsx --test src/utils/pl-document-reminder-jump.test.ts
```
Expected: PASS
- [ ] **Step 2: 手工冒烟(需登录)**
1. 打开 `/#/pl/document-reminder-module/document-reminder`
2. 点车辆行「维护证件」→ 进入对应自有/三方车辆页且编辑弹窗打开,证件可编辑
3. 点人员行同理
4. 刷新目标页不重复弹窗(query 已清)
5. 原「处理」按钮仍可用
- [ ] **Step 3: 记录未跑项**
若无法登录 UI,在交付说明写明仅完成单测与代码接入,UI 冒烟待用户验证。
---
## Spec coverage
| 设计项 | 任务 |
| --- | --- |
| 操作列「维护证件」 | Task 3 |
| 按类型/归属分流 path | Task 1 + 3 |
| `editId` 打开编辑并清 query | Task 2 + 4 + 5 |
| 后端不动 / 不自动 resolve | 全程遵守 |
| 仅 Web | 不改 miniprogram |
## Placeholder scan
无 TBD;路径、文件、测试命令、代码块已钉死。
@@ -0,0 +1,543 @@
# ETC / 油卡充值管理拆分 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Web 端将混合的「ETC/油卡充值管理」拆成并列的【ETC充值管理】与【油卡充值管理】;前端复制页面并按类型过滤,后端共用接口并新增 `rechargeTypeNe`;审批流程与审批人配置保持一套。
**Architecture:** 后端在现有 list/export/approval/board 过滤链增加 `recharge_type_ne`;油卡前端固定传 `rechargeTypeNe=etc`,ETC 前端固定传 `rechargeType=etc` 并复制四套页面。原分组 path 改为 `fuel-recharge`,新建 `etc-recharge` 分组;顶部 `PlRechargeSummary` 仅油卡路径显示。工作流 `biz_type=vehicle_recharge` 不拆。
**Tech Stack:** FastAPI / SQLAlchemy;Vue 3 / TypeScript / Element Plus;pytest;`node:test` + `tsx`;MySQL 菜单迁移 SQL。
**Spec:** `docs/superpowers/specs/2026-11-12-pl-etc-fuel-recharge-split-design.md`
---
## File map
| File | Responsibility |
| --- | --- |
| `backend/app/services/pl/vehicle_recharge_list_filters.py` | `recharge_type_ne` → `col != value` |
| `backend/app/services/pl_vehicle_recharge_service.py` | `list_applies` / `list_disburse_board`(及 list_records 若已接类型)透传 ne |
| `backend/app/services/pl_vehicle_recharge_approval_service.py` | pending/done 透传 ne |
| `backend/app/api/pl/vehicle_recharge.py` | Query/export body 增加 `rechargeTypeNe` |
| `backend/app/api/pl/vehicle_recharge_approval.py` | pending/done 增加 `rechargeTypeNe` |
| `backend/tests/test_pl_vehicle_ops_list_filters.py` | ne 过滤单测 |
| `web-admin/src/utils/pl-recharge-summary.ts` | 仅 `fuel-recharge` 显示统计 |
| `web-admin/src/utils/pl-recharge-summary.test.ts` | 路由断言更新 |
| `web-admin/src/views/pl/vehicle-recharge-{apply,approval,record,workflow}/` | 油卡侧:排除 etc、文案、默认类型 |
| `web-admin/src/views/pl/etc-vehicle-recharge-{apply,approval,record,workflow}/` | ETC 复制页:固定 etc、无油卡字段、新 table-key |
| `web-admin/src/router/modules/pl.ts` | 分组 path、标题、ETC 子路由、旧 URL redirect |
| `docs/sql/migrations/2026-11-12-pl-etc-fuel-recharge-split-menus.sql` | 改名、改 path、新建 ETC 菜单、角色同步 |
| `web-admin/src/components/core/layouts/art-page-content/index.vue` | 确认 summary 仍指向油卡申请 menuCode(一般无需改逻辑) |
**说明:**
- 「车油卡信息」`PL_VEH_FUEL_CARD` 继续留在油卡分组下(现挂在原充值分组),不迁入 ETC。
- 写入接口本期不按域拆分;类型由前端固定。
- 提交前须有用户明确授权再 `git commit`;下列 Commit 步骤在已授权时执行。
---
### Task 1: 后端 `rechargeTypeNe` 过滤
**Files:**
- Modify: `backend/app/services/pl/vehicle_recharge_list_filters.py`
- Modify: `backend/tests/test_pl_vehicle_ops_list_filters.py`
- Modify: `backend/app/services/pl_vehicle_recharge_service.py`
- Modify: `backend/app/services/pl_vehicle_recharge_approval_service.py`
- Modify: `backend/app/api/pl/vehicle_recharge.py`
- Modify: `backend/app/api/pl/vehicle_recharge_approval.py`
- [ ] **Step 1: 写失败单测**
在 `test_pl_vehicle_ops_list_filters.py` 追加:
```python
def test_recharge_apply_type_ne_filter():
filters = build_recharge_apply_column_filters(recharge_type_ne="etc")
assert len(filters) == 1
sql = _compile(filters[0])
assert "recharge_type" in sql
assert "!=" in sql or "<>" in sql
assert "etc" in sql
def test_recharge_disburse_board_type_ne_filter():
filters = build_recharge_disburse_board_column_filters(recharge_type_ne="etc")
assert any("recharge_type" in _compile(f) for f in filters)
```
- [ ] **Step 2: 跑测确认失败**
```bash
cd backend && source venv/bin/activate
pytest tests/test_pl_vehicle_ops_list_filters.py::test_recharge_apply_type_ne_filter tests/test_pl_vehicle_ops_list_filters.py::test_recharge_disburse_board_type_ne_filter -v
```
Expected: FAIL(未知参数或无 ne 条件)
- [ ] **Step 3: 实现 filter 层**
在 `vehicle_recharge_list_filters.py` 增加:
```python
def _append_ne_filter(filters: list, col, raw: Optional[str]) -> None:
value = (raw or "").strip()
if value:
filters.append(col != value)
```
`build_recharge_apply_column_filters` / `build_recharge_disburse_board_column_filters` 增加参数 `recharge_type_ne: Optional[str] = None`,在现有 `_append_enum_filter(... recharge_type ...)` 之后调用:
```python
_append_ne_filter(filters, PlVehicleRechargeApply.recharge_type, recharge_type_ne)
```
(发放看板同样用 `PlVehicleRechargeApply.recharge_type`。)
- [ ] **Step 4: 透传 service + API**
对以下调用链增加同名可选参数并向下传递(alias `rechargeTypeNe`):
1. `list_applies`(service + `_apply_list_kwargs` / export 解析 + `list_applies` Query)
2. `list_disburse_board`(及 export)
3. `list_records`(若当前已支持 `recharge_type`,一并加 ne;否则仅保证 apply/board/approval)
4. approval `list_pending` / `list_done`(`vehicle_recharge_approval.py` 的 `_param_get` 与 Query)
示例(API Query):
```python
recharge_type_ne: Optional[str] = Query(default=None, alias="rechargeTypeNe"),
```
export body Field 同样加 `rechargeTypeNe`。
- [ ] **Step 5: 跑测确认通过**
```bash
cd backend && source venv/bin/activate
pytest tests/test_pl_vehicle_ops_list_filters.py -v
```
Expected: PASS
- [ ] **Step 6: Commit(需用户授权)**
```bash
git add backend/app/services/pl/vehicle_recharge_list_filters.py \
backend/app/services/pl_vehicle_recharge_service.py \
backend/app/services/pl_vehicle_recharge_approval_service.py \
backend/app/api/pl/vehicle_recharge.py \
backend/app/api/pl/vehicle_recharge_approval.py \
backend/tests/test_pl_vehicle_ops_list_filters.py
git commit -m "$(cat <<'EOF'
feat(pl): add rechargeTypeNe filter for vehicle recharge lists
EOF
)"
```
---
### Task 2: 顶部统计仅油卡路由
**Files:**
- Modify: `web-admin/src/utils/pl-recharge-summary.ts`
- Modify: `web-admin/src/utils/pl-recharge-summary.test.ts`
- [ ] **Step 1: 更新失败测试**
替换 `isRechargeSummaryRoute` 相关用例为:
```ts
it('只对油卡充值业务子页显示,ETC 与其它车辆页不显示', () => {
assert.equal(
isRechargeSummaryRoute('/vehicle-mgmt/fuel-recharge/vehicle-recharge-apply'),
true
)
assert.equal(
isRechargeSummaryRoute('/vehicle-mgmt/fuel-recharge/vehicle-recharge-record'),
true
)
assert.equal(
isRechargeSummaryRoute('/vehicle-mgmt/fuel-recharge/vehicle-recharge-approval'),
true
)
assert.equal(
isRechargeSummaryRoute('/vehicle-mgmt/fuel-recharge/vehicle-recharge-workflow'),
true
)
assert.equal(
isRechargeSummaryRoute('/vehicle-mgmt/etc-recharge/etc-vehicle-recharge-apply'),
false
)
assert.equal(
isRechargeSummaryRoute('/vehicle-mgmt/etc-recharge/vehicle-recharge-apply'),
false
)
assert.equal(isRechargeSummaryRoute('/vehicle-mgmt/vehicle-fuel-card'), false)
assert.equal(isRechargeSummaryRoute('/vehicle-mgmt/vehicle'), false)
})
```
- [ ] **Step 2: 跑测确认失败**
```bash
cd web-admin && ./node_modules/.bin/tsx --test src/utils/pl-recharge-summary.test.ts
```
Expected: FAIL(旧路径仍为 true / 新 fuel 路径未匹配)
- [ ] **Step 3: 实现**
```ts
export function isRechargeSummaryRoute(path: string) {
return /\/fuel-recharge\/vehicle-recharge-(apply|approval|record|workflow)\/?$/.test(
path
)
}
```
确认 `art-page-content` 仍用 `menuCode === 'pl:vehicle-recharge-apply:list'` 解析跳转(油卡申请 perms 不变),一般无需改文件。
- [ ] **Step 4: 跑测确认通过**
同 Step 2,Expected: PASS
- [ ] **Step 5: Commit(需用户授权)**
```bash
git add web-admin/src/utils/pl-recharge-summary.ts web-admin/src/utils/pl-recharge-summary.test.ts
git commit -m "$(cat <<'EOF'
fix(web-admin): show recharge summary only on fuel-recharge routes
EOF
)"
```
---
### Task 3: 油卡侧原页面改造(排除 ETC)
**Files:**
- Modify: `web-admin/src/views/pl/vehicle-recharge-apply/index.vue`
- Modify: `web-admin/src/views/pl/vehicle-recharge-approval/index.vue`
- Modify: `web-admin/src/views/pl/vehicle-recharge-record/index.vue`
- Modify: `web-admin/src/views/pl/vehicle-recharge-workflow/index.vue`
- Modify: `web-admin/src/views/pl/vehicle-recharge-apply/columnFilterDefs.ts`(若枚举选项在此定义)
- [ ] **Step 1: 申请页强制排除 etc**
在 `buildListQueryParams`(约 L715)末尾增加:
```ts
params.rechargeTypeNe = 'etc'
return params
```
若用户列筛选选了 `rechargeType` 且包含 `etc`,以 ne 为准仍排除 etc(后端 `!=` 与 `IN` 同时生效时需确认 SQLAlchemy 为 AND)。为避免矛盾:油卡页 `enumOptionsFor('rechargeType')` **过滤掉** `etc` 选项;表单 `<el-option>` 同样不渲染 `etc`。
默认新建类型:
```ts
// handleAdd / formData 初始
formData.rechargeType = 'fuel'
```
文案:文件头注释、导出 filename、提示里的「ETC/油卡」改为「油卡」(审批跳转文案等同理)。
- [ ] **Step 2: 审批页 / 发放页同样传 `rechargeTypeNe=etc`**
在各自组装 list/export/board 参数处增加 `rechargeTypeNe: 'etc'`;充值类型筛选项去掉 etc;文案改为油卡。
发放页中类似:
```ts
ElMessage.warning('审批中的申请请到「油卡充值审批」菜单完成发放登记')
```
- [ ] **Step 3: 审批人设置页**
顶部 `wf-info-bar` 旁或下方增加一行提示(简短即可):
```vue
<span class="form-tip">ETC 与油卡共用同一套审批人配置</span>
```
不改 `biz_type` / API。
- [ ] **Step 4: 本地点验(开发服)**
打开 `/#/vehicle-mgmt/fuel-recharge/vehicle-recharge-apply`(路由 Task 5 完成前可暂用旧 path 带 `rechargeTypeNe` 联调)。确认列表无 etc 行;新建无 ETC 选项。
- [ ] **Step 5: Commit(需用户授权)**
```bash
git add web-admin/src/views/pl/vehicle-recharge-apply \
web-admin/src/views/pl/vehicle-recharge-approval \
web-admin/src/views/pl/vehicle-recharge-record \
web-admin/src/views/pl/vehicle-recharge-workflow
git commit -m "$(cat <<'EOF'
fix(web-admin): restrict legacy recharge pages to non-ETC types
EOF
)"
```
---
### Task 4: 复制并改造 ETC 四套页面
**Files:**
- Create: `web-admin/src/views/pl/etc-vehicle-recharge-apply/`(含 `index.vue`、`columnFilterDefs.ts`)
- Create: `web-admin/src/views/pl/etc-vehicle-recharge-approval/index.vue`
- Create: `web-admin/src/views/pl/etc-vehicle-recharge-record/index.vue`
- Create: `web-admin/src/views/pl/etc-vehicle-recharge-workflow/index.vue`
- [ ] **Step 1: 复制目录**
```bash
cd web-admin/src/views/pl
cp -R vehicle-recharge-apply etc-vehicle-recharge-apply
cp -R vehicle-recharge-approval etc-vehicle-recharge-approval
cp -R vehicle-recharge-record etc-vehicle-recharge-record
cp -R vehicle-recharge-workflow etc-vehicle-recharge-workflow
```
- [ ] **Step 2: ETC 申请页改造清单(在复制后的文件上改)**
1. 注释与标题文案 →「ETC充值申请」等。
2. `buildListQueryParams`:删除 `rechargeTypeNe`;固定 `params.rechargeType = 'etc'`(若用户又选了类型筛选,仍强制 etc,或隐藏类型筛选项)。
3. 表单:充值类型固定 `etc`,可用只读展示或隐藏 select 并在提交时写死;去掉油卡卡号相关表单项与 `useVehicleFuelCard` 展示(ETC 不需要)。
4. `table-key`、`usePersistedListColumns` 的 localStorage key、导出列定义 key → 加 `etc-` 前缀,例如 `pl-etc-vehicle-recharge-apply`、`pl-etc-vehicle-recharge-apply-list-columns-v2`。
5. `columnFilterDefs` 中若 import 路径指向旧目录,改为相对本目录或 `@/views/pl/etc-vehicle-recharge-apply/columnFilterDefs`。
6. 权限指令 / `menuCode` 若页面内有硬编码,改为 ETC 新 perms(与菜单 SQL 一致,见 Task 6):`pl:etc-vehicle-recharge-apply:*`。
7. 统计刷新:可保留 `mittBus.emit('rechargeSummaryRefresh')`(无害);ETC 路由本就不显示 summary。
- [ ] **Step 3: ETC 审批 / 发放页**
- 列表参数固定 `rechargeType=etc`。
- 文案「ETC充值审批」「ETC发放管理」。
- 新 `table-key` / 列偏好 key。
- 发放页跳转提示改为「ETC充值审批」。
- 隐藏或固定类型筛选为 etc;无油卡卡号列亦可保留显示为 —。
- [ ] **Step 4: ETC 审批人设置页**
- 仍调用现有工作流 API / `vehicle_recharge` biz_type(与油卡页相同 service 调用)。
- 增加共用说明文案。
- `table-key` 改为 `pl-etc-vehicle-recharge-workflow`(仅前端偏好隔离)。
- 页面内 perms 复用 `pl:vehicle-recharge-workflow:*`(与设计一致)。
- [ ] **Step 5: Commit(需用户授权)**
```bash
git add web-admin/src/views/pl/etc-vehicle-recharge-apply \
web-admin/src/views/pl/etc-vehicle-recharge-approval \
web-admin/src/views/pl/etc-vehicle-recharge-record \
web-admin/src/views/pl/etc-vehicle-recharge-workflow
git commit -m "$(cat <<'EOF'
feat(web-admin): add dedicated ETC vehicle recharge pages
EOF
)"
```
---
### Task 5: 静态路由与 redirect
**Files:**
- Modify: `web-admin/src/router/modules/pl.ts`
- [ ] **Step 1: 原分组改为油卡**
将 `PL_GROUP_VEH_RECHARGE`:
- `path: 'etc-recharge'` → `'fuel-recharge'`
- `meta.title` → `'油卡充值管理'`
- 子菜单 title:`油卡充值申请` / `油卡充值审批` / `油卡充值发放管理` / `油卡充值审批人设置`
- **保留** `PL_VEH_FUEL_CARD`(车油卡信息)在此分组下
- component 仍指向原 `pl/vehicle-recharge-*`
- [ ] **Step 2: 新增 ETC 分组(插在油卡分组旁)**
```ts
{
id: 'PL_GROUP_VEH_ETC_RECHARGE',
name: 'PlVehicleEtcRechargeGroup',
path: 'etc-recharge',
meta: { title: 'ETC充值管理', keepAlive: false },
children: [
{
id: 'PL_ETC_VEH_RECHARGE_APPLY',
name: 'PlEtcVehicleRechargeApply',
path: 'etc-vehicle-recharge-apply',
component: '/pl/etc-vehicle-recharge-apply/index',
meta: {
title: 'ETC充值申请',
keepAlive: false,
menuCode: 'pl:etc-vehicle-recharge-apply:list'
}
},
{
id: 'PL_ETC_VEH_RECHARGE_APPROVAL',
name: 'PlEtcVehicleRechargeApproval',
path: 'etc-vehicle-recharge-approval',
component: '/pl/etc-vehicle-recharge-approval/index',
meta: {
title: 'ETC充值审批',
keepAlive: false,
menuCode: 'pl:etc-vehicle-recharge-approval:list'
}
},
{
id: 'PL_ETC_VEH_RECHARGE_RECORD',
name: 'PlEtcVehicleRechargeRecord',
path: 'etc-vehicle-recharge-record',
component: '/pl/etc-vehicle-recharge-record/index',
meta: {
title: 'ETC发放管理',
keepAlive: false,
menuCode: 'pl:etc-vehicle-recharge-record:list'
}
},
{
id: 'PL_ETC_VEH_RECHARGE_WF',
name: 'PlEtcVehicleRechargeWorkflow',
path: 'etc-vehicle-recharge-workflow',
component: '/pl/etc-vehicle-recharge-workflow/index',
meta: {
title: 'ETC充值审批人设置',
keepAlive: false,
menuCode: 'pl:vehicle-recharge-workflow:list'
}
}
]
}
```
- [ ] **Step 3: 更新隐藏 redirect**
所有原指向 `/vehicle-mgmt/etc-recharge/vehicle-recharge-*` 的 redirect,改为:
`/vehicle-mgmt/fuel-recharge/vehicle-recharge-*`
(书签落到油卡侧。)
- [ ] **Step 4: Commit(需用户授权)**
```bash
git add web-admin/src/router/modules/pl.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): split etc and fuel recharge router groups
EOF
)"
```
---
### Task 6: 菜单迁移 SQL + 角色同步
**Files:**
- Create: `docs/sql/migrations/2026-11-12-pl-etc-fuel-recharge-split-menus.sql`
- [ ] **Step 1: 编写可重复执行的迁移**
要点(MySQL 5.7,`SET NAMES utf8mb4;` + 事务):
1. 更新原分组与子菜单名称:
- `PL_GROUP_VEH_RECHARGE`:`menu_name='油卡充值管理'`,`path='fuel-recharge'`,remark 更新
- 原子菜单名改为「油卡充值申请/审批/发放管理/审批人设置」
2. 插入 ETC 分组(`parent_id=PL_GROUP_VEHICLE`,`path='etc-recharge'`,`order_num` 建议小于油卡分组以便并列靠前,或紧挨油卡):
- id: `PL_GROUP_VEH_ETC_RECHARGE`
3. 插入 4 个 C 菜单(component / path / perms 与 Task 5 一致):
- Apply/Approval/Record:新 perms `pl:etc-vehicle-recharge-*:list`
- WF:`perms='pl:vehicle-recharge-workflow:list'`(复用)
4. 为 Apply/Approval/Record 复制对应 F 按钮菜单(对照现有 `PL_VRA_*` / `PL_VRR_*` / 审批按钮),id 前缀 `PL_ETC_…`,perms 换新前缀。
5. 角色同步:对已拥有对应油卡菜单(如 `PL_VEH_RECHARGE_APPLY`)的角色,插入 ETC 对等菜单及祖先(模式参考 `docs/sql/migrations/2026-10-25-pl-vehicle-fuel-card.sql` 的 `sys_role_menu` 段);`NOT EXISTS` 防重复;不恢复管理员已软删授权。
6. WF 菜单:对已有 `PL_VEH_RECHARGE_WF` 授权的角色补 `PL_ETC_VEH_RECHARGE_WF` + 分组祖先。
- [ ] **Step 2: 在本地库执行并抽查**
```bash
# 使用项目既有 mysql 方式执行该 migration;执行后:
# SELECT id,menu_name,path,component,perms FROM sys_menu
# WHERE id LIKE 'PL_%RECHARGE%' OR parent_id IN ('PL_GROUP_VEH_RECHARGE','PL_GROUP_VEH_ETC_RECHARGE');
```
Expected: 两组菜单并存;油卡 path=`fuel-recharge`;ETC 指向 `pl/etc-vehicle-recharge-*`。
- [ ] **Step 3: Commit(需用户授权)**
```bash
git add docs/sql/migrations/2026-11-12-pl-etc-fuel-recharge-split-menus.sql
git commit -m "$(cat <<'EOF'
chore(sql): split ETC and fuel recharge menus with role sync
EOF
)"
```
---
### Task 7: 联调验收与回归
**Files:** 无必须改动;按需修漏网文案 / 参数。
- [ ] **Step 1: 后端过滤回归**
```bash
cd backend && source venv/bin/activate
pytest tests/test_pl_vehicle_ops_list_filters.py -v
```
Expected: PASS
- [ ] **Step 2: 前端 summary 单测**
```bash
cd web-admin && ./node_modules/.bin/tsx --test src/utils/pl-recharge-summary.test.ts
```
Expected: PASS
- [ ] **Step 3: 类型检查(可选但推荐)**
```bash
cd web-admin && pnpm build:typecheck
```
Expected: 与本改动相关的错误为 0
- [ ] **Step 4: 手工验收对照设计「验收」清单**
1. 侧栏并列【ETC充值管理】【油卡充值管理】,ETC 4 子菜单;油卡含原 4 项 +「车油卡信息」。
2. ETC 列表仅 etc;新建固定 ETC;无顶部统计。
3. 油卡列表无 etc;有顶部统计;卡片跳转进油卡申请。
4. 两边审批人设置改同一套节点。
5. 旧 URL `/#/vehicle-mgmt/etc-recharge/vehicle-recharge-apply` redirect 到 fuel 路径。
6. 小程序未改。
- [ ] **Step 5: 若有修复,再提交(需用户授权)**
---
## Spec coverage(自检)
| 设计要求 | 任务 |
| --- | --- |
| 并列两模块 + 子菜单 | Task 5–6 |
| 油卡排除 etc、界面现状 | Task 3 |
| ETC 复制页 + 固定 etc + 无统计 | Task 2、4 |
| 后端不整拆 + `rechargeTypeNe` | Task 1 |
| 审批/审批人保持一套 + 双入口 | Task 3–4 workflow |
| 仅 Web | 全程不改 miniprogram |
| 旧 URL redirect | Task 5 |
| 角色默认同步 | Task 6 |
| 车油卡信息归属油卡组 | Task 5–6 明确保留 |
## Placeholder scan
无 TBD/TODO;油卡/ETC 路径、菜单 id、perms、测试命令已钉死。
@@ -0,0 +1,498 @@
# 充值申请「发放中」状态 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 末级审批通过进入发放节点后,申请状态变为 `pending`,Web 展示为「发放中」;待办/徽章/发放登记与撤销回退一并兼容。
**Architecture:** 油卡/ETC 共用后端状态机。`approve_apply` 在下一节点为 `disburse` 时写入 `pending`;`_current_node` / `list_pending` / 菜单徽章同时接受 `in_approval` 与 `pending`;撤销发放有流程时回退 `pending`。前端仅改充值申请文案与筛选项(尿素等其它业务不动)。
**Tech Stack:** FastAPI / SQLAlchemy、pytest、Vue 3 + Element Plus(web-admin)
**Spec:** `docs/superpowers/specs/2026-11-12-pl-recharge-pending-disburse-status-design.md`
---
## File map
| 文件 | 职责 |
| --- | --- |
| `backend/app/schemas/pl/vehicle_recharge.py` | `APPLY_STATUSES` 纳入 `pending` |
| `backend/app/services/pl_vehicle_recharge_approval_service.py` | 审批推进切 `pending`;待办/当前节点准入 |
| `backend/app/services/pl_vehicle_recharge_service.py` | 撤销/换绑回退;关联提示文案 |
| `backend/app/services/pl_menu_badge_service.py` | 徽章计数含 `pending` |
| `backend/app/api/pl/vehicle_recharge.py` | 导出状态标签「发放中」 |
| `backend/tests/test_pl_vehicle_recharge_workflow.py` | 状态机单测 |
| `backend/tests/test_pl_menu_badge_recharge_approval.py` | 徽章含 pending |
| `web-admin/src/utils/pl-display.ts` | 充值申请 `pending` → 发放中 |
| `web-admin/src/views/pl/vehicle-recharge-apply/columnFilterDefs.ts` | 筛选项 |
| `web-admin/src/views/pl/etc-vehicle-recharge-apply/columnFilterDefs.ts` | 筛选项 |
| `web-admin/src/views/pl/vehicle-recharge-record/index.vue` | 撤销确认文案 |
| `web-admin/src/views/pl/etc-vehicle-recharge-record/index.vue` | 撤销确认文案 |
---
### Task 1: Schema + 审批推进写 pending(TDD)
**Files:**
- Modify: `backend/app/schemas/pl/vehicle_recharge.py`
- Modify: `backend/app/services/pl_vehicle_recharge_approval_service.py`
- Modify: `backend/tests/test_pl_vehicle_recharge_workflow.py`
- [ ] **Step 1: 改现有断言并加失败用例**
在 `test_apply_statuses_include_workflow_states` 中让集合包含 `pending`:
```python
assert {"draft", "in_approval", "pending", "rejected", "disbursed", "cancelled"} <= APPLY_STATUSES
```
将 `test_approve_advances_to_next_node` 中通过后断言改为(默认种子流程下一节点是发放):
```python
assert result.status == "pending"
assert result.current_node == node_fin
```
新增三级流程用例(一级通过仍审批中,二级通过变发放中):
```python
def _seed_three_node_workflow(db) -> tuple[str, str, str, str]:
wf_id = "wf-3"
n1, n2, n3 = "node-a1", "node-a2", "node-dis"
db.add(
BxWorkflowDef(
id=wf_id,
biz_type="vehicle_recharge",
wf_name="三级充值流程",
status=1,
tenant_no="t1",
create_dept_id="d1",
)
)
for nid, name, order, uid, uname, action in (
(n1, "一审", 1, "u-a1", "一审", "approve"),
(n2, "二审", 2, "u-a2", "二审", "approve"),
(n3, "发放", 3, "u-fin", "财务", "disburse"),
):
db.add(
BxWorkflowNode(
id=nid,
wf_id=wf_id,
node_name=name,
node_order=order,
approver_id=uid,
approver_name=uname,
action_type=action,
tenant_no="t1",
create_dept_id="d1",
)
)
return wf_id, n1, n2, n3
def test_mid_approve_stays_in_approval_final_approve_sets_pending(monkeypatch):
from app.services import pl_vehicle_recharge_approval_service as approval_svc
from app.services import pl_vehicle_recharge_service as svc
db = _make_session()
_seed_vehicle(db)
wf_id, n1, n2, n3 = _seed_three_node_workflow(db)
db.add(
PlVehicleRechargeApply(
id="apply-1",
apply_no="YK2026092601",
license_plate="鄂A00001",
recharge_type="fuel",
apply_date=date(2026, 9, 26),
applicant_id="u-applicant",
status="in_approval",
wf_id=wf_id,
current_node=n1,
approval_round=1,
tenant_no="t1",
create_dept_id="d1",
)
)
db.commit()
_patch_scope(monkeypatch, svc)
mid = approval_svc.approve_apply(db, "t1", _user("u-a1", "一审"), "apply-1", opinion="ok")
assert mid.status == "in_approval"
assert mid.current_node == n2
final = approval_svc.approve_apply(db, "t1", _user("u-a2", "二审"), "apply-1", opinion="ok")
assert final.status == "pending"
assert final.current_node == n3
```
新增:`pending` 状态下可发放:
```python
def test_disburse_accepts_pending_status(monkeypatch):
from app.services import pl_vehicle_recharge_approval_service as approval_svc
from app.services import pl_vehicle_recharge_service as svc
from app.schemas.pl.vehicle_recharge import VehicleRechargeRecordCreate
db = _make_session()
_seed_vehicle(db)
wf_id, _gm, node_fin = _seed_workflow(db)
db.add(
PlVehicleRechargeApply(
id="apply-1",
apply_no="ETC2026092601",
license_plate="鄂A00001",
recharge_type="etc",
apply_date=date(2026, 9, 26),
applicant_id="u-applicant",
status="pending",
wf_id=wf_id,
current_node=node_fin,
approval_round=1,
tenant_no="t1",
create_dept_id="d1",
)
)
db.commit()
_patch_scope(monkeypatch, svc)
payload = VehicleRechargeRecordCreate(
rechargeDate=date(2026, 9, 26),
rechargeType="etc",
amount=Decimal("50.00"),
applyId="apply-1",
)
result = approval_svc.disburse_apply(
db, "t1", _user("u-fin", "财务"), "apply-1", payload
)
assert result.status == "disbursed"
```
- [ ] **Step 2: 跑测确认失败**
Run(在 `backend/`,已激活项目 venv):
```bash
pytest tests/test_pl_vehicle_recharge_workflow.py::test_apply_statuses_include_workflow_states \
tests/test_pl_vehicle_recharge_workflow.py::test_approve_advances_to_next_node \
tests/test_pl_vehicle_recharge_workflow.py::test_mid_approve_stays_in_approval_final_approve_sets_pending \
tests/test_pl_vehicle_recharge_workflow.py::test_disburse_accepts_pending_status -v
```
Expected: FAIL(`pending` 不在集合 / 状态仍为 `in_approval` / `申请单不在审批中`)
- [ ] **Step 3: 最小实现**
`vehicle_recharge.py` schema:
```python
APPLY_STATUSES = {"draft", "in_approval", "pending", "rejected", "disbursed", "cancelled"}
```
`pl_vehicle_recharge_approval_service.py` 中 `_current_node`:
```python
if apply.status not in ("in_approval", "pending") or not apply.current_node:
raise HTTPException(status_code=400, detail="申请单不在可审批/发放状态")
```
`list_pending` 过滤:
```python
PlVehicleRechargeApply.status.in_(("in_approval", "pending")),
```
`approve_apply` 在推进节点后:
```python
next_node = nodes[idx + 1]
apply.current_node = next_node.id
next_action = (next_node.action_type or "approve").strip() or "approve"
if next_action == "disburse":
apply.status = "pending"
```
(勿改中间 approve 的 status。)
- [ ] **Step 4: 再跑 Step 2 命令**
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add backend/app/schemas/pl/vehicle_recharge.py \
backend/app/services/pl_vehicle_recharge_approval_service.py \
backend/tests/test_pl_vehicle_recharge_workflow.py
git commit -m "$(cat <<'EOF'
feat(pl): set recharge apply to pending when entering disburse node
EOF
)"
```
---
### Task 2: 徽章计数含 pending
**Files:**
- Modify: `backend/app/services/pl_menu_badge_service.py`
- Modify: `backend/tests/test_pl_menu_badge_recharge_approval.py`
- [ ] **Step 1: 扩展徽章测试**
在现有 `add_apply` 种子后增加:
```python
add_apply("a6", status="pending", current_node="node-me")
```
并将断言改为:
```python
assert counts[MENU_CODE_RECHARGE_APPROVAL] == 3
```
- [ ] **Step 2: 跑测确认失败**
```bash
pytest tests/test_pl_menu_badge_recharge_approval.py -v
```
Expected: FAIL(仍为 2)
- [ ] **Step 3: 实现**
`count_recharge_approval_pending` 中:
```python
PlVehicleRechargeApply.status.in_(("in_approval", "pending")),
```
- [ ] **Step 4: 再跑测**
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add backend/app/services/pl_menu_badge_service.py \
backend/tests/test_pl_menu_badge_recharge_approval.py
git commit -m "$(cat <<'EOF'
fix(pl): include pending recharge applies in approval menu badge
EOF
)"
```
---
### Task 3: 撤销发放回退为 pending + 文案「发放中」
**Files:**
- Modify: `backend/app/services/pl_vehicle_recharge_service.py`(`delete_records` / 换绑旧申请回退两处:约 1136、1205 行,有 `wf_id` 时现为 `in_approval`)
- Modify: `backend/app/api/pl/vehicle_recharge.py`(`RECHARGE_APPLY_STATUS_LABELS`)
- Modify: `backend/app/services/pl_vehicle_recharge_service.py`(`仅待发放申请可关联` → `仅发放中申请可关联`)
- [ ] **Step 1: 写回退单测(可放在 workflow 测试文件)**
```python
def test_delete_record_with_workflow_reverts_to_pending(monkeypatch):
from app.services import pl_vehicle_recharge_service as svc
db = _make_session()
_seed_vehicle(db)
wf_id, _gm, node_fin = _seed_workflow(db)
db.add(
PlVehicleRechargeApply(
id="apply-1",
apply_no="ETC2026092601",
license_plate="鄂A00001",
recharge_type="etc",
apply_date=date(2026, 9, 26),
applicant_id="u-applicant",
status="disbursed",
wf_id=wf_id,
current_node=None,
approval_round=1,
tenant_no="t1",
create_by="u-applicant",
create_dept_id="d1",
)
)
db.add(
PlVehicleRechargeRecord(
id="rec-1",
recharge_order_no="RO1",
recharge_date=date(2026, 9, 26),
recharge_type="etc",
amount=Decimal("10.00"),
apply_id="apply-1",
license_plate="鄂A00001",
tenant_no="t1",
create_by="u-fin",
create_dept_id="d1",
del_flag=0,
)
)
db.commit()
_patch_scope(monkeypatch, svc)
svc.delete_records(db, "t1", _user("u-fin", "财务"), ["rec-1"])
apply = db.query(PlVehicleRechargeApply).filter_by(id="apply-1").one()
assert apply.status == "pending"
assert apply.current_node == node_fin
```
若 `PlVehicleRechargeRecord` 构造缺必填字段,按模型补齐后保持断言不变。
- [ ] **Step 2: 跑测确认失败**
```bash
pytest tests/test_pl_vehicle_recharge_workflow.py::test_delete_record_with_workflow_reverts_to_pending -v
```
Expected: FAIL(仍为 `in_approval`)
- [ ] **Step 3: 实现回退与文案**
两处有 wf 回退改为:
```python
old_apply.status = "pending" # 或 apply.status = "pending"
```
API 标签:
```python
"pending": "发放中",
```
服务提示:
```python
detail="仅发放中申请可关联",
```
- [ ] **Step 4: 跑回退测 + 相关 workflow 测**
```bash
pytest tests/test_pl_vehicle_recharge_workflow.py -v
```
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add backend/app/services/pl_vehicle_recharge_service.py \
backend/app/api/pl/vehicle_recharge.py \
backend/tests/test_pl_vehicle_recharge_workflow.py
git commit -m "$(cat <<'EOF'
fix(pl): revert disbursed recharge apply to pending and rename label
EOF
)"
```
---
### Task 4: Web 文案与筛选项
**Files:**
- Modify: `web-admin/src/utils/pl-display.ts`(仅 `getPlRechargeApplyStatusLabel`,**不要**改 `getPlUreaApplyStatusLabel`)
- Modify: `web-admin/src/views/pl/vehicle-recharge-apply/columnFilterDefs.ts`
- Modify: `web-admin/src/views/pl/etc-vehicle-recharge-apply/columnFilterDefs.ts`
- Modify: `web-admin/src/views/pl/vehicle-recharge-record/index.vue`
- Modify: `web-admin/src/views/pl/etc-vehicle-recharge-record/index.vue`
- [ ] **Step 1: 改充值状态文案**
```typescript
pending: '发放中',
```
- [ ] **Step 2: 筛选项插入 pending(建议放在审批中之后)**
油卡与 ETC 的 `RECHARGE_STATUS_OPTIONS`:
```typescript
{ value: 'draft', label: '草稿' },
{ value: 'in_approval', label: '审批中' },
{ value: 'pending', label: '发放中' },
{ value: 'rejected', label: '已驳回' },
{ value: 'disbursed', label: '已发放' },
{ value: 'cancelled', label: '已作废' }
```
- [ ] **Step 3: 撤销确认文案**
两处 record 页:
```typescript
'确定撤销该发放登记吗?关联申请将回退为发放中。'
```
全文检索 `web-admin` 内充值相关「待发放」字样,仅改充值申请/发放语境;跳过尿素、运费、备用金。
- [ ] **Step 4: 静态核对**
```bash
# 在 web-admin/
rg "待发放" src/utils/pl-display.ts src/views/pl/vehicle-recharge-apply src/views/pl/etc-vehicle-recharge-apply src/views/pl/vehicle-recharge-record src/views/pl/etc-vehicle-recharge-record
```
Expected: 上述路径无残留「待发放」(尿素等其它目录可仍有)
- [ ] **Step 5: Commit**
```bash
git add web-admin/src/utils/pl-display.ts \
web-admin/src/views/pl/vehicle-recharge-apply/columnFilterDefs.ts \
web-admin/src/views/pl/etc-vehicle-recharge-apply/columnFilterDefs.ts \
web-admin/src/views/pl/vehicle-recharge-record/index.vue \
web-admin/src/views/pl/etc-vehicle-recharge-record/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): show recharge pending status as 发放中
EOF
)"
```
---
### Task 5: 回归验证
**Files:** 无新文件
- [ ] **Step 1: 后端相关测试全跑**
```bash
pytest tests/test_pl_vehicle_recharge_workflow.py \
tests/test_pl_menu_badge_recharge_approval.py \
tests/test_pl_vehicle_recharge_summary.py -v
```
Expected: PASS
- [ ] **Step 2: 手工冒烟清单(记结果,未跑勿称通过)**
1. 两级审批 + 发放节点:一审后「审批中」;二审后「发放中」;发放后「已发放」
2. 发放节点人审批待办仍可见并发放成功
3. 申请列表可用「发放中」筛选
4. 油卡、ETC 各至少一单(或同后端断言后单侧冒烟 + 说明)
- [ ] **Step 3: 若用户要求再 push;默认本任务不 push**
---
## Spec coverage check
| Spec 项 | Task |
| --- | --- |
| 末级 approve → pending | Task 1 |
| 中间仍 in_approval | Task 1 |
| 待办/准入认 pending | Task 1 |
| 徽章含 pending | Task 2 |
| 撤销回退 pending | Task 3 |
| 文案「发放中」 | Task 3–4 |
| 筛选项 | Task 4 |
| 不做历史刷库 / 不改尿素文案 | 明确不做 |
| 回归 | Task 5 |
@@ -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,77 @@
# Web 附件图片复制到剪贴板 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在 Web 管理端公共附件展示处提供「复制」按钮,将图片写入系统剪贴板。
**Architecture:** 复用 `fetchAttachmentBlob` 取得图片 Blob,经 Clipboard API(`ClipboardItem` / PNG)写入剪贴板;在 `attachment-upload-field`、`attachment-preview-dialog` 及主要列表附件单元格与「下载」同级增加显式按钮。不改后端、不改小程序。
**Tech Stack:** Vue 3、TypeScript、Element Plus、现有 `web-admin/src/utils/download-attachment.ts` / `clipboard.ts`
**Spec:** `docs/superpowers/specs/2026-11-14-web-attachment-copy-clipboard-design.md`
**Issue:** https://gitea.aichengyan.com/cykj/shanxia2.0/issues/28
---
## File map
| 文件 | 职责 |
| --- | --- |
| `web-admin/src/utils/clipboard.ts` | 新增图片 Blob / 附件图片复制 API;保留现有文本复制 |
| `web-admin/src/utils/clipboard.image.test.ts`(或同目录既有测试风格) | 工具函数单测(mock clipboard / fetch) |
| `web-admin/src/components/business/attachment-upload-field/index.vue` | 卡片操作区 + 预览工具栏「复制」 |
| `web-admin/src/components/business/attachment-preview-dialog/index.vue` | 预览工具栏「复制」 |
| `web-admin/src/components/business/pl-plan-document-attachment-cell/index.vue` 等列表单元格 | 下载旁图片复制(按现有下载模式) |
---
### Task 1: 剪贴板图片工具函数
**Files:**
- Modify: `web-admin/src/utils/clipboard.ts`
- Create/Modify: 同目录或 `web-admin` 既有测试约定下的单测文件
- [x] **Step 1:** 实现 `ensurePngBlob(blob)`(已是 png 则直通,否则 ImageBitmap/canvas → PNG)
- [x] **Step 2:** 实现 `copyImageBlobToClipboard(blob): Promise<boolean>`(`navigator.clipboard.write` + `ClipboardItem`)
- [x] **Step 3:** 实现 `copyAttachmentImageToClipboard(file, options?)`:非图片返回 false;否则 `fetchAttachmentBlob` → `copyImageBlobToClipboard`
- [x] **Step 4:** 补充单元测试(mock `navigator.clipboard.write`、非图片短路、PNG 直通)
- [x] **Step 5:** 运行相关测试通过
### Task 2: 公共附件卡与内嵌预览
**Files:**
- Modify: `web-admin/src/components/business/attachment-upload-field/index.vue`
- [x] **Step 1:** 卡片 footer / compact 下载旁增加复制按钮(图片用图片复制;非图片可用 `copyTextToClipboard` 复制 URL 或隐藏复制)
- [x] **Step 2:** 图片预览工具栏增加「复制」按钮与 loading/禁用态
- [x] **Step 3:** 成功/失败 `ElMessage` 文案与设计一致
### Task 3: 独立预览弹窗
**Files:**
- Modify: `web-admin/src/components/business/attachment-preview-dialog/index.vue`
- [x] **Step 1:** 图片工具栏增加「复制」,调用同一工具函数
- [x] **Step 2:** 仅 `kind === 'image'` 显示
### Task 4: 主要列表附件单元格
**Files:**
- Modify: `pl-plan-document-attachment-cell`、`pl-cert-list-attachment-cell`、`pl-carrier-list-attachment-cell`、`pl-plan-list-cert-cell`(有下载角标处)
- [x] **Step 1:** 图片项在下载旁增加复制,`@click.stop`
- [x] **Step 2:** 非图片不显示图片复制(或仅复制链接,与 Task 2 策略一致)
### Task 5: 验证
- [x] **Step 1:** `pnpm exec tsx --test src/utils/clipboard.image.test.ts` — 5 pass
- [x] **Step 2:** `vue-tsc --noEmit`:本次改动文件无新增相关错误;全仓仍有 workflow 等既有错误与缺失 `.auto-import.json` 导致的 `reactive` 误报
- [ ] **Step 3:** 手工:预览弹窗复制图片后粘贴到微信/Word(待本机验收)
---
## 统一交互策略(实现锁定)
- 图片:复制位图到剪贴板;成功「已复制到剪贴板」
- 非图片:卡片/列表显示「复制链接」(`copyTextToClipboard`),成功「已复制链接」;预览弹窗非图片无复制按钮
- 失败:统一「复制失败,请允许剪贴板权限或改用下载」
@@ -0,0 +1,80 @@
# 证件到期提醒本页维护证件弹窗(Web)
## 背景与问题
Web【证件到期提醒】展示车辆、人员证件过期信息。用户需要补录/更新证件时,若整页跳到【基础信息库】再返回,连续处理多条很麻烦。
## 已确认决策
| 项 | 结论 |
| --- | --- |
| 交互(方案 A) | 在提醒页点「维护证件」,**本页弹出**与基础信息库同款编辑弹窗;关闭后仍停在提醒列表 |
| 车辆弹窗 | 复用 `PlVehicleFormDialog`;按 `vehicleType` 传 `self` / `carrier` |
| 人员弹窗 | 复用 `PlPersonnelFormDialog`;按 `personnelAffiliation` 传 `self` / `carrier` |
| 打开前 | `get` 主体详情以判定归属;缺 ID / 失败则提示,不弹窗 |
| 保存后 | 刷新提醒列表与菜单角标;**不**自动将提醒标为已处理 |
| 整页跳转 | 提醒页「维护证件」**不再** `router.push` 离开本页 |
| `editId` 深链 | 车辆/人员档案页已有的 `editId` 能力可保留(书签/外链),与本页弹窗无关 |
| 后端 | 不改 |
| 范围 | 仅 Web |
## 范围
**包含**
- `document-reminder.vue`:操作列改为打开本页 `PlVehicleFormDialog` / `PlPersonnelFormDialog`
- `pl-document-reminder-jump.ts`:增加表单模式解析(或复用归属判定);路径函数可保留给深链/兼容
**不包含**
- 小程序
- 保存后自动 resolve 提醒
- 抽离档案列表页内联大表单(已有独立 dialog 组件则直接复用)
## 前端设计
### 提醒页
1. 模板挂载:
```vue
<PlVehicleFormDialog
v-model="vehicleFormVisible"
:vehicle-type="vehicleFormType"
:vehicle-id="editingOwnerId"
@saved="handleOwnerFormSaved"
/>
<PlPersonnelFormDialog
v-model="personnelFormVisible"
:affiliation="personnelFormAffiliation"
:personnel-id="editingOwnerId"
@saved="handleOwnerFormSaved"
/>
```
2. `handleMaintainCert`:校验 → get → `resolveDocumentReminderFormMode` → 设置 `editingOwnerId` 与 type/affiliation → `vehicleFormVisible` 或 `personnelFormVisible = true`。
3. `@saved`:`fetchData()` + `refreshMenuBadges()`;成功提示可选。
4. 文案:「缺少主体信息,无法维护」等,不再提「跳转」。
### 模式解析
```ts
type ReminderFormMode =
| { kind: 'vehicle'; vehicleType: 'self' | 'carrier' }
| { kind: 'personnel'; affiliation: 'self' | 'carrier' }
| null
```
- vehicle + vehicleType self → self,否则 carrier
- personnel + affiliation carrier → carrier,否则 self
## 验收
1. 提醒页点「维护证件」不离开当前路由,弹出编辑框(含证件)。
2. 关窗后列表仍在,可立刻点下一条。
3. 保存后列表/角标刷新;提醒状态仍须手动「处理」。
4. 车辆自有/三方、人员山峡/三方弹窗标题与校验与基础信息库一致。
## 状态
用户确认方案 A(本页弹窗)。实现于 `V3.0`。
@@ -0,0 +1,177 @@
# ETC / 油卡充值管理菜单拆分(Web)
## 背景与问题
Web【车辆管理】下现有【ETC/油卡充值管理】(路由前缀 `/vehicle-mgmt/etc-recharge/`)将 ETC 与油卡(及山峡、百链等非 ETC 类型)混在同一套申请 / 审批 / 发放 / 审批人设置页面中。布局层在匹配充值路由时还会展示 `PlRechargeSummary` 顶部统计,该统计面向油卡场景,不适合挂在独立 ETC 模块上。
用户希望:ETC 与油卡分成两个并列模块,前端可独立演进;后端表与接口暂不整模块拆分;审批流程与审批人配置继续共用现有 `biz_type=vehicle_recharge`。
## 已确认决策
| 项 | 结论 |
| --- | --- |
| 产品结构 | 并列【ETC充值管理】与【油卡充值管理】(原名改名) |
| ETC 子菜单 | 申请、审批、发放管理、审批人设置 |
| 油卡侧 | 界面保持现状;列表等不再展示 `rechargeType=etc` |
| ETC 顶部统计 | ETC 分组展示 `PlRechargeSummary`(仅 ETC 信息);油卡分组仅展示加油卡信息(验收调整) |
| 前端实现 | 复制现有 4 个页面目录再改,便于后续各自演进 |
| 后端实现 | 不拆服务/表;列表与写入靠类型过滤/约束;小改即可 |
| 审批流程 / 审批人 | 保持现状,仍一套 `vehicle_recharge`;两边菜单都进同一配置(方案 A) |
| 修改端 | 仅 Web 管理端;小程序不改 |
| 非 ETC 类型归属 | `fuel` / `shanxia` / `bailian` 等非 `etc` 均留在油卡模块 |
## 范围
**包含**
- `web-admin`:菜单与路由、页面复制与类型固化、顶部统计路由判定、文案与筛选
- `backend`:列表/导出/看板/审批查询落实类型过滤(见后端设计);不拆接口与表
- `docs/sql/migrations/`:菜单改名、新建 ETC 分组与子菜单、角色菜单同步;旧 path 由路由 redirect 兼容
**不包含**
- 小程序 / 移动 H5
- 拆分审批流程 `biz_type`、复制工作流定义、在途单迁移
- 拆分数据表或新建独立后端模块
- 本期不要求油卡与 ETC 审批人可分别配置(后续若需要再单独立项)
## 菜单与路由
### 目标结构(均挂在【车辆管理】`PL_GROUP_VEHICLE` 下)
1. **【ETC充值管理】**(新建分组)
- ETC充值申请
- ETC充值审批
- ETC发放管理
- ETC充值审批人设置
2. **【油卡充值管理】**(原 `PL_GROUP_VEH_RECHARGE` 改名)
- 油卡充值申请 / 审批 / 发放管理 / 审批人设置(原子菜单改名)
### 路径约定
| 模块 | 分组 path | 说明 |
| --- | --- | --- |
| ETC | `etc-recharge` | 语义与产品名一致;组件指向**复制后的** ETC 页面 |
| 油卡 | `fuel-recharge` | 原分组 path 由 `etc-recharge` 改为 `fuel-recharge` |
完整路径(菜单 SQL 与此对齐):
- ETC:`/#/vehicle-mgmt/etc-recharge/etc-vehicle-recharge-{apply|approval|record|workflow}`
- 油卡:`/#/vehicle-mgmt/fuel-recharge/vehicle-recharge-{apply|approval|record|workflow}`
### 兼容
- `web-admin/src/router/modules/pl.ts` 中现有指向 `/vehicle-mgmt/etc-recharge/vehicle-recharge-*` 的 redirect,改为指向 **油卡** 新路径 `/vehicle-mgmt/fuel-recharge/vehicle-recharge-*`(保持书签可用且落到油卡侧)。
- 若另有文档/外链写死旧地址,实现计划中列出并改 redirect 或文档。
### 权限
- 油卡:保留现有菜单 id 与 `perms`(如 `pl:vehicle-recharge-apply:list`),仅改展示名与分组 path/component 若需要。
- ETC:新建菜单 id 与独立 `perms`(如 `pl:etc-vehicle-recharge-apply:list` 等),便于日后分开授权。
- 迁移 SQL:对已拥有原充值相关菜单的角色,同步挂上对应 ETC 菜单,保证上线后默认两边都可用;之后可由管理员单独收回。
- 审批人设置:ETC / 油卡各一条菜单,component 分别指向复制页与原页,均调用同一工作流 API / `biz_type=vehicle_recharge`。ETC 菜单的 `perms` **复用**现有 `pl:vehicle-recharge-workflow:*`,避免配置权被拆成两套。
## 前端设计
### 页面复制
在 `web-admin/src/views/pl/` 复制:
| 原路径 | ETC 复制目标(示例名) |
| --- | --- |
| `vehicle-recharge-apply/` | `etc-vehicle-recharge-apply/` |
| `vehicle-recharge-approval/` | `etc-vehicle-recharge-approval/` |
| `vehicle-recharge-record/` | `etc-vehicle-recharge-record/` |
| `vehicle-recharge-workflow/` | `etc-vehicle-recharge-workflow/`(复制后仅改文案/说明;仍调用同一工作流 API) |
油卡侧继续使用原目录,做「排除 etc」与文案改名。审批人设置菜单的 `component` 分别指向上述两套页面,避免一边改提示文案影响另一边;业务数据仍是同一 `biz_type`。
`columnFilterDefs`、本地 `table-key` / 列偏好 localStorage key:ETC 页使用**新 key**,避免与油卡列配置互相污染。
API 封装:优先继续共用 `web-admin/src/api/pl` 现有 `vehicleRecharge*Api`,在页面请求参数中带类型约束;不为本期再复制一套 API 模块,除非出现仅 ETC 需要的入参。
### 类型与交互
**ETC 页**
- 列表 / 导出 / 待办 / 已办 / 发放看板:固定只查 `rechargeType=etc`(或后端等价过滤)。
- 新增/编辑:充值类型固定为 `etc`,不提供油卡/山峡/百链选项;隐藏油卡卡号等仅油卡字段。
- 文案、导出文件名、空态、跳转提示改为「ETC…」。
- **不展示**顶部 `PlRechargeSummary`。
**油卡页**
- 列表 / 导出 / 审批 / 发放:**排除** `rechargeType=etc`;保留 `fuel` / `shanxia` / `bailian` 等现有非 ETC 类型与筛选(筛选中去掉或禁用单独再选 ETC,避免空结果误导)。
- 顶部统计:**保留**(仍走现有 summary 组件与接口)。
- 其余交互、按钮、审批发放流程与现网一致。
### 顶部统计路由
`isRechargeSummaryRoute`(`web-admin/src/utils/pl-recharge-summary.ts`)及 `art-page-content` 中 `showRechargeSummary` / `rechargeApplyPath`:
- 仅对**油卡**充值相关 path 为 true(例如匹配 `fuel-recharge` 下的 apply/approval/record/workflow)。
- ETC 路径一律不展示。
- `rechargeApplyPath` 解析菜单 code 时指向油卡申请菜单,保证统计卡片跳转仍进入油卡申请页。
相关单测(`pl-recharge-summary.test.ts`)同步改断言。
### 审批人设置(方案 A)
- 两菜单入口都打开同一业务配置(`biz_type=vehicle_recharge`)。
- UI 可增加简短说明:「ETC 与油卡共用同一套审批人配置」,降低「改了一边另一边不变」的误解。
- 流程显示名可暂保持「ETC/油卡充值审批流程」,本期不强制改名。
## 后端设计
### 查询
现有列表已支持 `rechargeType` / `rechargeTypeIn`(见 `vehicle_recharge` API 与 `vehicle_recharge_list_filters`)。本期落实:
- ETC 前端固定传 `rechargeType=etc`。
- 油卡「非 etc」过滤:**采用新增可选查询参数 `rechargeTypeNe=etc`**(filter 层 `!=`),避免字典新增类型时维护白名单;保留现有 `rechargeType` / `rechargeTypeIn` 兼容。
- 审批 pending/done、发放看板、导出与列表使用同一套类型约束,避免一侧漏过滤。
### 写入约束
- 本期**不**引入域请求头,**不**按菜单拆 create/update 接口。
- 类型正确性由前端保证:ETC 页只提交 `etc`;油卡页表单选项与默认值不含 `etc`。
- 后端 create/update 维持现有字段校验即可;防串改列为后续增强,不阻塞本期验收。
### 审批与工作流
- `_BIZ_TYPE_VEHICLE_RECHARGE = "vehicle_recharge"` 不变。
- 不新增 workflow 定义,不做在途迁移。
- summary 接口可保持现行为(油卡统计);不强制为 ETC 提供第二套 summary。
## 数据与兼容
- 表结构不改;历史 `etc` 单据仅在 ETC 菜单可见,非 etc 仅在油卡菜单可见。
- 小程序仍使用原混合入口与文案;可能仍能看到/提交两类数据,与 Web 分栏并存,接受为已知差异,后续另项处理。
## UI 规范
- 沿用现页模式(复杂业务列表)与 Fresh Mint;不新造布局与主色。
- 复制页保持与原页相同的 panel / 工具栏 / 表格 / 弹窗结构;仅改类型范围、文案与统计显隐。
- 菜单标题变更后,若申请页有 `panelTitleBarTextRules` 注册需求,按 Web AGENTS 补齐完整文案。
## 验收
1. 侧栏可见并列【ETC充值管理】【油卡充值管理】,各含 4 个子菜单(文案正确)。
2. ETC 申请/审批/发放仅出现 `rechargeType=etc` 数据;新建只能选/固定 ETC。
3. 油卡对应页面不出现 ETC 数据;`fuel` / `shanxia` / `bailian` 仍可见可操作(在权限与数据允许时)。
4. ETC 各子路由无顶部充值统计;油卡各子路由仍有统计,且卡片跳转进油卡申请。
5. 两边「审批人设置」打开后编辑的是同一套节点;保存一侧,另一侧刷新可见。
6. 旧 URL `/vehicle-mgmt/etc-recharge/vehicle-recharge-apply` 等 redirect 到油卡新路径且可用。
7. 原已授权充值菜单的角色,上线后默认可进 ETC 新菜单(迁移 SQL 生效后)。
8. 小程序行为与菜单未改。
## 风险
- 前端双份页面:同类 Bug 可能需改两处;接受为独立演进的代价。
- 审批人双入口同配置:需文案提示,否则易误解为已拆分。
- 小程序仍混合:与 Web 不一致,需在发布说明中写明。
- `rechargeTypeNe` 需在 list/export/approval/board 全路径接通,漏接会导致某一页仍混入 ETC。
## 状态
会话中已确认:复制前端、后端不整拆、审批配置保持现状且双菜单进同一配置(A)、仅 Web。实现计划:`docs/superpowers/plans/2026-11-12-pl-etc-fuel-recharge-split.md`。
@@ -0,0 +1,124 @@
# 充值申请「发放中」状态(审批通过后)
## 背景与问题
车辆充值(油卡 / ETC 共用后端)走审批流程时,单据从提交起一直是 `in_approval`(审批中),直到发放登记才变为 `disbursed`(已发放)。
用户配置多级审批人后,期望:
- 中间审批级等待处理:仍显示「审批中」
- **末级审批人通过、进入发放节点后**:显示「发放中」
- 发放登记完成后:显示「已发放」
系统里已有 `pending` 枚举(原前端文案「待发放」),但有工作流时主路径几乎不会写入该状态;发放撤销在有流程时还会回退为 `in_approval`。申请列表筛选项也未暴露该状态。
**文案约定(已确认)**:充值申请 `pending` 的对外名称统一为 **「发放中」**(不再用「待发放」)。状态码仍为 `pending`,不改库字段。尿素 / 运费等其它业务的「待发放」文案不在本期范围。
## 已确认决策
| 项 | 结论 |
| --- | --- |
| 切换时机 | 末级 `approve` 节点通过后,若下一节点为 `disburse`(发放登记),将 `status` 设为 `pending` |
| 展示文案 | `pending` → **发放中**(Web `getPlRechargeApplyStatusLabel`、导出/后端 `RECHARGE_APPLY_STATUS_LABELS`、筛选项、相关提示语) |
| 中间审批 | 仍保持 `in_approval` |
| 发放完成 | 仍为 `disbursed` |
| 撤销发放 | 有工作流时回退为 `pending`(停在发放节点),不再回退为 `in_approval`;提示改为「回退为发放中」 |
| 无工作流路径 | 维持现有 `pending` 语义不变,仅文案改为「发放中」 |
| 历史数据 | 本期**不做**批量刷库;已停在发放节点但仍为 `in_approval` 的单据继续可在审批待办发放,列表可能仍显示「审批中」直至被处理 |
| 范围 | 油卡 + ETC 共用后端一并生效;Web 列表文案/筛选项同步;小程序不单独改(若共用展示映射则自然带上) |
| 不另拆 | 不新建独立「发放中」菜单;发放仍在现有「充值审批」待办中对发放节点操作 |
## 目标状态机(有工作流)
```text
draft / rejected
└─提交─► in_approval # 当前节点为 approve*
│
├─中间 approve 通过──► in_approval(推进 current_node)
│
└─末级 approve 通过且下一节点为 disburse
─► pending # 发放中;current_node = 发放节点
│
└─发放登记─► disbursed
任意审批节点驳回 ─► rejected
撤回(规则不变)─► draft
撤销发放登记 ─► pending(有 wf)或 pending(无 wf,保持现状)
```
\* 流程末节点必须仍是 `disburse`(现有约束:`approve_apply` 不得收尾)。
## 后端改动要点
### 1. 状态常量与文案
- `APPLY_STATUSES` 正式纳入 `pending`(schema / 单测断言同步)
- `RECHARGE_APPLY_STATUS_LABELS["pending"]`:`待发放` → `发放中`
- 关联申请错误提示等用户可见「待发放」字样同步改为「发放中」
### 2. `approve_apply`
通过当前 `approve` 节点后:
- `apply.current_node = nodes[idx + 1].id`(不变)
- 若下一节点 `action_type == "disburse"`:`apply.status = "pending"`
- 否则:保持 `in_approval`
### 3. 待办与发放登记准入
当前 `_current_node` / `list_pending` / 菜单徽章只认 `status == "in_approval"`。改为认:
`status in ("in_approval", "pending")`
且仍要求 `current_node` 存在、当前用户为该节点审批人。这样「发放中」单据继续出现在审批待办,发放人可完成登记。
错误文案可区分「不在可处理状态」,但行为上两者同等可办。
### 4. 撤销 / 换绑发放记录回退
`delete_records` / 换绑旧申请回退时:有 `wf_id` 时改为 `status = "pending"` + `current_node = 发放节点`(与文案「回退为发放中」一致);无流程仍 `pending`。
### 5. 发放登记页关联申请
现有 `_resolve_apply_for_record`:拒绝 `in_approval`、仅允许 `pending`。与新状态机一致——走审批发放应走审批菜单的 `disburse_apply`;记录页手动关联仍只认 `pending` 单。无需为发放节点旁路放开记录页。
### 6. 看板 / 汇总
`pl_vehicle_recharge_summary_service` 已将 `pending` 与 `in_approval` 计入 pending 类统计,保持即可;回归确认油卡看板数字仍合理。
## 前端改动要点
- `getPlRechargeApplyStatusLabel`:`pending` → `发放中`(仅充值申请映射;尿素等其它模块不动)
- 油卡 / ETC 申请列表列头筛选项:增加 `{ value: 'pending', label: '发放中' }`
- 发放页撤销确认等「回退为待发放」→「回退为发放中」
- 发放页提示「审批中请到审批菜单发放」逻辑可保留(`in_approval`);`pending` 仍可按现有关联逻辑处理
- 审批页「待我审批」Tab 不改名(含审批 + 发放节点待办)
## 测试与验收
**后端单测(优先)**
- 两级 approve + 末节点 disburse:一级通过后仍 `in_approval`;二级通过后 `pending` 且 `current_node` 为发放节点
- `list_pending` / 徽章计数包含 `pending` 且当前节点审批人匹配的单据
- `disburse_apply` 在 `pending` 状态下成功 → `disbursed`
- 撤销发放回退为 `pending`(有流程)
- 导出/标签映射:`pending` 文案为「发放中」
**手工验收**
- 申请列表:二级通过后状态显示「发放中」,可按「发放中」筛选
- 发放节点处理人在审批待办仍能看到并完成发放 →「已发放」
- 油卡与 ETC 模块各走一单冒烟
## 明确不做
- 历史 `in_approval` + 已在发放节点的数据迁移脚本
- 拆分发放独立菜单 / 独立待办 API
- 修改流程配置 UI(仍要求末节点为发放登记)
- 小程序专项改造
- 尿素 / 运费 / 备用金等其它业务「待发放」文案改名
## 风险
- 若漏改徽章或 `list_pending` 状态条件,会出现「列表已是发放中但待办/角标消失」;实现时与 `approve_apply` 同批改、同批测。
- 历史停在发放节点的 `in_approval` 单据列表文案仍为「审批中」,验收时需知悉;若后续需要可另开刷数任务。
@@ -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/新规则。
@@ -0,0 +1,98 @@
# Web 附件图片复制到剪贴板
工单:https://gitea.aichengyan.com/cykj/shanxia2.0/issues/28
## 背景与问题
系统多处图片/文件展示提供「下载」,用户希望再提供「复制」,将图片快速写入操作系统剪贴板,便于粘贴到微信、Word 等。场景分布在列表缩略图、表单附件卡、详情只读与预览弹窗,入口需统一,避免逐页发明交互。
## 已确认决策
| 项 | 结论 | 依据 |
| --- | --- | --- |
| 修改端 | 仅 `web-admin`(桌面 Web;移动 H5 若复用同一附件组件则自动获得) | 用户 2026-11-14 确认「只修改 web 端,不改小程序端」 |
| 主入口 | 与「下载」同级的显式「复制」按钮;预览弹窗工具栏必有 | 对话方案获用户认可 |
| 图片行为 | 写入系统剪贴板为图片(`image/png` 优先) | Clipboard API + 用户诉求 |
| 非图片 | 不写入文件本体;可「复制链接」或仅保留下载 | 浏览器对任意文件剪贴板支持差 |
| 右键 / 快捷键 | 本期不做主入口(可不做) | 发现成本高、易与表格/输入焦点冲突 |
| 小程序 | 本期不改 | 用户明确排除;且无法位图复制 |
| Figma 原型 | 免除,按现有下载旁按钮样式实现 | 用户 2026-11-14 选择「1」;此前推荐亦到期可采用 |
## 方案对比
| 方案 | 做法 | 优点 | 缺点 |
| --- | --- | --- | --- |
| A(推荐) | 公共工具函数 + 公共附件/预览组件加按钮 | 一处覆盖列表/表单/详情多数场景;与下载同发现路径 | 少数自建附件 UI 需二次补齐 |
| B | 各业务页单独加复制 | 可控精细 | 遗漏多、风格易漂 |
| C | 仅右键 / 快捷键 | UI 干净 | B 端发现差,触控与 H5 弱 |
**采用方案 A。**
## 范围
**包含**
- `web-admin`:剪贴板工具(图片 Blob → `navigator.clipboard.write`)
- 复用现有 `fetchAttachmentBlob`(`download-attachment.ts`)取内容
- 公共组件入口:
- `attachment-upload-field`:卡片操作区 / compact 下载旁;图片预览工具栏
- `attachment-preview-dialog`:图片预览工具栏
- 主要列表附件单元格(如 `pl-*-attachment-cell`、证书列表单元格等已有下载角标处)按同一模式补「复制」(仅图片显示图片复制;非图片可选复制链接或隐藏)
- 成功 / 失败 `ElMessage` 提示;安全上下文或权限失败时明确提示
**不包含**
- `miniprogram/` 任何改动
- 后端 API / schema 变更(沿用现有预签名下载)
- 右键菜单、全局快捷键作为主入口
- PDF/Office 等「复制文件本体」进剪贴板
- 强制改造所有业务页内联重复附件 UI(优先公共组件;遗漏页可后续补)
## 前端设计
### 工具函数
在 `web-admin/src/utils/clipboard.ts`(或同目录新建 `copy-attachment.ts` 并由 clipboard 导出)增加:
- `copyImageBlobToClipboard(blob: Blob): Promise<boolean>`
- 优先 `ClipboardItem` 写入;若类型非 `image/png`,经 canvas/`createImageBitmap` 转为 PNG 再写(兼容 JPEG 等)
- 无 Clipboard API / 非安全上下文 / 权限拒绝 → 返回 false
- `copyAttachmentImageToClipboard(file, options?): Promise<boolean>`
- 内部:`detectAttachmentPreviewKind` 非 image 则失败或降级复制 URL 文本(由调用方决定文案)
- `fetchAttachmentBlob` → `copyImageBlobToClipboard`
非图片「复制链接」可复用已有 `copyTextToClipboard`。
### UI 放置
1. **附件卡片**(`attachment-upload-field`):`下载` 旁增加复制图标按钮;仅当判定为图片时执行图片复制,其它类型可复制链接或只显示下载。
2. **预览弹窗工具栏**(upload-field 内嵌预览 + `attachment-preview-dialog`):在重置等按钮后增加「复制」;仅 `kind === 'image'` 时显示。
3. **列表缩略图角标**:已有下载角标的单元格,图片项增加复制角标或与下载并列,点击 `@click.stop`,避免触发行预览。
按钮样式、图标遵循 Fresh Mint / 现有 `action-btn` 与预览工具栏按钮规范(图标 + title 或短文案「复制」)。
### 数据与网络
- 剪贴板需要 Blob,不能只拷 DOM。
- 使用现有预签名下载路径取 Blob;常命中浏览器缓存,用户体感可接受。
- 不新增后端接口。
### 兼容与降级
| 情况 | 行为 |
| --- | --- |
| 现代桌面 Chrome / Edge | 图片写入剪贴板,成功提示「已复制到剪贴板」 |
| 权限 / 非 HTTPS / 旧浏览器 | 失败提示「复制失败,请允许剪贴板权限或改用下载」;可选降级复制图片 URL |
| 移动 H5(同组件) | 尽力而为;失败走同一提示,不单独做小程序方案 |
## 验收条件
1. Web 管理端:在公共附件卡、图片预览弹窗可见「复制」,点击后可将图片粘贴到本机支持图片剪贴板的应用(如预览/Word/微信桌面,视 OS 而定)。
2. 非图片附件:不出现误导性的「复制图片」成功;链接复制或仅下载,行为与提示一致。
3. 小程序目录无本次改动。
4. 无后端变更;相关前端 lint / typecheck 通过(按实施计划执行)。
## 风险与后续
- MinIO / 预签名 CORS 若阻止 `fetch` Blob,复制会失败(下载走 `<a download>` 可能仍可用)→ 失败提示引导改用下载;必要时再评估代理拉取。
- 少数未走公共组件的页面可能暂无按钮 → 后续按反馈补齐。
+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
+5
View File
@@ -1,3 +1,7 @@
# 单包工程也须声明 packages,否则 pnpm 9+ 报 “packages field missing or empty”
packages:
- '.'
allowBuilds:
'@parcel/watcher': true
'@tailwindcss/oxide': true
@@ -5,4 +9,5 @@ allowBuilds:
core-js: false
es5-ext: false
esbuild: true
protobufjs: false
vue-demi: true
@@ -0,0 +1,36 @@
/**
* #26 回归:
* 1) ArtSvgIcon 的 el-icon 不得带 Tailwind `inline`(生产 CSS 会覆盖 display)
* 2) 运输归档列展示含 planNo,且表格列受 isColumnVisible 控制
*/
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parse } from '@vue/compiler-sfc'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
test('ArtSvgIcon: el-icon branch must not use Tailwind inline class', () => {
const src = readFileSync(join(root, 'src/components/core/base/art-svg-icon/index.vue'), 'utf8')
const { descriptor } = parse(src)
const template = descriptor.template?.content || ''
assert.match(template, /<el-icon\b[^>]*class="[^"]*art-svg-icon/)
assert.doesNotMatch(
template,
/<el-icon\b[^>]*class="[^"]*\binline\b/,
'el-icon must not include Tailwind inline (breaks display:inline-flex in prod CSS order)'
)
})
test('plan-archive: planNo is in column defs and table visibility gate', () => {
const src = readFileSync(join(root, 'src/views/pl/plan-archive/index.vue'), 'utf8')
assert.match(src, /prop:\s*'planNo',\s*label:\s*'计划编号'/)
assert.match(src, /pl-plan-archive-list-columns-v17/)
assert.match(
src,
/v-if="isColumnVisible\('planNo'\)"[\s\S]*?prop="planNo"/,
'planNo table column must be gated by isColumnVisible'
)
})
@@ -0,0 +1,46 @@
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/ContractTab.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, /客户\+证用品类/)
assert.match(contractTab, /仅供历史查看/)
})
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
+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
),
'证件余量不足'
)
})
+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'
+54 -1
View File
@@ -44,6 +44,8 @@ export interface CertBody {
routeText?: string | null
consigneeName?: string | null
applicantName?: string | null
customerId?: string | null
categoryId?: string | null
contractBindings?: CertificateContractBinding[]
purchaseRefs?: PurchaseReference[]
}
@@ -61,6 +63,8 @@ export interface QuotaCert extends Omit<CertBody, 'status'> {
usagePolicy: 'once' | 'multiple' | 'needs_confirmation'
createTime?: string
certType?: CertType
derivedCategoryIds?: string[]
customerName?: string | null
}
export interface ScopeProduct {
id?: string
@@ -327,6 +331,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 +398,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 = {
-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,
@@ -45,6 +45,16 @@
<el-icon v-if="!isMpPreviewToolbar"><Refresh /></el-icon>
重置
</el-button>
<span class="preview-toolbar-sep" aria-hidden="true" />
<el-button
:size="mpBtnSize"
:loading="copyBusy"
:disabled="!props.fileUrl || copyBusy"
@click="handleCopyImage"
>
<el-icon v-if="!isMpPreviewToolbar && !copyBusy"><DocumentCopy /></el-icon>
复制
</el-button>
<span class="preview-status-chip">{{ previewStatusText }}</span>
</div>
<div
@@ -98,17 +108,20 @@
import { useRoute } from 'vue-router'
import {
Document,
DocumentCopy,
Refresh,
RefreshLeft,
RefreshRight,
ZoomIn,
ZoomOut
} from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import {
detectAttachmentPreviewKind,
resolveAttachmentPreviewUrl,
type AttachmentPreviewKind
} from '@/utils/download-attachment'
import { copyAttachmentImageToClipboard } from '@/utils/clipboard'
import {
IMAGE_PREVIEW_ZOOM_MIN,
IMAGE_PREVIEW_ZOOM_MAX,
@@ -159,6 +172,7 @@
})
const loading = ref(false)
const copyBusy = ref(false)
const pdfSrc = ref('')
const previewStageRef = ref<HTMLElement>()
const previewImgRef = ref<HTMLImageElement>()
@@ -229,6 +243,21 @@
nextTick(() => refreshPreviewFitBase())
}
async function handleCopyImage() {
if (kind.value !== 'image' || !props.fileUrl || copyBusy.value) return
copyBusy.value = true
try {
const ok = await copyAttachmentImageToClipboard(
{ fileUrl: props.fileUrl, fileName: props.fileName },
{ fullFileUrl: props.resolveUrl }
)
if (ok) ElMessage.success('已复制到剪贴板')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
} finally {
copyBusy.value = false
}
}
function resetPreviewZoomPan() {
previewZoom.value = 1
previewOffsetX.value = 0
@@ -59,6 +59,20 @@
>
<span aria-hidden="true">↓</span>
</button>
<button
v-if="file.fileUrl && !file._uploading"
type="button"
class="att-icon att-icon--copy"
:aria-label="
isImageFile(file)
? `复制图片:${file.fileName || '未命名附件'}`
: `复制链接:${file.fileName || '未命名附件'}`
"
:disabled="copyBusyId === (file.id || String(index))"
@click.stop="handleCopyAttachment(file)"
>
<span aria-hidden="true">⧉</span>
</button>
<button
class="att-preview"
type="button"
@@ -145,6 +159,16 @@
>
<el-icon :size="12"><Delete /></el-icon>
</button>
<button
v-if="compact && disabled && file.fileUrl"
type="button"
class="attachment-compact-copy"
:title="isImageFile(file) ? '复制图片' : '复制链接'"
:disabled="copyBusyId === (file.id || String(index))"
@click.stop="handleCopyAttachment(file)"
>
<el-icon :size="12"><DocumentCopy /></el-icon>
</button>
<button
v-if="compact && disabled && file.fileUrl"
type="button"
@@ -157,6 +181,13 @@
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<div v-if="!disabled" class="attachment-actions">
<el-icon
class="action-btn"
:title="isImageFile(file) ? '复制图片' : '复制链接'"
@click="handleCopyAttachment(file)"
>
<DocumentCopy />
</el-icon>
<el-icon class="action-btn" title="下载" @click="handleDownloadAttachment(file)">
<Download />
</el-icon>
@@ -169,6 +200,13 @@
</el-icon>
</div>
<div v-else class="attachment-actions">
<el-icon
class="action-btn"
:title="isImageFile(file) ? '复制图片' : '复制链接'"
@click="handleCopyAttachment(file)"
>
<DocumentCopy />
</el-icon>
<el-icon class="action-btn" title="下载" @click="handleDownloadAttachment(file)">
<Download />
</el-icon>
@@ -222,6 +260,16 @@
<el-icon v-if="!isMpPreviewToolbar"><Refresh /></el-icon>
重置
</el-button>
<span class="preview-toolbar-sep" aria-hidden="true" />
<el-button
:size="mpBtnSize"
:loading="previewCopyBusy"
:disabled="!previewFile?.fileUrl || previewCopyBusy"
@click="handleCopyPreviewImage"
>
<el-icon v-if="!isMpPreviewToolbar && !previewCopyBusy"><DocumentCopy /></el-icon>
复制
</el-button>
<span class="preview-status-chip">{{ previewStatusText }}</span>
</div>
<div
@@ -278,6 +326,7 @@ import {
Loading,
VideoPlay,
Document,
DocumentCopy,
Download,
Delete,
RefreshLeft,
@@ -293,6 +342,10 @@ import {
downloadAttachmentFile,
resolveAttachmentPreviewUrl
} from '@/utils/download-attachment'
import {
copyAttachmentImageToClipboard,
copyTextToClipboard
} from '@/utils/clipboard'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
import { buildAttachmentUploadFormData, type CrmUploadBizType } from '@/utils/crm-upload'
import {
@@ -798,6 +851,47 @@ function handleDownloadAttachment(file: AttachmentItem) {
downloadAttachmentFile(file, { fullFileUrl: resolveFileUrl })
}
const copyBusyId = ref('')
const previewCopyBusy = ref(false)
async function handleCopyAttachment(file: AttachmentItem) {
const url = String(file.fileUrl || '').trim()
if (!url) {
ElMessage.warning('暂无可复制内容')
return
}
const busyKey = file.id || url
if (copyBusyId.value === busyKey) return
copyBusyId.value = busyKey
try {
if (isImageFile(file)) {
const ok = await copyAttachmentImageToClipboard(file, { fullFileUrl: resolveFileUrl })
if (ok) ElMessage.success('已复制到剪贴板')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
return
}
const href = resolveFileUrl(url)
const ok = await copyTextToClipboard(href || url)
if (ok) ElMessage.success('已复制链接')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
} finally {
if (copyBusyId.value === busyKey) copyBusyId.value = ''
}
}
async function handleCopyPreviewImage() {
const file = previewFile.value
if (!file?.fileUrl || previewCopyBusy.value) return
previewCopyBusy.value = true
try {
const ok = await copyAttachmentImageToClipboard(file, { fullFileUrl: resolveFileUrl })
if (ok) ElMessage.success('已复制到剪贴板')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
} finally {
previewCopyBusy.value = false
}
}
function handlePreviewAttachment(file: AttachmentItem) {
if (!canPreview(file)) return
previewFile.value = file
@@ -1269,10 +1363,10 @@ defineExpose({ hasUploading })
border-radius: 6px;
}
.attachment-compact-download {
.attachment-compact-download,
.attachment-compact-copy {
position: absolute;
top: 2px;
right: 2px;
z-index: 2;
display: flex;
align-items: center;
@@ -1290,6 +1384,19 @@ defineExpose({ hasUploading })
&:hover {
background: #1ca851;
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.attachment-compact-download {
right: 2px;
}
.attachment-compact-copy {
right: 22px;
}
}
</style>
@@ -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>
@@ -31,6 +31,13 @@
<span v-if="item.ext" class="pl-carrier-list-attachment-cell__ext">{{ item.ext }}</span>
</div>
</div>
<el-icon
class="pl-carrier-list-attachment-cell__copy"
:title="item.isImage ? '复制图片' : '复制链接'"
@click.stop="handleCopy(item)"
>
<DocumentCopy />
</el-icon>
<el-icon
class="pl-carrier-list-attachment-cell__download"
title="下载"
@@ -50,7 +57,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Document, Download } from '@element-plus/icons-vue'
import { Document, DocumentCopy, Download } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { Carrier } from '@/api/pl'
import {
@@ -58,6 +65,10 @@
normalizeAttachmentUrls,
openAttachmentPreviewInNewTab
} from '@/utils/download-attachment'
import {
copyAttachmentImageToClipboard,
copyTextToClipboard
} from '@/utils/clipboard'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
const props = withDefaults(
@@ -160,6 +171,26 @@
}
}
async function handleCopy(item: ItemDef) {
if (!item.url) {
ElMessage.warning('暂无可复制内容')
return
}
const file = { fileUrl: item.url, fileName: fileNameFromUrl(item.url) }
if (item.isImage) {
const ok = await copyAttachmentImageToClipboard(file, {
fullFileUrl: resolvePhotoDisplayUrl
})
if (ok) ElMessage.success('已复制到剪贴板')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
return
}
const href = resolvePhotoDisplayUrl(item.url) || item.url
const ok = await copyTextToClipboard(href)
if (ok) ElMessage.success('已复制链接')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
}
const items = computed<ItemDef[]>(() => {
const defs = [
{
@@ -286,9 +317,9 @@
color: #0284c7;
}
.pl-carrier-list-attachment-cell__download {
.pl-carrier-list-attachment-cell__download,
.pl-carrier-list-attachment-cell__copy {
position: absolute;
right: 2px;
top: 2px;
z-index: 2;
width: 18px;
@@ -306,6 +337,14 @@
}
}
.pl-carrier-list-attachment-cell__download {
right: 2px;
}
.pl-carrier-list-attachment-cell__copy {
right: 22px;
}
.pl-carrier-list-attachment-cell__empty-text {
margin-bottom: 14px;
font-size: 11px;
@@ -40,6 +40,13 @@
}}</span>
</div>
</div>
<el-icon
class="pl-cert-list-attachment-cell__copy"
:title="item.isImage ? '复制图片' : '复制链接'"
@click.stop="handleCopy(item)"
>
<DocumentCopy />
</el-icon>
<el-icon
class="pl-cert-list-attachment-cell__download"
title="下载"
@@ -60,7 +67,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Document, Download, Picture } from '@element-plus/icons-vue'
import { Document, DocumentCopy, Download, Picture } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { CertRead } from '@/api/pl'
import {
@@ -68,6 +75,10 @@
normalizeAttachmentUrls,
openAttachmentPreviewInNewTab
} from '@/utils/download-attachment'
import {
copyAttachmentImageToClipboard,
copyTextToClipboard
} from '@/utils/clipboard'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
import { PL_VEHICLE_CERT_PART_TRAILER, normalizeVehicleCertPart } from '@/utils/pl-vehicle-cert'
@@ -181,6 +192,26 @@
}
}
async function handleCopy(item: ItemDef) {
if (!item.url) {
ElMessage.warning('暂无可复制内容')
return
}
const file = { fileUrl: item.url, fileName: fileNameFromUrl(item.url) }
if (item.isImage) {
const ok = await copyAttachmentImageToClipboard(file, {
fullFileUrl: resolvePhotoDisplayUrl
})
if (ok) ElMessage.success('已复制到剪贴板')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
return
}
const href = resolvePhotoDisplayUrl(item.url) || item.url
const ok = await copyTextToClipboard(href)
if (ok) ElMessage.success('已复制链接')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
}
const items = computed<ItemDef[]>(() => {
const certs = props.certList || []
if (!certs.length) return []
@@ -305,9 +336,9 @@
color: #0284c7;
}
.pl-cert-list-attachment-cell__download {
.pl-cert-list-attachment-cell__download,
.pl-cert-list-attachment-cell__copy {
position: absolute;
right: 2px;
top: 2px;
z-index: 2;
width: 18px;
@@ -325,6 +356,14 @@
}
}
.pl-cert-list-attachment-cell__download {
right: 2px;
}
.pl-cert-list-attachment-cell__copy {
right: 22px;
}
.pl-cert-list-attachment-cell__empty-text {
margin-bottom: 14px;
font-size: 11px;
@@ -452,9 +452,15 @@
resolveStopProductsForView(mapLoadFromApi(plan.value?.load).products, productRows.value)
)
const showStopFeedback = computed(
() => plan.value?.transportStatus === 'arranged' && plan.value?.followStatus !== 'cancelled'
)
const showStopFeedback = computed(() => {
const hasStopFeedback = Boolean(
(transport.value?.driverStopFeedbacks || []).length ||
(transport.value?.siteStopFeedbacks || []).length
)
// 作废/取消单仍展示已有分段回传;无数据时仅在已安排且未取消时显示空态入口
if (hasStopFeedback) return true
return plan.value?.transportStatus === 'arranged' && plan.value?.followStatus !== 'cancelled'
})
const summaryCarrierName = computed(
() => transport.value?.carrierName || vehicleBundle.value?.detail?.carrierName || ''
@@ -1596,8 +1602,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 pendingFulfill =
message.includes('待完善') || message.includes('完善并关闭') || message.includes('证书齐备待完善')
if (title !== '用证没有完成' || pendingFulfill) {
const guide = pendingFulfill
? `${html}<p class="pl-arrange-block-text">新规则下证书齐备只放行安排,不能绕过归档挂证。请到「证书齐备 → 已标记待完善证书」上传证件并执行完善并关闭后再归档。</p>`
: html
await ElMessageBox.alert(guide, title, {
type: 'warning',
confirmButtonText: '知道了',
dangerouslyUseHTMLString: true,
@@ -1606,7 +1617,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',
@@ -40,6 +40,13 @@
}}</span>
</div>
</div>
<el-icon
class="pl-plan-document-attachment-cell__copy"
:title="item.isImage ? '复制图片' : '复制链接'"
@click.stop="handleCopy(item)"
>
<DocumentCopy />
</el-icon>
<el-icon
class="pl-plan-document-attachment-cell__download"
title="下载"
@@ -63,7 +70,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Document, Download, Picture } from '@element-plus/icons-vue'
import { Document, DocumentCopy, Download, Picture } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { PlanDocumentAttachmentItem } from '@/api/pl'
import AttachmentPreviewDialog from '@/components/business/attachment-preview-dialog/index.vue'
@@ -71,6 +78,10 @@
downloadAttachmentFile,
openAttachmentPreviewInNewTab
} from '@/utils/download-attachment'
import {
copyAttachmentImageToClipboard,
copyTextToClipboard
} from '@/utils/clipboard'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
const props = withDefaults(
@@ -170,6 +181,26 @@
}
}
async function handleCopy(item: ItemDef) {
if (!item.url) {
ElMessage.warning('暂无可复制内容')
return
}
const file = { fileUrl: item.url, fileName: fileNameFromUrl(item.url) }
if (item.isImage) {
const ok = await copyAttachmentImageToClipboard(file, {
fullFileUrl: resolvePhotoDisplayUrl
})
if (ok) ElMessage.success('已复制到剪贴板')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
return
}
const href = resolvePhotoDisplayUrl(item.url) || item.url
const ok = await copyTextToClipboard(href)
if (ok) ElMessage.success('已复制链接')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
}
const items = computed<ItemDef[]>(() => {
const list = props.attachments || []
return list
@@ -260,9 +291,9 @@
color: #64748b;
}
.pl-plan-document-attachment-cell__download {
.pl-plan-document-attachment-cell__download,
.pl-plan-document-attachment-cell__copy {
position: absolute;
right: 2px;
top: 2px;
z-index: 2;
width: 18px;
@@ -281,6 +312,14 @@
}
}
.pl-plan-document-attachment-cell__download {
right: 2px;
}
.pl-plan-document-attachment-cell__copy {
right: 22px;
}
.pl-plan-document-attachment-cell__caption {
position: absolute;
right: 0;
@@ -142,6 +142,17 @@
<el-icon :size="20"><Document /></el-icon>
</div>
</div>
<el-icon
class="pl-plan-list-cert-cell__copy"
:title="
isCertImageUrl(getPrimaryProductCertUrl(product, slot.key))
? '复制图片'
: '复制链接'
"
@click.stop="handleCopyCert(getPrimaryProductCertUrl(product, slot.key))"
>
<DocumentCopy />
</el-icon>
<el-icon
class="pl-plan-list-cert-cell__download"
title="下载"
@@ -167,7 +178,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Document, Download, Upload } from '@element-plus/icons-vue'
import { Document, DocumentCopy, Download, Upload } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { PlanProduct } from '@/api/pl'
import {
@@ -183,6 +194,10 @@
downloadAttachmentFile,
openAttachmentPreviewInNewTab
} from '@/utils/download-attachment'
import {
copyAttachmentImageToClipboard,
copyTextToClipboard
} from '@/utils/clipboard'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
const props = withDefaults(
@@ -295,6 +310,27 @@
ElMessage.error('下载失败,请稍后重试')
}
}
async function handleCopyCert(url: string) {
const fileUrl = String(url || '').trim()
if (!fileUrl) {
ElMessage.warning('暂无可复制内容')
return
}
const file = { fileUrl, fileName: getCertFileName(fileUrl) || '附件' }
if (isCertImageUrl(fileUrl)) {
const ok = await copyAttachmentImageToClipboard(file, {
fullFileUrl: resolveCertFileUrl
})
if (ok) ElMessage.success('已复制到剪贴板')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
return
}
const href = resolveCertFileUrl(fileUrl) || fileUrl
const ok = await copyTextToClipboard(href)
if (ok) ElMessage.success('已复制链接')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
}
</script>
<style scoped lang="scss">
@@ -420,10 +456,10 @@
pointer-events: none;
}
.pl-plan-list-cert-cell__download {
.pl-plan-list-cert-cell__download,
.pl-plan-list-cert-cell__copy {
position: absolute;
top: 2px;
right: 2px;
z-index: 2;
width: 18px;
height: 18px;
@@ -440,6 +476,14 @@
}
}
.pl-plan-list-cert-cell__download {
right: 2px;
}
.pl-plan-list-cert-cell__copy {
right: 22px;
}
.pl-plan-list-cert-cell__cert-caption {
position: absolute;
right: 0;
@@ -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)
@@ -133,10 +133,13 @@
prefetchedArrange.value = null
bootstrappedPlan.value = null
let data: Plan | null = props.seedPlan?.id === props.planId ? (props.seedPlan as Plan) : null
if (!data) {
// 打开详情始终回源,避免列表 seed 数量/装卸段滞后于库内已修正数据
let data: Plan | null = null
try {
const res = await planApi.get(props.planId)
data = pickBxData<Plan>(res) || ((res as { data?: Plan }).data ?? null)
} catch {
data = props.seedPlan?.id === props.planId ? (props.seedPlan as Plan) : null
}
if (!data) {
throw new Error('运输计划数据无效')
@@ -71,6 +71,23 @@
<p v-else-if="!loading" class="pl-plan-form-empty-hint">暂无车辆信息</p>
</section>
<section class="pl-plan-form-section pl-plan-form-section--plain">
<h3 class="pl-plan-form-section-title">关联购买证 / 运输证</h3>
<p v-if="plan?.quotaCertPendingFulfill" class="pl-plan-form-empty-hint">
证书齐备(待完善):请到「证书齐备 → 已标记待完善证书」挂证并完善并关闭
</p>
<div v-else-if="linkedCertRows.length" class="pl-plan-linked-certs">
<div v-for="item in linkedCertRows" :key="item.key" class="pl-plan-linked-cert-row">
<el-tag size="small" :type="item.kind === 'purchase' ? 'success' : 'warning'">
{{ item.kind === 'purchase' ? '购买证' : '运输证' }}
</el-tag>
<span>{{ item.certNo || item.certId }}</span>
<span class="text-slate-500">预占 {{ item.reservedQty }} / 已用 {{ item.usedQty }}</span>
</div>
</div>
<p v-else class="pl-plan-form-empty-hint">暂无关联证件</p>
</section>
<section class="pl-plan-form-section pl-plan-form-section--plain">
<h3 class="pl-plan-form-section-title">装送货信息</h3>
<PlPlanStopsSplitShell
@@ -779,7 +796,14 @@
transport.value?.feedbackRemark ||
feedbackImageList.value.length ||
transport.value?.feedbackQty != null ||
(transport.value?.driverProductFeedbacks || []).length
(transport.value?.driverProductFeedbacks || []).length ||
(transport.value?.driverStopFeedbacks || []).some(
(row) =>
(row.attachmentUrls && row.attachmentUrls.length > 0) ||
row.remark ||
row.feedbackTime ||
(row.products && row.products.length > 0)
)
)
)
@@ -1406,9 +1430,15 @@
)
}
const showStopFeedback = computed(
() => plan.value?.transportStatus === 'arranged' && plan.value?.followStatus !== 'cancelled'
)
const showStopFeedback = computed(() => {
const hasStopFeedback = Boolean(
(transport.value?.driverStopFeedbacks || []).length ||
(transport.value?.siteStopFeedbacks || []).length
)
// 作废/取消单仍展示已有分段回传;无数据时仅在已安排且未取消时显示空态入口
if (hasStopFeedback) return true
return plan.value?.transportStatus === 'arranged' && plan.value?.followStatus !== 'cancelled'
})
const feedbackBlocksBind = computed(() => ({
feedbackEditable: props.feedbackEditable,
@@ -1435,7 +1465,27 @@
ioOrderViewFields: ioOrderViewFields.value,
ioOrderImageCsv: ioOrderImageCsv.value,
siteFeedbackTableRows: siteFeedbackTableRows.value,
dibangPlanRows: dibangPlanRows.value
dibangPlanRows: dibangPlanRows.value,
driverStopFeedbacks: (transport.value?.driverStopFeedbacks || []).map((row) => ({
stopType: row.stopType,
stopId: row.stopId,
label: row.label,
customerName: row.customerName,
attachmentUrls: row.attachmentUrls,
remark: row.remark,
feedbackTime: row.feedbackTime
})),
siteStopFeedbacks: (transport.value?.siteStopFeedbacks || []).map((row) => ({
stopType: row.stopType,
stopId: row.stopId,
label: row.label,
customerName: row.customerName,
businessType: row.businessType,
quantity: row.quantity != null ? Number(row.quantity) : undefined,
attachmentUrls: row.attachmentUrls,
remark: row.remark,
feedbackTime: row.feedbackTime
}))
}))
const feedbackBlocksOn = {
@@ -1600,11 +1650,41 @@
})
)
const linkedCertRows = ref<
Array<{
key: string
kind: string
certId: string
certNo?: string
reservedQty: string | number
usedQty: string | number
}>
>([])
async function loadLinkedCerts(planId: string) {
linkedCertRows.value = []
try {
const { planQuotaApi } = await import('@/api/pl/quota-cert')
const data = await planQuotaApi.get(planId)
linkedCertRows.value = (data?.usages || []).map((u: any, index: number) => ({
key: `${u.certType || u.cert_type}-${u.certId || u.cert_id}-${index}`,
kind: String(u.certType || u.cert_type || ''),
certId: String(u.certId || u.cert_id || ''),
certNo: u.certNo || u.cert_no,
reservedQty: u.reservedQty ?? u.reserved_qty ?? '—',
usedQty: u.usedQty ?? u.used_qty ?? '—'
}))
} catch {
linkedCertRows.value = []
}
}
async function runLoadDetail(planId: string) {
await loadDetail(planId, {
initialPlan: props.initialPlan?.id === planId ? props.initialPlan : null,
prefetchedArrange: props.prefetchedArrange
})
await loadLinkedCerts(planId)
}
watch(
@@ -9,15 +9,15 @@
<article class="fuel-plate-card recharge-card recharge-notice">
<div class="recharge-notice-head"> <div class="plate">消息通知</div><em>最新</em> </div>
<div class="recharge-notice-list">
<div v-for="notice in summary.notices" :key="notice.id" class="recharge-notice-item">
<div v-for="notice in displayedNotices" :key="notice.id" class="recharge-notice-item">
<time :datetime="notice.occurredAt">{{ notice.occurredAt.slice(0, 10) }}</time>
<span
>{{ notice.actorName }}{{ notice.roleName ? `(${notice.roleName})` : ''
}}{{ notice.action }}{{ notice.licensePlate
}}{{ notice.rechargeType === 'etc' ? 'ETC' : '加油卡' }}充值;</span
}}{{ noticeTypeLabel(notice.rechargeType) }}充值;</span
>
</div>
<div v-if="!summary.notices.length" class="recharge-notice-item muted">暂无消息</div>
<div v-if="!displayedNotices.length" class="recharge-notice-item muted">暂无消息</div>
</div>
</article>
<article
@@ -45,7 +45,7 @@
>{{ statusLabels[vehicle.vehicleStatus] || vehicle.vehicleStatus || '—' }}</el-tag
>
</div>
<div class="recharge-split">
<div class="recharge-split" :class="{ 'is-single': kinds.length === 1 }">
<div v-for="kind in kinds" :key="kind.value" class="recharge-pane">
<div class="recharge-pane-h">
<h4>{{ kind.label }}</h4>
@@ -94,10 +94,17 @@
} from '@/api/pl/recharge-summary'
import { pickBxData } from '@/utils/http/bx'
import { formatPlVehiclePlateDisplay } from '@/utils/pl-display'
import { rechargeComparison, rechargeSummaryQuery } from '@/utils/pl-recharge-summary'
import {
rechargeComparison,
rechargeSummaryQuery,
type RechargeSummaryMode
} from '@/utils/pl-recharge-summary'
import mittBus from '@/utils/sys/mittBus'
const props = defineProps<{ applyPath: string }>()
const props = withDefaults(
defineProps<{ applyPath: string; mode?: RechargeSummaryMode }>(),
{ mode: 'fuel' }
)
const router = useRouter()
const route = useRoute()
const summary = ref<RechargeSummary | null>(null)
@@ -107,12 +114,22 @@
vehicle.vehicleType === 'self' && !['油桶', '叉车'].includes(vehicle.licensePlate.trim())
)
)
const displayedNotices = computed(() => {
const notices = summary.value?.notices || []
if (props.mode === 'etc') return notices.filter((n) => n.rechargeType === 'etc')
return notices.filter((n) => n.rechargeType !== 'etc')
})
const loading = ref(false)
const error = ref(false)
const kinds = [
const kindDefs = [
{ value: 'etc', label: 'ETC' },
{ value: 'fuel', label: '加油卡' }
] as const
const kinds = computed(() =>
kindDefs.filter((kind) =>
props.mode === 'etc' ? kind.value === 'etc' : kind.value === 'fuel'
)
)
const statusLabels: Record<string, string> = {
available: '可用',
maintaining: '维修中',
@@ -122,6 +139,11 @@
Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 0 })
let requestVersion = 0
function noticeTypeLabel(rechargeType: string) {
if (rechargeType === 'etc') return 'ETC'
return '加油卡'
}
function metrics(amounts: RechargeAmounts) {
return [
{
@@ -186,11 +186,13 @@
}
.recharge-grid {
grid-template-columns: repeat(3, 1fr);
/* 单栏(油卡/ETC 分开展示):固定一行 5 张 */
grid-template-columns: repeat(5, 1fr);
}
.recharge-card {
cursor: default;
padding: 12px 14px;
}
.recharge-split {
@@ -199,6 +201,10 @@
gap: 10px;
}
.recharge-split.is-single {
grid-template-columns: 1fr;
}
.recharge-pane {
min-width: 0;
padding: 10px 12px;
@@ -1,6 +1,8 @@
<!-- 图标组件:支持 Element Plus 图标(无 : 前缀)与 Iconify 图标(含 : 前缀) -->
<template>
<el-icon v-if="epIcon" v-bind="iconAttrs" class="art-svg-icon inline">
<!-- el-icon 勿加 Tailwind `inline`:生产 CSS 中 `.inline{display:inline}` 会覆盖
`.el-icon{display:inline-flex}`,导致 Fold 等图标在打包后不可见 -->
<el-icon v-if="epIcon" v-bind="iconAttrs" class="art-svg-icon">
<component :is="epIcon" />
</el-icon>
<Icon v-else-if="icon" :icon="icon" v-bind="iconAttrs" class="art-svg-icon inline" />
@@ -14,7 +14,11 @@
</div>
</div>
<PlRechargeSummary v-if="!isFullPage && showRechargeSummary" :apply-path="rechargeApplyPath" />
<PlRechargeSummary
v-if="!isFullPage && showRechargeSummary && rechargeSummaryMode"
:apply-path="rechargeApplyPath"
:mode="rechargeSummaryMode"
/>
<ArtGroupLeafTabs v-if="!isFullPage && showGroupLeafTabs" />
<!-- 勿强制 minHeight:calc(100vh-…),否则门户布局会叠出多余纵向滚动条 -->
@@ -71,7 +75,11 @@
import { layoutOutletCacheKey, resolveLeafGroup } from '@/utils/router/leaf-group'
import ArtGroupLeafTabs from '@/components/core/layouts/art-group-leaf-tabs/index.vue'
import PlRechargeSummary from '@/components/business/pl-recharge-summary/index.vue'
import { isRechargeSummaryRoute } from '@/utils/pl-recharge-summary'
import {
isRechargeSummaryRoute,
rechargeSummaryModeFromPath,
type RechargeSummaryMode
} from '@/utils/pl-recharge-summary'
defineOptions({ name: 'ArtPageContent' })
@@ -93,12 +101,29 @@
const showGroupLeafTabs = computed(() => (leafGroup.value?.leaves.length || 0) > 1)
const leafGroupId = computed(() => leafGroup.value?.groupId || '')
const rechargeApplyPath = computed(() =>
leafGroup.value?.leaves.find((leaf) => leaf.meta.menuCode === 'pl:vehicle-recharge-apply:list')?.path || ''
const rechargeSummaryMode = computed<RechargeSummaryMode | null>(() => {
const fromRoute = rechargeSummaryModeFromPath(route.path)
if (fromRoute) return fromRoute
for (const leaf of leafGroup.value?.leaves || []) {
const mode = rechargeSummaryModeFromPath(leaf.path)
if (mode) return mode
}
return null
})
const rechargeApplyMenuCode = computed(() =>
rechargeSummaryMode.value === 'etc'
? 'pl:etc-vehicle-recharge-apply:list'
: 'pl:vehicle-recharge-apply:list'
)
const showRechargeSummary = computed(() =>
isRechargeSummaryRoute(route.path) ||
!!leafGroup.value?.leaves.some((leaf) => isRechargeSummaryRoute(leaf.path))
const rechargeApplyPath = computed(
() =>
leafGroup.value?.leaves.find((leaf) => leaf.meta.menuCode === rechargeApplyMenuCode.value)
?.path || ''
)
const showRechargeSummary = computed(
() =>
isRechargeSummaryRoute(route.path) ||
!!leafGroup.value?.leaves.some((leaf) => isRechargeSummaryRoute(leaf.path))
)
const useGroupOutletCache = computed(
@@ -0,0 +1,33 @@
import { watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
/**
* 监听 route.query.editId,调用 openById;结束后清除 editId,避免刷新重复弹窗。
* openById 应自行处理加载失败提示。
*/
export function useRouteEditIdOpen(openById: (id: string) => void | Promise<void>) {
const route = useRoute()
const router = useRouter()
async function consumeEditId() {
const raw = route.query.editId
const id = Array.isArray(raw) ? String(raw[0] || '') : String(raw || '')
if (!id.trim()) return
try {
await openById(id.trim())
} finally {
if (route.query.editId == null) return
const query = { ...route.query }
delete query.editId
await router.replace({ path: route.path, query })
}
}
watch(
() => route.query.editId,
() => {
void consumeEditId()
},
{ immediate: true }
)
}
+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'
+109 -22
View File
@@ -68,6 +68,13 @@ export const plRoutes: AppRouteRecord[] = [
component: '/pl/quota-cert/index',
meta: { title: '证件建档', keepAlive: false, menuCode: 'pl:quota-cert:list' }
},
{
id: 'PL_CERT_CATEGORY',
name: 'PlCertCategory',
path: 'cert-category',
component: '/pl/cert-category/index',
meta: { title: '证用品类', keepAlive: false, menuCode: 'pl:quota-cert:list' }
},
{
id: 'PL_CONTRACT_QUOTA',
name: 'PlContractQuota',
@@ -411,57 +418,57 @@ export const plRoutes: AppRouteRecord[] = [
id: 'PL_REDIRECT_VEH_RECHARGE_APPLY',
name: 'PlVehicleRechargeApplyRedirect',
path: 'vehicle-recharge-apply',
redirect: '/vehicle-mgmt/etc-recharge/vehicle-recharge-apply',
meta: { title: 'ETC/油卡充值申请', isHide: true }
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-apply',
meta: { title: '油卡充值申请', isHide: true }
},
{
id: 'PL_REDIRECT_VEH_RECHARGE_APPLY_NESTED',
name: 'PlVehicleRechargeApplyNestedRedirect',
path: 'vehicle-mgmt/vehicle-recharge-apply',
redirect: '/vehicle-mgmt/etc-recharge/vehicle-recharge-apply',
meta: { title: 'ETC/油卡充值申请', isHide: true }
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-apply',
meta: { title: '油卡充值申请', isHide: true }
},
{
id: 'PL_REDIRECT_VEH_RECHARGE_RECORD',
name: 'PlVehicleRechargeRecordRedirect',
path: 'vehicle-recharge-record',
redirect: '/vehicle-mgmt/etc-recharge/vehicle-recharge-record',
meta: { title: 'ETC/油卡充值发放管理', isHide: true }
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-record',
meta: { title: '油卡充值发放管理', isHide: true }
},
{
id: 'PL_REDIRECT_VEH_RECHARGE_RECORD_NESTED',
name: 'PlVehicleRechargeRecordNestedRedirect',
path: 'vehicle-mgmt/vehicle-recharge-record',
redirect: '/vehicle-mgmt/etc-recharge/vehicle-recharge-record',
meta: { title: 'ETC/油卡充值发放管理', isHide: true }
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-record',
meta: { title: '油卡充值发放管理', isHide: true }
},
{
id: 'PL_REDIRECT_VEH_RECHARGE_APPROVAL',
name: 'PlVehicleRechargeApprovalRedirect',
path: 'vehicle-recharge-approval',
redirect: '/vehicle-mgmt/etc-recharge/vehicle-recharge-approval',
meta: { title: 'ETC/油卡充值审批', isHide: true }
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-approval',
meta: { title: '油卡充值审批', isHide: true }
},
{
id: 'PL_REDIRECT_VEH_RECHARGE_APPROVAL_NESTED',
name: 'PlVehicleRechargeApprovalNestedRedirect',
path: 'vehicle-mgmt/vehicle-recharge-approval',
redirect: '/vehicle-mgmt/etc-recharge/vehicle-recharge-approval',
meta: { title: 'ETC/油卡充值审批', isHide: true }
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-approval',
meta: { title: '油卡充值审批', isHide: true }
},
{
id: 'PL_REDIRECT_VEH_RECHARGE_WORKFLOW',
name: 'PlVehicleRechargeWorkflowRedirect',
path: 'vehicle-recharge-workflow',
redirect: '/vehicle-mgmt/etc-recharge/vehicle-recharge-workflow',
meta: { title: 'ETC/油卡充值审批人设置', isHide: true }
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-workflow',
meta: { title: '油卡充值审批人设置', isHide: true }
},
{
id: 'PL_REDIRECT_VEH_RECHARGE_WORKFLOW_NESTED',
name: 'PlVehicleRechargeWorkflowNestedRedirect',
path: 'vehicle-mgmt/vehicle-recharge-workflow',
redirect: '/vehicle-mgmt/etc-recharge/vehicle-recharge-workflow',
meta: { title: 'ETC/油卡充值审批人设置', isHide: true }
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-workflow',
meta: { title: '油卡充值审批人设置', isHide: true }
},
{
id: 'PL_REDIRECT_VEH_MAINT_APPLY',
@@ -566,15 +573,15 @@ export const plRoutes: AppRouteRecord[] = [
{
id: 'PL_GROUP_VEH_RECHARGE',
name: 'PlVehicleRechargeGroup',
path: 'etc-recharge',
meta: { title: 'ETC/油卡充值管理', keepAlive: false },
path: 'fuel-recharge',
meta: { title: '油卡充值管理', keepAlive: false },
children: [
{
id: 'PL_VEH_RECHARGE_APPLY',
name: 'PlVehicleRechargeApply',
path: 'vehicle-recharge-apply',
component: '/pl/vehicle-recharge-apply/index',
meta: { title: 'ETC/油卡充值申请', keepAlive: false, menuCode: 'pl:vehicle-recharge-apply:list' }
meta: { title: '油卡充值申请', keepAlive: false, menuCode: 'pl:vehicle-recharge-apply:list' }
},
{
id: 'PL_VEH_RECHARGE_APPROVAL',
@@ -582,7 +589,7 @@ export const plRoutes: AppRouteRecord[] = [
path: 'vehicle-recharge-approval',
component: '/pl/vehicle-recharge-approval/index',
meta: {
title: 'ETC/油卡充值审批',
title: '油卡充值审批',
keepAlive: false,
menuCode: 'pl:vehicle-recharge-approval:list'
}
@@ -592,7 +599,7 @@ export const plRoutes: AppRouteRecord[] = [
name: 'PlVehicleRechargeRecord',
path: 'vehicle-recharge-record',
component: '/pl/vehicle-recharge-record/index',
meta: { title: 'ETC/油卡充值发放管理', keepAlive: false, menuCode: 'pl:vehicle-recharge-record:list' }
meta: { title: '油卡充值发放管理', keepAlive: false, menuCode: 'pl:vehicle-recharge-record:list' }
},
{
id: 'PL_VEH_RECHARGE_WF',
@@ -600,7 +607,7 @@ export const plRoutes: AppRouteRecord[] = [
path: 'vehicle-recharge-workflow',
component: '/pl/vehicle-recharge-workflow/index',
meta: {
title: 'ETC/油卡充值审批人设置',
title: '油卡充值审批人设置',
keepAlive: false,
menuCode: 'pl:vehicle-recharge-workflow:list'
}
@@ -614,6 +621,86 @@ export const plRoutes: AppRouteRecord[] = [
}
]
},
{
id: 'PL_GROUP_VEH_ETC_RECHARGE',
name: 'PlVehicleEtcRechargeGroup',
path: 'etc-recharge',
meta: { title: 'ETC充值管理', keepAlive: false },
children: [
{
id: 'PL_ETC_VEH_RECHARGE_APPLY',
name: 'PlEtcVehicleRechargeApply',
path: 'etc-vehicle-recharge-apply',
component: '/pl/etc-vehicle-recharge-apply/index',
meta: {
title: 'ETC充值申请',
keepAlive: false,
menuCode: 'pl:etc-vehicle-recharge-apply:list'
}
},
{
id: 'PL_ETC_VEH_RECHARGE_APPROVAL',
name: 'PlEtcVehicleRechargeApproval',
path: 'etc-vehicle-recharge-approval',
component: '/pl/etc-vehicle-recharge-approval/index',
meta: {
title: 'ETC充值审批',
keepAlive: false,
menuCode: 'pl:etc-vehicle-recharge-approval:list'
}
},
{
id: 'PL_ETC_VEH_RECHARGE_RECORD',
name: 'PlEtcVehicleRechargeRecord',
path: 'etc-vehicle-recharge-record',
component: '/pl/etc-vehicle-recharge-record/index',
meta: {
title: 'ETC发放管理',
keepAlive: false,
menuCode: 'pl:etc-vehicle-recharge-record:list'
}
},
{
id: 'PL_ETC_VEH_RECHARGE_WF',
name: 'PlEtcVehicleRechargeWorkflow',
path: 'etc-vehicle-recharge-workflow',
component: '/pl/etc-vehicle-recharge-workflow/index',
meta: {
title: 'ETC充值审批人设置',
keepAlive: false,
menuCode: 'pl:vehicle-recharge-workflow:list'
}
},
{
id: 'PL_REDIRECT_ETC_GROUP_OLD_APPLY',
name: 'PlEtcGroupOldApplyRedirect',
path: 'vehicle-recharge-apply',
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-apply',
meta: { title: '油卡充值申请', isHide: true }
},
{
id: 'PL_REDIRECT_ETC_GROUP_OLD_APPROVAL',
name: 'PlEtcGroupOldApprovalRedirect',
path: 'vehicle-recharge-approval',
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-approval',
meta: { title: '油卡充值审批', isHide: true }
},
{
id: 'PL_REDIRECT_ETC_GROUP_OLD_RECORD',
name: 'PlEtcGroupOldRecordRedirect',
path: 'vehicle-recharge-record',
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-record',
meta: { title: '油卡充值发放管理', isHide: true }
},
{
id: 'PL_REDIRECT_ETC_GROUP_OLD_WORKFLOW',
name: 'PlEtcGroupOldWorkflowRedirect',
path: 'vehicle-recharge-workflow',
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-workflow',
meta: { title: '油卡充值审批人设置', isHide: true }
}
]
},
{
id: 'PL_GROUP_VEH_FUEL',
name: 'PlVehicleFuelGroup',
+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',
@@ -407,6 +407,14 @@
justify-content: flex-end;
}
.mp-crm-att-grid .att-icon--copy {
right: 0;
bottom: 0;
top: auto;
align-items: flex-end;
justify-content: flex-end;
}
.mp-crm-att-grid .att-icon > span {
display: flex;
align-items: center;
@@ -426,6 +434,10 @@
background: #087f57;
}
.mp-crm-att-grid .att-icon--copy > span {
background: #0f766e;
}
.mp-crm-att-grid .att-uploading {
position: absolute;
inset: 0;
+116
View File
@@ -0,0 +1,116 @@
import assert from 'node:assert/strict'
import { afterEach, before, describe, it } from 'node:test'
import {
copyAttachmentImageToClipboard,
copyImageBlobToClipboard,
ensurePngBlob
} from './clipboard'
class FakeClipboardItem {
readonly types: string[]
private readonly data: Record<string, Blob>
constructor(items: Record<string, Blob>) {
this.data = items
this.types = Object.keys(items)
}
async getType(type: string) {
return this.data[type]
}
}
before(() => {
if (typeof globalThis.ClipboardItem === 'undefined') {
;(globalThis as unknown as { ClipboardItem: typeof FakeClipboardItem }).ClipboardItem =
FakeClipboardItem
}
if (typeof globalThis.navigator === 'undefined') {
;(globalThis as unknown as { navigator: Navigator }).navigator = {} as Navigator
}
})
const originalClipboard = globalThis.navigator?.clipboard
afterEach(() => {
if (typeof globalThis.navigator === 'undefined') return
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: originalClipboard
})
})
describe('ensurePngBlob', () => {
it('returns the same blob when type is already image/png', async () => {
const png = new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' })
const out = await ensurePngBlob(png)
assert.equal(out, png)
assert.equal(out.type, 'image/png')
})
})
describe('copyImageBlobToClipboard', () => {
it('writes a ClipboardItem with image/png and returns true', async () => {
const written: ClipboardItem[] = []
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: {
write: async (items: ClipboardItem[]) => {
written.push(...items)
}
}
})
const png = new Blob([new Uint8Array([9])], { type: 'image/png' })
const ok = await copyImageBlobToClipboard(png)
assert.equal(ok, true)
assert.equal(written.length, 1)
const types = written[0].types
assert.ok(types.includes('image/png'))
})
it('returns false when clipboard.write is unavailable', async () => {
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: {}
})
const png = new Blob([new Uint8Array([1])], { type: 'image/png' })
assert.equal(await copyImageBlobToClipboard(png), false)
})
})
describe('copyAttachmentImageToClipboard', () => {
it('returns false for non-image attachments without fetching', async () => {
let fetched = false
const ok = await copyAttachmentImageToClipboard(
{ fileUrl: 'https://example.com/a.pdf', fileName: 'a.pdf' },
{
fetchBlob: async () => {
fetched = true
return new Blob(['x'], { type: 'application/pdf' })
}
}
)
assert.equal(ok, false)
assert.equal(fetched, false)
})
it('fetches image blob and copies via clipboard', async () => {
const written: ClipboardItem[] = []
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: {
write: async (items: ClipboardItem[]) => {
written.push(...items)
}
}
})
const ok = await copyAttachmentImageToClipboard(
{ fileUrl: 'https://example.com/a.jpg', fileName: 'a.jpg' },
{
fetchBlob: async () => new Blob([new Uint8Array([7])], { type: 'image/png' })
}
)
assert.equal(ok, true)
assert.equal(written.length, 1)
})
})
+85
View File
@@ -2,9 +2,15 @@
* 复制文本到剪贴板。
* 小程序 web-view / H5 iframe 常被 Permissions Policy 拦截 Clipboard API,
* 需回退到 execCommand,必要时再交给壳页复制。
* 另提供图片 Blob / 附件图片写入系统剪贴板(ClipboardItem)。
*/
import { MP_WEBVIEW_COPY_TEXT } from '@/utils/mp-webview'
export type AttachmentClipboardItem = {
fileUrl?: string
fileName?: string
}
function fallbackExecCommandCopy(text: string): boolean {
try {
const textarea = document.createElement('textarea')
@@ -64,3 +70,82 @@ export async function copyTextToClipboard(text: string): Promise<boolean> {
return tryCopyViaMpWebviewHost(value)
}
/** 与 download-attachment 图片判定对齐,避免测试静态拉入整条 API 依赖链 */
function isImageAttachment(file: AttachmentClipboardItem): boolean {
const name = String(file.fileName || '').toLowerCase()
const url = String(file.fileUrl || '')
.split('?')[0]
.toLowerCase()
return (
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(name) ||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(url)
)
}
/** 已是 PNG 则原样返回;否则经 canvas 转为 image/png。 */
export async function ensurePngBlob(blob: Blob): Promise<Blob> {
if (blob.type === 'image/png') return blob
if (typeof createImageBitmap === 'function' && typeof document !== 'undefined') {
const bitmap = await createImageBitmap(blob)
try {
const canvas = document.createElement('canvas')
canvas.width = bitmap.width
canvas.height = bitmap.height
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('canvas 2d unavailable')
ctx.drawImage(bitmap, 0, 0)
const png = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png')
})
return png
} finally {
bitmap.close?.()
}
}
// 无 canvas 环境时仍尝试按原类型写入(部分浏览器接受 image/jpeg)
return blob
}
/** 将图片 Blob 写入系统剪贴板;失败返回 false。 */
export async function copyImageBlobToClipboard(blob: Blob): Promise<boolean> {
if (!blob || blob.size <= 0) return false
try {
if (typeof navigator === 'undefined' || !navigator.clipboard?.write) return false
if (typeof ClipboardItem === 'undefined') return false
const png = await ensurePngBlob(blob)
const type = png.type || 'image/png'
await navigator.clipboard.write([new ClipboardItem({ [type]: png })])
return true
} catch {
return false
}
}
export type CopyAttachmentImageOptions = {
fullFileUrl?: (url: string) => string
/** 测试可注入;默认走预签名下载 Blob */
fetchBlob?: (
file: AttachmentClipboardItem,
options?: { fullFileUrl?: (url: string) => string }
) => Promise<Blob>
}
/** 仅图片附件:拉取 Blob 后写入剪贴板;非图片返回 false。 */
export async function copyAttachmentImageToClipboard(
file: AttachmentClipboardItem,
options?: CopyAttachmentImageOptions
): Promise<boolean> {
if (!isImageAttachment(file)) return false
try {
const fetchBlob =
options?.fetchBlob ||
(await import('@/utils/download-attachment')).fetchAttachmentBlob
const blob = await fetchBlob(file, { fullFileUrl: options?.fullFileUrl })
return await copyImageBlobToClipboard(blob)
} catch {
return false
}
}
@@ -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)
}
+1 -1
View File
@@ -661,7 +661,7 @@ export function buildFuelRegisterCopyText(row: {
export function getPlRechargeApplyStatusLabel(status: string) {
const map: Record<string, string> = {
draft: '草稿',
pending: '待发放',
pending: '发放中',
in_approval: '审批中',
rejected: '已驳回',
disbursed: '已发放',
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
resolveDocumentReminderEditPath,
resolveDocumentReminderFormMode,
DOCUMENT_REMINDER_JUMP_UNSUPPORTED
} from './pl-document-reminder-jump'
describe('证件提醒表单模式与路径', () => {
it('车辆按 vehicleType 解析弹窗类型与路径', () => {
assert.deepEqual(
resolveDocumentReminderFormMode({ ownerType: 'vehicle', vehicleType: 'self' }),
{ kind: 'vehicle', vehicleType: 'self' }
)
assert.deepEqual(
resolveDocumentReminderFormMode({ ownerType: 'vehicle', vehicleType: 'carrier' }),
{ kind: 'vehicle', vehicleType: 'carrier' }
)
assert.deepEqual(resolveDocumentReminderFormMode({ ownerType: 'vehicle', vehicleType: '' }), {
kind: 'vehicle',
vehicleType: 'carrier'
})
assert.equal(
resolveDocumentReminderEditPath({ ownerType: 'vehicle', vehicleType: 'self' }),
'/vehicle-mgmt/base-info/self-vehicle'
)
assert.equal(
resolveDocumentReminderEditPath({ ownerType: 'vehicle', vehicleType: 'carrier' }),
'/vehicle-mgmt/base-info/vehicle'
)
})
it('人员按 personnelAffiliation 解析弹窗归属与路径', () => {
assert.deepEqual(
resolveDocumentReminderFormMode({
ownerType: 'personnel',
personnelAffiliation: 'self'
}),
{ kind: 'personnel', affiliation: 'self' }
)
assert.deepEqual(
resolveDocumentReminderFormMode({
ownerType: 'personnel',
personnelAffiliation: 'carrier'
}),
{ kind: 'personnel', affiliation: 'carrier' }
)
assert.deepEqual(
resolveDocumentReminderFormMode({
ownerType: 'personnel',
personnelAffiliation: ''
}),
{ kind: 'personnel', affiliation: 'self' }
)
assert.equal(
resolveDocumentReminderEditPath({
ownerType: 'personnel',
personnelAffiliation: 'carrier'
}),
'/vehicle-mgmt/base-info/personnel-carrier'
)
})
it('不支持的主体返回 null', () => {
assert.equal(resolveDocumentReminderFormMode({ ownerType: 'carrier' }), null)
assert.equal(resolveDocumentReminderEditPath({ ownerType: '' }), null)
assert.equal(DOCUMENT_REMINDER_JUMP_UNSUPPORTED, '暂不支持该主体类型的证件维护跳转')
})
})
@@ -0,0 +1,46 @@
export const DOCUMENT_REMINDER_JUMP_UNSUPPORTED = '暂不支持该主体类型的证件维护跳转'
export type ReminderJumpInput = {
ownerType?: string | null
vehicleType?: string | null
personnelAffiliation?: string | null
}
export type ReminderFormMode =
| { kind: 'vehicle'; vehicleType: 'self' | 'carrier' }
| { kind: 'personnel'; affiliation: 'self' | 'carrier' }
/** 提醒页本页弹窗:解析车辆/人员表单锁定类型。 */
export function resolveDocumentReminderFormMode(
input: ReminderJumpInput
): ReminderFormMode | null {
const ownerType = String(input.ownerType || '').trim()
if (ownerType === 'vehicle') {
return {
kind: 'vehicle',
vehicleType: String(input.vehicleType || '').trim() === 'self' ? 'self' : 'carrier'
}
}
if (ownerType === 'personnel') {
return {
kind: 'personnel',
affiliation:
String(input.personnelAffiliation || '').trim() === 'carrier' ? 'carrier' : 'self'
}
}
return null
}
/** 可选深链:档案页 path(提醒主流程已改为本页弹窗)。 */
export function resolveDocumentReminderEditPath(input: ReminderJumpInput): string | null {
const mode = resolveDocumentReminderFormMode(input)
if (!mode) return null
if (mode.kind === 'vehicle') {
return mode.vehicleType === 'self'
? '/vehicle-mgmt/base-info/self-vehicle'
: '/vehicle-mgmt/base-info/vehicle'
}
return mode.affiliation === 'carrier'
? '/vehicle-mgmt/base-info/personnel-carrier'
: '/vehicle-mgmt/base-info/personnel'
}
+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
}
@@ -3,7 +3,10 @@ import { describe, it } from 'node:test'
import {
rechargeComparison,
rechargeSummaryQuery,
isRechargeSummaryRoute
isRechargeSummaryRoute,
isFuelRechargeSummaryRoute,
isEtcRechargeSummaryRoute,
rechargeSummaryModeFromPath
} from './pl-recharge-summary'
describe('充值统计卡片', () => {
@@ -30,10 +33,60 @@ describe('充值统计卡片', () => {
})
})
it('只对充值业务子页显示,其他车辆页面不显示', () => {
assert.equal(isRechargeSummaryRoute('/vehicle-mgmt/etc-recharge/vehicle-recharge-apply'), true)
assert.equal(isRechargeSummaryRoute('/vehicle-mgmt/etc-recharge/vehicle-recharge-record'), true)
it('油卡分组子页识别为 fuel 模式', () => {
assert.equal(
isFuelRechargeSummaryRoute('/vehicle-mgmt/fuel-recharge/vehicle-recharge-apply'),
true
)
assert.equal(
isFuelRechargeSummaryRoute('/vehicle-mgmt/fuel-recharge/vehicle-recharge-record'),
true
)
assert.equal(
isFuelRechargeSummaryRoute('/vehicle-mgmt/fuel-recharge/vehicle-recharge-approval'),
true
)
assert.equal(
isFuelRechargeSummaryRoute('/vehicle-mgmt/fuel-recharge/vehicle-recharge-workflow'),
true
)
assert.equal(rechargeSummaryModeFromPath('/vehicle-mgmt/fuel-recharge/vehicle-recharge-apply'), 'fuel')
})
it('ETC 分组子页识别为 etc 模式并展示顶部统计', () => {
assert.equal(
isEtcRechargeSummaryRoute('/vehicle-mgmt/etc-recharge/etc-vehicle-recharge-apply'),
true
)
assert.equal(
isEtcRechargeSummaryRoute('/vehicle-mgmt/etc-recharge/etc-vehicle-recharge-approval'),
true
)
assert.equal(
isEtcRechargeSummaryRoute('/vehicle-mgmt/etc-recharge/etc-vehicle-recharge-record'),
true
)
assert.equal(
isEtcRechargeSummaryRoute('/vehicle-mgmt/etc-recharge/etc-vehicle-recharge-workflow'),
true
)
assert.equal(
isRechargeSummaryRoute('/vehicle-mgmt/etc-recharge/etc-vehicle-recharge-apply'),
true
)
assert.equal(
rechargeSummaryModeFromPath('/vehicle-mgmt/etc-recharge/etc-vehicle-recharge-apply'),
'etc'
)
})
it('旧路径与其它车辆页不显示充值统计', () => {
assert.equal(
isRechargeSummaryRoute('/vehicle-mgmt/etc-recharge/vehicle-recharge-apply'),
false
)
assert.equal(isRechargeSummaryRoute('/vehicle-mgmt/vehicle-fuel-card'), false)
assert.equal(isRechargeSummaryRoute('/vehicle-mgmt/vehicle'), false)
assert.equal(rechargeSummaryModeFromPath('/vehicle-mgmt/vehicle'), null)
})
})
+21 -1
View File
@@ -13,10 +13,30 @@ export function rechargeComparison(
}
}
export type RechargeSummaryMode = 'fuel' | 'etc'
export function rechargeSummaryQuery(plate: string, kind: 'etc' | 'fuel') {
return { rechargePlate: plate, rechargeKind: kind }
}
/** 油卡充值分组下的申请/审批/发放/审批人设置 */
export function isFuelRechargeSummaryRoute(path: string) {
return /\/fuel-recharge\/vehicle-recharge-(apply|approval|record|workflow)\/?$/.test(path)
}
/** ETC 充值分组下的申请/审批/发放/审批人设置 */
export function isEtcRechargeSummaryRoute(path: string) {
return /\/etc-recharge\/etc-vehicle-recharge-(apply|approval|record|workflow)\/?$/.test(path)
}
/** 是否展示顶部充值统计(油卡或 ETC 业务子页) */
export function isRechargeSummaryRoute(path: string) {
return /\/vehicle-recharge-(apply|approval|record|workflow)\/?$/.test(path)
return isFuelRechargeSummaryRoute(path) || isEtcRechargeSummaryRoute(path)
}
/** 当前路由对应的统计模式;非充值业务页返回 null */
export function rechargeSummaryModeFromPath(path: string): RechargeSummaryMode | null {
if (isEtcRechargeSummaryRoute(path)) return 'etc'
if (isFuelRechargeSummaryRoute(path)) return 'fuel'
return null
}
@@ -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
}
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff. Show more