Compare commits

..
Author SHA1 Message Date
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
wuzilongandCursor 0baa6880f7 feat(doclib): 文档库支持多选文档后批量设置分享。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 17:43:16 +08:00
wuzilongandCursor dd4fdf1e6e feat(doclib): 增加批量设置分享接口并复用单文档写入逻辑。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 17:42:34 +08:00
wuzilongandCursor 92a8b3f6da feat(doclib): 增加批量设置分享 API 客户端。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 17:42:06 +08:00
wuzilongandCursor 522ef36e7a feat(doclib): 允许多选可管理文档分享并计算分享人交集。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 17:41:48 +08:00
wuzilongandCursor bb75f98582 docs: 增加文档库多选批量分享实现计划(#25)。
按 TDD 拆分前后端任务,覆盖交集预选、batch-shares 事务与页面接入。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 17:39:49 +08:00
wuzilongandCursor bf89a1f301 docs: 记录文档库多选批量分享设计(#25)。
明确整份覆盖、交集预选、batch-shares 事务与 Web 弹窗扩展范围,供实现对照。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 17:36:51 +08:00
wuzilong 391e5f66f5 merge: 合入合同改号同步运输计划快照修复(#23)。 2026-11-12 16:34:58 +08:00
wuzilongandCursor 522b4be705 feat(pl): 装送货段增加同市标记,并同步额度证件与计划表单。
同市易制毒段只校验购买证;同步 Web/H5 表单、额度统计展示与相关回归测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 16:34:58 +08:00
102 changed files with 8259 additions and 248 deletions

No files matched your search

+1
View File
@@ -115,4 +115,5 @@ api_key
# personal-dev:begin
/.local/agent/
/.local/secrets/
# personal-dev:end
+12 -1
View File
@@ -4,7 +4,13 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.doclib_upload_route import DoclibRoute
from app.schemas.doclib import DeleteFilesBody, FolderCreateBody, FolderUpdateBody, ShareBody
from app.schemas.doclib import (
BatchShareBody,
DeleteFilesBody,
FolderCreateBody,
FolderUpdateBody,
ShareBody,
)
from app.services.doclib_service import DoclibService
from app.services.doclib_storage import download_document, upload_document
@@ -87,6 +93,11 @@ def delete_document_files(body: DeleteFilesBody, svc: DoclibService = Depends(se
return success_response()
@router.post('/web/doclib/files/batch-shares')
def batch_share_documents(body: BatchShareBody, svc: DoclibService = Depends(service)):
return success_response(svc.set_shares_batch(body.ids, body.userIds))
@router.get('/web/doclib/files/{file_id}')
def document_detail(file_id: str, svc: DoclibService = Depends(service)):
return success_response(svc.get_file(file_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,
)
+2
View File
@@ -536,6 +536,7 @@ class PlPlanLoad(Base):
notes = Column(String(500), nullable=True, comment="注意事项")
generate_dibang = Column(SmallInteger, nullable=False, default=0, comment="是否生成地磅单 0-否 1-是")
dibang_direction = Column(String(20), nullable=True, comment="地磅方向 outbound-出库 inbound-入库 none-无")
same_city = Column(SmallInteger, nullable=False, default=0, comment="是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证")
tenant_no = Column(String(64), nullable=False, default="shanxia2.0", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
@@ -599,6 +600,7 @@ class PlPlanDelivery(Base):
notes = Column(String(500), nullable=True, comment="注意事项")
generate_dibang = Column(SmallInteger, nullable=False, default=0, comment="是否生成地磅单 0-否 1-是")
dibang_direction = Column(String(20), nullable=True, comment="地磅方向 outbound-出库 inbound-入库 none-无")
same_city = Column(SmallInteger, nullable=False, default=0, comment="是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证")
tenant_no = Column(String(64), nullable=False, default="shanxia2.0", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
+5
View File
@@ -28,5 +28,10 @@ class ShareBody(_Body):
userIds: List[str] = Field(max_length=500)
class BatchShareBody(_Body):
ids: List[str] = Field(min_length=1, max_length=100)
userIds: List[str] = Field(max_length=500)
class DeleteFilesBody(_Body):
ids: List[str] = Field(min_length=1, max_length=100)
+2
View File
@@ -182,6 +182,7 @@ class PlanLoadBody(BaseModel):
dibang_direction: Optional[str] = Field(
default=None, alias="dibangDirection", description="地磅方向 outbound-出库 inbound-入库 none-无"
)
same_city: int = Field(default=0, alias="sameCity", description="是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证")
products: list[PlanStopProductBody] = Field(default_factory=list, description="装货段产品明细")
contracts: Optional[list["PlanContractLink"]] = Field(default=None, description="装货段合同列表")
remark: Optional[str] = Field(default=None, max_length=512, description="备注")
@@ -203,6 +204,7 @@ class PlanDeliveryBody(BaseModel):
dibang_direction: Optional[str] = Field(
default=None, alias="dibangDirection", description="地磅方向 outbound-出库 inbound-入库 none-无"
)
same_city: int = Field(default=0, alias="sameCity", description="是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证")
products: list[PlanStopProductBody] = Field(default_factory=list, description="送货段产品明细")
contracts: Optional[list["PlanContractLink"]] = Field(default=None, description="送货段合同列表")
remark: Optional[str] = Field(default=None, max_length=512, description="备注")
+1
View File
@@ -20,6 +20,7 @@ class UsageSource(InternalModel):
related_stop_products: List[str] = Field(default_factory=list)
fingerprint: str
snapshot: dict = Field(default_factory=dict)
requires_transport: bool = True
@property
def source_id(self):
+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"}
+20 -5
View File
@@ -228,23 +228,38 @@ class DoclibService:
self.db.commit()
return result
def set_shares(self, file_id, user_ids):
def _apply_shares(self, file_id, user_ids):
row = self._file(file_id, manage=True, lock=True)
requested = set(user_ids) - {row.create_by}
valid = {u.id for u in self._base(SysUser).filter(SysUser.id.in_(requested), SysUser.status == '1').all()}
if requested != valid:
raise HTTPException(400, '分享人员不存在、已停用或不属于当前租户')
if requested:
valid = {u.id for u in self._base(SysUser).filter(
SysUser.id.in_(requested), SysUser.status == '1').all()}
if requested != valid:
raise HTTPException(400, '分享人员不存在、已停用或不属于当前租户')
history = {s.user_id: s for s in self.db.query(DoclibShare).filter(
DoclibShare.tenant_no == self.tenant, DoclibShare.file_id == file_id).populate_existing().with_for_update().all()}
DoclibShare.tenant_no == self.tenant, DoclibShare.file_id == file_id
).populate_existing().with_for_update().all()}
for uid, share in history.items():
share.del_flag = 0 if uid in requested else 1
share.update_by = self.uid
for uid in requested - set(history):
self.db.add(DoclibShare(file_id=file_id, user_id=uid, **self._public()))
row.update_by, row.update_time = self.uid, datetime.now()
return row
def set_shares(self, file_id, user_ids):
row = self._apply_shares(file_id, user_ids)
self.db.commit()
return self._serialize_files([row])[0]
def set_shares_batch(self, ids, user_ids):
file_ids = sorted({str(i).strip() for i in ids if str(i).strip()})
if not file_ids:
raise HTTPException(400, '请选择要设置分享的文档')
rows = [self._apply_shares(fid, user_ids) for fid in file_ids]
self.db.commit()
return self._serialize_files(rows)
def delete_files(self, ids):
rows = [self._file(fid, manage=True, lock=True) for fid in sorted(set(ids))]
for row in rows:
@@ -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,
@@ -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,
@@ -1152,6 +1152,7 @@ def _resolve_or_create_plan_load(
existing.notes = load.notes
existing.generate_dibang = generate_dibang
existing.dibang_direction = dibang_direction
existing.same_city = 1 if int(load.same_city or 0) == 1 else 0
existing.remark = load.remark
existing.del_flag = 0
existing.update_by = user_id
@@ -1173,6 +1174,7 @@ def _resolve_or_create_plan_load(
notes=load.notes,
generate_dibang=generate_dibang,
dibang_direction=dibang_direction,
same_city=1 if int(load.same_city or 0) == 1 else 0,
tenant_no=tenant_no,
remark=load.remark,
create_by=user_id,
@@ -1439,6 +1441,7 @@ def replace_plan_stops(
stop.seq = body.seq or 1
stop.generate_dibang, stop.dibang_direction = normalize_stop_dibang_fields(
stop.customer_name, kind, body.generate_dibang, body.dibang_direction)
stop.same_city = 1 if int(getattr(body, "same_city", 0) or 0) == 1 else 0
stop.del_flag, stop.update_by, stop.update_time = 0, user_id, now
model = PlPlanLoadProduct if kind == 'load' else PlPlanDeliveryProduct
for idx, (row, product, linked) in enumerate(product_jobs or []):
+25 -7
View File
@@ -37,6 +37,7 @@ class SourceDemand:
product_id: str
quantity: Decimal
unit: str
requires_transport: bool = True
@dataclass(frozen=True)
@@ -231,6 +232,8 @@ def allocate_plan(demands: Sequence[SourceDemand], purchases: Sequence[PermitCan
key = (row.permit_kind, row.source_id, row.permit_id)
s = sources.get(row.source_id)
p = groups.get(row.permit_kind, {}).get(row.permit_id)
if s is not None and not s.requires_transport and row.permit_kind == 'transport':
raise AllocationInputError('同市来源不接受运输证分配')
sq, pq = _quantity(row.source_quantity), _quantity(row.permit_quantity)
if key in manual or s is None or p is None or s.product_id not in p.product_ids:
raise AllocationInputError('人工分配标识重复或不适用')
@@ -249,12 +252,19 @@ def allocate_plan(demands: Sequence[SourceDemand], purchases: Sequence[PermitCan
routes = []
for sid, s in sorted(sources.items()):
for gid, g in sorted(groups['purchase'].items()):
for tid, t in sorted(groups['transport'].items()):
if gid in t.purchase_permit_ids and eligible('purchase', s, g) and eligible('transport', s, t):
routes.append((sid, gid, tid))
if len(routes) > limits.max_variables:
raise AllocationIncomplete('分配计算未完成:匹配变量超限')
if s.requires_transport:
pairs = (
(gid, tid)
for gid, g in sorted(groups['purchase'].items())
for tid, t in sorted(groups['transport'].items())
if gid in t.purchase_permit_ids and eligible('purchase', s, g) and eligible('transport', s, t)
)
else:
pairs = ((gid, None) for gid, g in sorted(groups['purchase'].items()) if eligible('purchase', s, g))
for gid, tid in pairs:
routes.append((sid, gid, tid))
if len(routes) > limits.max_variables:
raise AllocationIncomplete('分配计算未完成:匹配变量超限')
a, b = [], []
def constrain(coefficients, bound, equal=False):
@@ -405,6 +415,7 @@ def validate_allocation_pairing(
if len(demands) > limits.max_sources or len(transports) > limits.max_permits:
raise AllocationIncomplete('匹配校验未完成:输入规模超限')
sources = _unique(demands, 'source_id')
purchase_only = {sid for sid, row in sources.items() if not row.requires_transport}
transport_map = _unique(transports, 'permit_id')
for s in sources.values():
_quantity(s.quantity)
@@ -434,13 +445,16 @@ def validate_allocation_pairing(
groups[kind][key] = row
for sid, s in sources.items():
total = sum(F(r.source_quantity) for (source_id, _), r in groups[kind].items() if source_id == sid)
if total != F(s.quantity):
expected = F(0) if kind == 'transport' and sid in purchase_only else F(s.quantity)
if total != expected:
raise AllocationPairingError('分配未覆盖实际来源量,需要人工调整;保留原占用')
purchase_ids = {pid for _, pid in groups['purchase']}
if len(purchase_ids) + len(transport_map) > limits.max_permits:
raise AllocationIncomplete('匹配校验未完成:证件数量超限')
routes = []
for sid, gid in sorted(groups['purchase']):
if sid in purchase_only:
continue
for source_id, tid in sorted(groups['transport']):
if sid == source_id and gid in transport_map[tid].purchase_permit_ids:
routes.append((sid, gid, tid))
@@ -450,9 +464,13 @@ def validate_allocation_pairing(
for kind, rows in groups.items():
position = 1 if kind == 'purchase' else 2
for (sid, pid), row in sorted(rows.items()):
if sid in purchase_only:
continue
v = [int(r[0] == sid and r[position] == pid) for r in routes]
a.extend([v, [-x for x in v]])
b.extend([F(row.source_quantity), -F(row.source_quantity)])
if not a:
return
try:
_solve(a, b, [0] * len(routes), budget)
except InsufficientQuota as exc:
+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
+12 -3
View File
@@ -21,7 +21,13 @@ def allocation_diagnostics(demands, purchases, transports):
grouped.append((code, source.unit))
for kind, permits in [('purchase', purchases), ('transport', transports)]:
for code, unit in grouped:
sources = [s for s in demands if canonical_quantity_unit(s.unit) == code]
sources = [
s for s in demands
if canonical_quantity_unit(s.unit) == code
and (kind == 'purchase' or getattr(s, 'requires_transport', True))
]
if not sources:
continue
products = {s.product_id for s in sources}
eligible = [p for p in permits if p.product_ids & products]
demand = sum((s.quantity for s in sources), Decimal(0))
@@ -67,6 +73,8 @@ def missing_purchase_link_message(demands, purchases, transports, cert_nos):
notices = []
seen = set()
for source in demands:
if not getattr(source, 'requires_transport', True):
continue
buys = [p for p in purchases if source.product_id in p.product_ids and same_quantity_unit(p.unit, source.unit)]
moves = [t for t in transports if source.product_id in t.product_ids and same_quantity_unit(t.unit, source.unit)]
if not buys or not moves:
@@ -93,16 +101,17 @@ def foreign_unit_message(demands, purchases, transports, cert_nos):
for source in demands:
buys = [p for p in purchases if source.product_id in p.product_ids]
moves = [t for t in transports if source.product_id in t.product_ids]
needs_transport = getattr(source, 'requires_transport', True)
same_buys = [p for p in buys if _directly_usable(p, source.unit)]
same_moves = [t for t in moves if _directly_usable(t, source.unit)]
if same_buys and same_moves:
if same_buys and (same_moves or not needs_transport):
continue
parts = []
if not same_buys:
text = _cert_unit_text('purchase', [p for p in buys if not _directly_usable(p, source.unit)], cert_nos)
if text:
parts.append(f'购买证 {text}')
if not same_moves:
if needs_transport and not same_moves:
text = _cert_unit_text('transport', [t for t in moves if not _directly_usable(t, source.unit)], cert_nos)
if text:
parts.append(f'运输证 {text}')
+19 -8
View File
@@ -35,8 +35,9 @@ def plan_fingerprint(sources):
def classify_contract_cert_bindings(sources, bindings_by_contract, contract_labels):
"""按合同产品的可用绑定选出购买证、运输证。缺任一证时拒绝安排。"""
purchase_ids, transport_ids, missing = [], [], []
"""按合同产品的可用绑定选出购买证、运输证。同市段只要求购买证。"""
purchase_ids, transport_ids = [], []
missing_both, missing_purchase = [], []
for source in sources:
found = {'purchase': [], 'transport': []}
for binding in bindings_by_contract.get(source.contract_id) or []:
@@ -52,13 +53,22 @@ def classify_contract_cert_bindings(sources, bindings_by_contract, contract_labe
if source.contract_product_id not in product_ids or kind not in found or not cert_id:
continue
found[kind].append(cert_id)
if not found['purchase'] or not found['transport']:
missing.append(contract_labels.get(source.contract_id) or '未编号合同')
needs_transport = getattr(source, 'requires_transport', True)
label = contract_labels.get(source.contract_id) or '未编号合同'
if not found['purchase'] or (needs_transport and not found['transport']):
(missing_both if needs_transport else missing_purchase).append(label)
purchase_ids.extend(found['purchase'])
transport_ids.extend(found['transport'])
if missing:
names = '、'.join(dict.fromkeys(missing))
raise HTTPException(409, f'合同{names}尚未绑定购买证和运输证,请先到合同中绑定后再安排运输')
if needs_transport:
transport_ids.extend(found['transport'])
messages = []
if missing_both:
names = '、'.join(dict.fromkeys(missing_both))
messages.append(f'合同{names}尚未绑定购买证和运输证,请先到合同中绑定后再安排运输')
if missing_purchase:
names = '、'.join(dict.fromkeys(missing_purchase))
messages.append(f'合同{names}的同市段尚未绑定购买证,请先到合同中绑定后再安排运输')
if messages:
raise HTTPException(409, ';'.join(messages))
def unique(values):
return list(dict.fromkeys(values))
@@ -279,6 +289,7 @@ def get_plan_quota(db, user, plan_id):
product = products[row.plan_product_id]
stop = stops[side][getattr(row, 'load_id' if side == 'load' else 'delivery_id')]
item = {'sourceStopId': stop.id, 'stopId': stop.id,
'sameCity': int(getattr(stop, 'same_city', 0) or 0) == 1,
'stopOrder': getattr(stop, 'seq', None) or stop.order_num or 1,
'productId': row.product_id, 'productNo': row.product_no,
'sourceStopType': side, 'sourceStopProductId': row.id,
+4 -2
View File
@@ -156,8 +156,10 @@ def extract_sources(db, user, plan_id, mapping=None, *, actual=False, lock=False
for stop_side, value in [(side, row)] + [(other_side, r) for r in related]:
parent = stops[stop_side][getattr(value, SIDE[stop_side][2])]
stop_facts.append({key: getattr(parent, key, None) for key in ('id', 'supplier_id', 'supplier_name', 'customer_id', 'customer_name', 'address')})
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}
'vehicle': plan.license_plate, 'trailer': plan.trailer_no, 'planType': plan.plan_type,
'sameCity': 1 if same_city else 0}
if measurement_version == 'per_stop_v2':
snapshot['measurementVersion'] = measurement_version
if preview_vehicle is not None:
@@ -168,7 +170,7 @@ def extract_sources(db, user, plan_id, mapping=None, *, actual=False, lock=False
plan_product_id=row.plan_product_id, contract_id=row.contract_id,
contract_product_id=row.contract_product_id, product_id=row.product_id,
quantity=row.plan_qty, unit=row.unit, related_stop_products=[r.id for r in related],
fingerprint=fingerprint, snapshot=snapshot)
fingerprint=fingerprint, snapshot=snapshot, requires_transport=not same_city)
sources.append(source)
if actual:
quantity = getattr(row, actual_field)
+19 -3
View File
@@ -197,6 +197,7 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
product=query(CrmContractProduct,id=s.contract_product_id,contract_id=s.contract_id,del_flag=0).first()
if product is None or product.product_id!=s.product_id: raise ValueError('合同产品归属不符')
scopes={}; candidates={'purchase':[],'transport':[]}
skipped_for_vehicle=False
for k in sorted(selected):
kind,pid=k; c=certs[k]
if not query(m.PlCertQtyLog,cert_type=kind,cert_id=pid,event_type='opening',del_flag=0).first(): raise ValueError('证件尚未确认期初')
@@ -206,7 +207,10 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
if not c.valid_from <= r.on_date <= c.valid_to: raise ValueError('证件不在有效日期内')
policy=usage_policy(c.valid_use_text)
if policy=='needs_confirmation': raise ValueError('有效次数待确认')
if kind=='transport' and (not r.vehicle_no or r.vehicle_no not in (c.vehicle_numbers_json or [])): raise ValueError('车号不在运输证范围')
# 合同上常有多张运输证。车号对不上的证不参与本次分配,只要还有一张覆盖该主车号即可安排。
if kind=='transport' and (not r.vehicle_no or r.vehicle_no not in (c.vehicle_numbers_json or [])):
skipped_for_vehicle=True
continue
if policy=='once':
previous=query(m.PlPlanCertUsage,cert_type=kind,cert_id=pid,del_flag=0).all()
if c.opening_single_use_consumed or any(u.plan_id!=plan.id and (u.usage_status in ('reserved','in_transit','settled','exception') or (u.confirmation_snapshot or {}).get('executed')) for u in previous):
@@ -220,11 +224,13 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
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))
if skipped_for_vehicle and not candidates['transport']:
raise ValueError('车号不在运输证范围')
return sources,scopes,candidates
def _reserve(db,user,r,plan,certs,usages,items,selected,preview=False,lock_preview=False):
sources,scopes,candidates=_candidate_context(db,user,r,plan,certs,usages,items,selected,preview,lock_preview)
demands=[SourceDemand(s.source_id,s.source_id,s.quantity,s.unit) for s in sources.values()]
demands=[SourceDemand(s.source_id,s.source_id,s.quantity,s.unit,s.requires_transport) for s in sources.values()]
try:
allocation=allocate_plan(demands,candidates['purchase'],candidates['transport'],manual_allocations=_manual(r.manual_allocations))
except InsufficientQuota as error:
@@ -244,6 +250,15 @@ def _reserve(db,user,r,plan,certs,usages,items,selected,preview=False,lock_previ
# 零量也占用一次资格;纯计算器省略零流,账本补存一对真实适用证件。
for sid,s in sorted(sources.items()):
if s.quantity != 0: continue
if not s.requires_transport:
buys=[p for p in candidates['purchase'] if sid in p.product_ids and (
same_quantity_unit(p.unit, s.unit) or standard_conversion_ratio(s.unit, p.unit) is not None
or any(a.permit_kind=='purchase' and a.source_id==sid and a.permit_id==p.permit_id for a in r.manual_allocations))]
if not buys:
raise ValueError('零量同市来源仍须有适用购买证')
chosen=min(buys, key=lambda item: (item.expires_on, item.permit_id))
rows.append(Allocation('purchase', sid, chosen.permit_id, ZERO, ZERO, s.unit, chosen.unit, 1))
continue
pairs=[(p,t) for p in candidates['purchase'] for t in candidates['transport']
if sid in p.product_ids and sid in t.product_ids and p.permit_id in t.purchase_permit_ids
and (same_quantity_unit(p.unit, s.unit) or standard_conversion_ratio(s.unit, p.unit) is not None or any(a.permit_kind=='purchase' and a.source_id==sid and a.permit_id==p.permit_id for a in r.manual_allocations))
@@ -430,12 +445,13 @@ def _settle(db,user,r,certs,usages,items,plan):
if any(u.usage_status!='settled' if adjusting else u.usage_status not in ('reserved','in_transit','exception') for k,u in usages.items() if k in active):
raise ValueError('用证状态不允许当前结转动作')
groups={'purchase':[],'transport':[]}; item_map={}; demands={}
transport_required={s.source_id: s.requires_transport for s in r.sources}
for k,rows in active.items():
for i in rows:
sid=_key(i)
a=Allocation(k[0],sid,k[1],i.allocated_source_qty,i.planned_cert_qty,i.source_unit,usages[k].quantity_unit,i.allocation_seq)
groups[k[0]].append(a); item_map[(k[0],sid,k[1])]=i
demands[sid]=SourceDemand(sid,sid,r.actual_by_source.get(sid),i.source_unit)
demands[sid]=SourceDemand(sid,sid,r.actual_by_source.get(sid),i.source_unit,transport_required.get(sid, True))
if not r.manual_actuals:
groups, generated, conversion_error = _prepare_saved_conversion(groups, r.actual_by_source, certs)
if conversion_error:
@@ -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"
@@ -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
+43 -2
View File
@@ -3,8 +3,8 @@ from decimal import Decimal as D
import pytest
from app.services.pl_quota_allocation import (SourceDemand, PermitCandidate, ManualAllocation, AllocationLimits, AllocationInputError, AllocationIncomplete, InsufficientQuota, SupplementalAllocationRequired, allocate_plan, settle_allocations)
def source(id='s', product='A', quantity='30', unit='t'):
return SourceDemand(id, product, D(quantity), unit)
def source(id='s', product='A', quantity='30', unit='t', requires_transport=True):
return SourceDemand(id, product, D(quantity), unit, requires_transport)
def permit(id, quantity='30', products=('A',), refs=(), unit='t', expiry=1):
return PermitCandidate(id, D(quantity), unit, date(2027, 1, expiry), frozenset(products), frozenset(refs))
@@ -153,6 +153,40 @@ def test_zero_demand_and_empty_plan():
assert allocate_plan([source(quantity='0')], [], []).transport_allocations == ()
def test_same_city_demand_uses_purchase_certificate_only():
result = allocate_plan([source('city', quantity='10', requires_transport=False)], [permit('g', '10')], [])
assert quantities(result.purchase_allocations) == [('g', D(10), D(10))]
assert result.transport_allocations == ()
def test_same_city_shares_purchase_balance_and_skips_transport():
city = source('city', 'A', '10', requires_transport=False)
cross = source('cross', 'B', '10')
result = allocate_plan(
[city, cross],
[permit('g', '30', ('A', 'B'))],
[permit('t', '10', ('A', 'B'), ('g',))],
)
assert sum(row.source_quantity for row in result.purchase_allocations) == D(20)
assert {(row.source_id, row.permit_id) for row in result.transport_allocations} == {('cross', 't')}
with pytest.raises(InsufficientQuota):
allocate_plan(
[source('city', 'A', '10', requires_transport=False), source('cross', 'A', '10')],
[permit('g', '15')],
[permit('t', '10', refs=('g',))],
)
def test_same_city_rejects_a_transport_manual_row():
with pytest.raises(AllocationInputError, match='同市'):
allocate_plan(
[source(requires_transport=False)],
[permit('g', '30')],
[permit('t', '30', refs=('g',))],
manual_allocations=[ManualAllocation('transport', 's', 't', D(30), D(30))],
)
def test_one_purchase_and_two_transport_certificates():
result = allocate_plan(
[source()], [permit('g', '30')],
@@ -208,6 +242,13 @@ def test_settlement_pairing_29_consistent_passes():
assert validate_allocation_pairing([source(quantity='29')], actual_rows(plan.purchase_allocations), actual_rows(plan.transport_allocations), transports) is None
def test_same_city_settlement_does_not_require_transport_pairing():
from app.services.pl_quota_allocation import Allocation, validate_allocation_pairing
demand = source('city', quantity='10', requires_transport=False)
purchase = Allocation('purchase', 'city', 'g', D(10), D(10), 't', 't', 1)
assert validate_allocation_pairing([demand], [purchase], [], []) is None
@pytest.mark.parametrize('manual_values', [(8,10,0),(18,0,0)])
def test_zero_capacity_foreign_row_cannot_unlock_manual_reordering(manual_values):
from app.services.pl_quota_allocation import Allocation
+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 = (
@@ -35,6 +35,17 @@ def test_sufficient_totals_do_not_claim_joint_feasibility():
assert all('引用' in row['reason'] for row in rows)
def test_same_city_shortfall_does_not_demand_a_transport_certificate():
from app.services.pl_quota_diagnostics import allocation_diagnostics, insufficient_quantity_message
rows = allocation_diagnostics(
[SourceDemand('a', 'a', D('30'), '吨', False)],
[permit('p', '10', ['a'])],
[],
)
assert [row['certType'] for row in rows] == ['purchase']
assert insufficient_quantity_message(rows) == '购买证剩余10.000吨,本次需要30.000吨。证件余量不足,不能安排运输,请先调整到有余量后再安排'
def test_missing_purchase_link_names_both_certificates():
from app.services.pl_quota_diagnostics import missing_purchase_link_message
source = SourceDemand('a', 'a', D('30'), '吨')
+15
View File
@@ -69,6 +69,21 @@ def test_arrange_uses_active_contract_cert_bindings_and_names_the_gap():
)
def test_same_city_stop_binds_purchase_certificate_only():
from app.services.pl_quota_planning import classify_contract_cert_bindings
source = SimpleNamespace(contract_id='c1', contract_product_id='line-1', requires_transport=False)
purchase = {
'certType': 'purchase', 'certId': 'buy-1', 'linkStatus': 'active',
'products': [{'contractProductId': 'line-1', 'scopeStatus': 'active'}],
}
labels = {'c1': 'HT-1'}
assert classify_contract_cert_bindings([source], {'c1': [purchase]}, labels) == (['buy-1'], [])
with pytest.raises(HTTPException) as error:
classify_contract_cert_bindings([source], {'c1': []}, labels)
assert error.value.status_code == 409
assert error.value.detail == '合同HT-1的同市段尚未绑定购买证,请先到合同中绑定后再安排运输'
def test_supplement_is_explicit_and_archive_public_schema_preserves_intent():
from app.schemas.pl.plan import PlanArchiveBody
from app.schemas.pl.quota_planning import PlanQuotaOperation
+19 -1
View File
@@ -245,7 +245,25 @@ def test_opening_consumed_and_expiry_vehicle_block(case):
p.opening_single_use_consumed=0; p.valid_to=date(2000,1,1); c.db.flush()
with pytest.raises(ValueError): run(c)
p.valid_to=date(2099,12,31); c.db.flush()
with pytest.raises(ValueError): run(c,vehicle_no='unknown')
with pytest.raises(ValueError, match='车号不在运输证范围'): run(c,vehicle_no='unknown')
def test_reserve_uses_transport_cert_that_lists_the_vehicle(case):
"""合同上多张运输证时,只用登记了本次主车号的证,不因其他证没有该车号而拒绝安排。"""
c = case
_second_pair(c)
c.db.get(c.m.PlPurchaseCert, 'p').total_qty = 100
c.db.get(c.m.PlTransportCert, 't').total_qty = 100
c.db.get(c.m.PlTransportCert, 't2').vehicle_numbers_json = ['OTHER']
c.db.flush()
run(c, purchase_cert_ids=['p', 'p2'], transport_cert_ids=['t', 't2'])
assert c.db.get(c.m.PlTransportCert, 't').reserved_qty == 29
assert c.db.get(c.m.PlTransportCert, 't2').reserved_qty == 0
assert c.db.get(c.m.PlPurchaseCert, 'p2').reserved_qty == 0
transport_usages = c.db.query(c.m.PlPlanCertUsage).filter_by(cert_type='transport', del_flag=0).all()
assert [u.cert_id for u in transport_usages if u.usage_status == 'reserved'] == ['t']
with pytest.raises(ValueError, match='车号不在运输证范围'):
run(c, operation_id='none', vehicle_no='NONE', purchase_cert_ids=['p', 'p2'], transport_cert_ids=['t', 't2'])
def test_no_cert_operation_is_still_idempotent(case):
c=case
@@ -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
@@ -0,0 +1,31 @@
-- 装货段、送货段增加「是否同市」。同市的易制毒段只校验、扣减购买证。
-- MySQL 5.7。可重复执行。历史数据默认 0,仍按购买证和运输证同时校验。
SET NAMES utf8mb4;
SET @schema_name = DATABASE();
SET @load_exists = (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'pl_plan_load' AND COLUMN_NAME = 'same_city'
);
SET @load_sql = IF(
@load_exists = 0,
'ALTER TABLE pl_plan_load ADD COLUMN same_city tinyint NOT NULL DEFAULT 0 COMMENT ''是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证'' AFTER dibang_direction',
'SELECT 1'
);
PREPARE load_stmt FROM @load_sql;
EXECUTE load_stmt;
DEALLOCATE PREPARE load_stmt;
SET @delivery_exists = (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'pl_plan_delivery' AND COLUMN_NAME = 'same_city'
);
SET @delivery_sql = IF(
@delivery_exists = 0,
'ALTER TABLE pl_plan_delivery ADD COLUMN same_city tinyint NOT NULL DEFAULT 0 COMMENT ''是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证'' AFTER dibang_direction',
'SELECT 1'
);
PREPARE delivery_stmt FROM @delivery_sql;
EXECUTE delivery_stmt;
DEALLOCATE PREPARE delivery_stmt;
@@ -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;
+2
View File
@@ -26711,6 +26711,7 @@ CREATE TABLE `pl_plan_delivery` (
`notes` varchar(500) DEFAULT NULL COMMENT '注意事项',
`generate_dibang` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否生成地磅单 0-否 1-是',
`dibang_direction` varchar(20) DEFAULT NULL COMMENT '地磅方向 outbound-出库 inbound-入库 none-无',
`same_city` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证',
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
@@ -26794,6 +26795,7 @@ CREATE TABLE `pl_plan_load` (
`notes` varchar(500) DEFAULT NULL COMMENT '注意事项',
`generate_dibang` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否生成地磅单 0-否 1-是',
`dibang_direction` varchar(20) DEFAULT NULL COMMENT '地磅方向 outbound-出库 inbound-入库 none-无',
`same_city` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证',
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
@@ -0,0 +1,374 @@
# 文档库多选批量设置分享 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 文档库 `/#/doclib/enter` 支持多选文档后一次设置分享人员(整份覆盖、交集预选、整批事务)。
**Architecture:** 后端新增 `POST /web/doclib/files/batch-shares`,从现有 `set_shares` 抽出无 commit 的 `_apply_shares`,批量一次 commit;前端放宽 `actionsFor.share`、扩展 `ShareDialog` 为多文档并用 `shareUserIntersection` 预选,统一调用批量 API。
**Tech Stack:** FastAPI / SQLAlchemy / Pydantic v2;Vue 3 / TypeScript / Element Plus;`node:test` + `tsx`(web-admin)。
**Spec:** `docs/superpowers/specs/2026-11-12-doclib-batch-share-design.md`(工单 #25)
---
## File map
| File | Responsibility |
| --- | --- |
| `backend/app/schemas/doclib.py` | 新增 `BatchShareBody` |
| `backend/app/services/doclib_service.py` | `_apply_shares`、`set_shares` 复用、`set_shares_batch` |
| `backend/app/api/doclib.py` | 注册 `POST .../batch-shares` |
| `web-admin/src/api/doclib.ts` | `saveDoclibBatchShares` |
| `web-admin/src/views/doclib/enter/model.ts` | `actionsFor.share` 多选 + `shareUserIntersection` |
| `web-admin/src/views/doclib/enter/model.test.ts` | 前端纯函数回归 |
| `web-admin/src/views/doclib/enter/api.test.ts` | 批量 API 适配器断言 |
| `web-admin/src/views/doclib/enter/ShareDialog.vue` | 多文档摘要、交集预选、批量保存 |
| `web-admin/src/views/doclib/enter/index.vue` | 工具栏传入全部 `selected` |
说明:仓库内暂无 backend doclib pytest;本计划以服务层行为对照现有 `set_shares`/`delete_files` 实现,并用前端单测锁定契约。若执行时已有可用测试 DB,可追加服务层用例,但不阻塞前端交付。
---
### Task 1: 前端纯函数 — 多选可分享 + 分享人交集
**Files:**
- Modify: `web-admin/src/views/doclib/enter/model.ts`
- Modify: `web-admin/src/views/doclib/enter/model.test.ts`
- [ ] **Step 1: 写失败测试(更新 actionsFor + 新增交集)**
在 `model.test.ts` 将首个测试改为多选可分享语义,并新增交集测试:
```ts
test('可管理文档可多选分享,混合选择不能分享', () => {
assert.deepEqual(actionsFor([{ canManage: false }]), {
share: false,
download: true,
delete: false
})
assert.deepEqual(actionsFor([{ canManage: true }, { canManage: false }]), {
share: false,
download: true,
delete: false
})
assert.deepEqual(actionsFor([{ canManage: true }]), {
share: true,
download: true,
delete: true
})
assert.deepEqual(actionsFor([{ canManage: true }, { canManage: true }]), {
share: true,
download: true,
delete: true
})
assert.deepEqual(actionsFor([]), { share: false, download: false, delete: false })
})
test('分享人初始勾选取所选文档交集', async () => {
const { shareUserIntersection } = await import('./model')
const docs = [
{ shares: [{ id: 'a' }, { id: 'b' }] },
{ shares: [{ id: 'b' }, { id: 'c' }] }
]
assert.deepEqual(shareUserIntersection(docs), ['b'])
assert.deepEqual(shareUserIntersection([{ shares: [{ id: 'a' }] }, { shares: [] }]), [])
assert.deepEqual(shareUserIntersection([]), [])
})
```
- [ ] **Step 2: 跑测试确认失败**
Run(在 `web-admin/`,使用本仓 `node_modules`):
```bash
./node_modules/.bin/tsx --test src/views/doclib/enter/model.test.ts
```
Expected: FAIL(`share` 仍要求 `length === 1`,且无 `shareUserIntersection`)
- [ ] **Step 3: 最小实现**
`model.ts`:
```ts
export function actionsFor(rows: Array<{ canManage: boolean }>) {
return {
share: rows.length > 0 && rows.every((row) => row.canManage),
download: rows.length > 0,
delete: rows.length > 0 && rows.every((row) => row.canManage)
}
}
export function shareUserIntersection(
documents: Array<{ shares?: Array<{ id: string }> | null }>
): string[] {
if (!documents.length) return []
let ids = new Set(
(documents[0].shares || []).map((person) => String(person.id || '').trim()).filter(Boolean)
)
for (const doc of documents.slice(1)) {
const next = new Set(
(doc.shares || []).map((person) => String(person.id || '').trim()).filter(Boolean)
)
ids = new Set([...ids].filter((id) => next.has(id)))
}
return [...ids]
}
```
- [ ] **Step 4: 跑测试确认通过**
```bash
./node_modules/.bin/tsx --test src/views/doclib/enter/model.test.ts
```
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add web-admin/src/views/doclib/enter/model.ts web-admin/src/views/doclib/enter/model.test.ts
git commit -m "feat(doclib): 允许多选可管理文档分享并计算分享人交集。"
```
---
### Task 2: 前端 API — batch-shares 适配器
**Files:**
- Modify: `web-admin/src/api/doclib.ts`
- Modify: `web-admin/src/views/doclib/enter/api.test.ts`
- [ ] **Step 1: 写失败测试**
在 `api.test.ts` 追加:
```ts
test('批量分享提交文档 id 列表与人员 id', async () => {
const { api, calls } = apiAdapter()
await api.saveDoclibBatchShares(['id/a', 'id/b'], ['u1', 'u2'])
assert.equal(calls[0].method, 'post')
assert.equal(calls[0].config.url, '/web/doclib/files/batch-shares')
assert.deepEqual(calls[0].config.data, { ids: ['id/a', 'id/b'], userIds: ['u1', 'u2'] })
})
```
- [ ] **Step 2: 跑测试确认失败**
```bash
./node_modules/.bin/tsx --test src/views/doclib/enter/api.test.ts
```
Expected: FAIL(无 `saveDoclibBatchShares`)
- [ ] **Step 3: 最小实现**
在 `doclib.ts` 的 `saveDoclibShares` 旁增加:
```ts
export const saveDoclibBatchShares = (ids: string[], userIds: string[]) =>
request.post<DoclibDocument[]>({
url: `${base}/files/batch-shares`,
data: { ids, userIds }
})
```
- [ ] **Step 4: 跑测试确认通过**
```bash
./node_modules/.bin/tsx --test src/views/doclib/enter/api.test.ts
```
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add web-admin/src/api/doclib.ts web-admin/src/views/doclib/enter/api.test.ts
git commit -m "feat(doclib): 增加批量设置分享 API 客户端。"
```
---
### Task 3: 后端 — BatchShareBody + set_shares_batch
**Files:**
- Modify: `backend/app/schemas/doclib.py`
- Modify: `backend/app/services/doclib_service.py`
- Modify: `backend/app/api/doclib.py`
- [ ] **Step 1: 增加 schema**
在 `ShareBody` 后增加:
```python
class BatchShareBody(_Body):
ids: List[str] = Field(min_length=1, max_length=100)
userIds: List[str] = Field(max_length=500)
```
- [ ] **Step 2: 重构服务层**
将 `set_shares` 拆为 `_apply_shares`(不 commit)与包装方法;新增 `set_shares_batch`:
```python
def _apply_shares(self, file_id, user_ids):
row = self._file(file_id, manage=True, lock=True)
requested = set(user_ids) - {row.create_by}
if requested:
valid = {u.id for u in self._base(SysUser).filter(
SysUser.id.in_(requested), SysUser.status == '1').all()}
if requested != valid:
raise HTTPException(400, '分享人员不存在、已停用或不属于当前租户')
else:
valid = set()
history = {s.user_id: s for s in self.db.query(DoclibShare).filter(
DoclibShare.tenant_no == self.tenant, DoclibShare.file_id == file_id
).populate_existing().with_for_update().all()}
for uid, share in history.items():
share.del_flag = 0 if uid in requested else 1
share.update_by = self.uid
for uid in requested - set(history):
self.db.add(DoclibShare(file_id=file_id, user_id=uid, **self._public()))
row.update_by, row.update_time = self.uid, datetime.now()
return row
def set_shares(self, file_id, user_ids):
row = self._apply_shares(file_id, user_ids)
self.db.commit()
return self._serialize_files([row])[0]
def set_shares_batch(self, ids, user_ids):
file_ids = sorted({str(i).strip() for i in ids if str(i).strip()})
if not file_ids:
raise HTTPException(400, '请选择要设置分享的文档')
rows = [self._apply_shares(fid, user_ids) for fid in file_ids]
self.db.commit()
return self._serialize_files(rows)
```
注意:空 `requested` 时跳过用户表查询(避免 `IN ()`),与「清空分享」语义一致。原 `set_shares` 在 `requested` 为空时 `SysUser.id.in_(set())` 行为依赖方言,重构时一并修掉。
- [ ] **Step 3: 注册路由**
`doclib.py` 增加 import `BatchShareBody`,并在 `batch-delete` 旁:
```python
@router.post('/web/doclib/files/batch-shares')
def batch_share_documents(body: BatchShareBody, svc: DoclibService = Depends(service)):
return success_response(svc.set_shares_batch(body.ids, body.userIds))
```
- [ ] **Step 4: 静态核对**
确认:
- 单文档 `PUT .../shares` 仍指向 `set_shares`
- 批量路径仅一次 `commit`
- `_apply_shares` 任一步抛错时不调用 `commit`
可选:在已激活 venv 下对改动文件做语法检查
```bash
cd backend && source venv/bin/activate && python -c "from app.schemas.doclib import BatchShareBody; from app.services.doclib_service import DoclibService; print('ok')"
```
- [ ] **Step 5: Commit**
```bash
git add backend/app/schemas/doclib.py backend/app/services/doclib_service.py backend/app/api/doclib.py
git commit -m "feat(doclib): 增加批量设置分享接口并复用单文档写入逻辑。"
```
---
### Task 4: ShareDialog + 列表页接入
**Files:**
- Modify: `web-admin/src/views/doclib/enter/ShareDialog.vue`
- Modify: `web-admin/src/views/doclib/enter/index.vue`
- [ ] **Step 1: 改 ShareDialog props 与保存**
- Props:`documents: DoclibDocument[]`(由页面传入;可去掉单 `document` 或保留 computed 取 `documents[0]` 兼容内部逻辑)。
- 打开时:`userIds = shareUserIntersection(documents)`;摘要展示名称列表(超过 5 个截断并显示「等 N 个」)。
- `options` 排除所有 `documents` 的 `ownerId`。
- `save`:若无文档或存在 `!canManage` 则 return;调用 `saveDoclibBatchShares(ids, userIds)`;`emit('saved', updated)`(`updated` 为数组时由父组件刷新)。
示意:
```ts
const props = defineProps<{ modelValue: boolean; documents: DoclibDocument[] }>()
// watch open:
userIds.value = shareUserIntersection(props.documents)
// save:
const ids = props.documents.map((d) => d.id)
const updated = await saveDoclibBatchShares(ids, userIds.value)
emit('saved', updated)
```
- [ ] **Step 2: 改 index.vue**
- `shareDocuments` 替代 `shareDocument`(`ref<DoclibDocument[]>`)。
- `openShare(rows: DoclibDocument | DoclibDocument[])` 规范化为数组。
- 工具栏:`@click="openShare(selected)"`。
- 详情:`@click="openShare(detail)"`。
- `<ShareDialog v-model="shareVisible" :documents="shareDocuments" @saved="shareSaved" />`
- `shareSaved`:若返回数组则按 id 更新详情;始终 `refresh()`。
- [ ] **Step 3: 跑前端相关测试**
```bash
cd web-admin
./node_modules/.bin/tsx --test src/views/doclib/enter/model.test.ts src/views/doclib/enter/api.test.ts
```
Expected: PASS
- [ ] **Step 4: Commit**
```bash
git add web-admin/src/views/doclib/enter/ShareDialog.vue web-admin/src/views/doclib/enter/index.vue
git commit -m "feat(doclib): 文档库支持多选文档后批量设置分享。"
```
---
### Task 5: 回归核对与交付准备
- [ ] **Step 1: 再跑前端测试套件**
```bash
cd web-admin && ./node_modules/.bin/tsx --test src/views/doclib/enter/model.test.ts src/views/doclib/enter/api.test.ts
```
- [ ] **Step 2: 对照验收清单(手工或浏览器)**
1. 多选可管理 → 可开分享;混入不可管理 → 按钮禁用
2. 初始勾选为交集
3. 保存后各文档分享人一致
4. 详情单文档分享仍可用
- [ ] **Step 3: 更新工单 #25 评论**:分支、提交、测试命令与结果、未跑项(真机)
- [ ] **Step 4: 按用户授权 push 任务分支**(`push_task_branch` 已开;`create_pr` 未开则不建 PR)
---
## Spec coverage self-check
| Spec 要求 | Task |
| --- | --- |
| `actionsFor` 多选可管理可分享 | Task 1 |
| 交集预选 | Task 1 + 4 |
| `batch-shares` API + 事务 | Task 3 |
| `_apply_shares` 复用单路径 | Task 3 |
| 前端批量客户端 | Task 2 |
| ShareDialog / 工具栏 | Task 4 |
| 单测 | Task 1–2、5 |
| 整份覆盖 / 空列表收回 | Task 3–4(沿用 `_apply_shares`) |
| 非目标:小程序 / 只追加 / 部分成功 | 无任务(刻意不做) |
## Placeholder scan
无 TBD /「类似 Task N」/ 空测试步骤。
@@ -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,115 @@
# 文档库多选批量设置分享(工单 #25)
工单:https://gitea.aichengyan.com/cykj/shanxia2.0/issues/25
## 背景与问题
Web 文档库管理(`/#/doclib/enter`)工具栏「设置分享」当前仅在恰好选中 1 份且可管理的文档时可用(`actionsFor.share`),弹窗与接口均为单文档。用户需要多选文档后统一设置分享人员。
## 已确认决策
| 项 | 结论 |
| --- | --- |
| 保存语义 | 整份覆盖:弹窗所选 `userIds` 写入每一份所选文档,替换原分享人;空列表表示全部收回 |
| 弹窗初始勾选 | 所选文档现有分享人的交集;无共同人员则为空 |
| 修改端 | Web 管理端 + 后端批量 API |
| 失败策略 | 整批事务:全部成功或全部回滚 |
| 实现方案 | 新增 `batch-shares` + 扩展现有 `ShareDialog`(方案 A) |
## 范围
**包含**
- `web-admin`:`doclib/enter` 列表工具栏、`ShareDialog`、`actionsFor`、API 封装与单测
- `backend`:批量分享路由、schema、服务层事务与单文件写入逻辑复用
**不包含**
- 小程序 / 移动 H5
- 「只追加不收回」、部分成功返回清单
- 文件夹级分享
## 后端设计
### 接口
`POST /web/doclib/files/batch-shares`
请求体(`extra=forbid`):
```json
{
"ids": ["file-id-1", "file-id-2"],
"userIds": ["user-id-a", "user-id-b"]
}
```
约束:
- `ids`:`min_length=1`,`max_length=100`(对齐 `DeleteFilesBody`)
- `userIds`:`max_length=500`(对齐 `ShareBody`)
- 去重后的 `ids` 均须当前用户可管理;任一份不可管理则 4xx,整批不写
- 人员须同租户且启用;自动排除各文档上传人(与现 `set_shares` 一致:`requested - {create_by}`)
- 非法 / 停用 / 跨租户人员 → 400,整批回滚
响应:成功后返回更新后的文档列表(字段与现有单文档分享成功返回一致的序列化)。
### 事务与复用
- 现有 `DoclibService.set_shares` 在单文件路径末尾 `commit`。批量路径须在**全部文件写入完成前不 commit**。
- 抽取内部方法(名称示例:`_apply_shares(file_id, user_ids)`)完成单文件差量更新(含 lock);`set_shares` = `_apply_shares` + `commit` + 返回序列化;`set_shares_batch` = 校验全部可管理 → 校验人员 → 对每个 id 调用 `_apply_shares` → 一次 `commit` → 返回列表。
- 任一步抛错依赖会话回滚,不留下部分文档已改的状态。
路由注册位置:与现有 `PUT /web/doclib/files/{file_id}/shares`、`POST .../batch-delete` 同模块(`backend/app/api/doclib.py`)。
## 前端设计
### 工具栏与权限
- `actionsFor.share`:`rows.length > 0 && rows.every((row) => row.canManage)`(与删除多选门槛一致,取消「仅 1 条」限制)。
- 工具栏「设置分享」对 `selected` 全量打开弹窗,不再只取 `selected[0]`。
- 详情抽屉单文档「设置分享」仍传入单元素数组或单文档,行为不变。
### ShareDialog
- Props 改为支持多文档(例如 `documents: DoclibDocument[]`);打开时:
- 摘要列出所选文档名称(数量较多时可截断并注明总数)
- `userIds` 初始 = 分享人 id **交集**
- 候选人员:交集中的人员 ∪ 远程搜索结果;排除各文档上传人(多文档时排除所有相关 `ownerId`)
- 保存:统一调用批量接口 `saveDoclibBatchShares(ids, userIds)`(单文档也走批量,少分支);成功提示后关闭并刷新列表 / 更新详情。
### 纯函数(便于单测)
- `shareUserIntersection(documents): string[]` — 交集;空文档列表返回 `[]`
- 可选:`canShareSelection(rows)` 与 `actionsFor` 共用语义
### API
在 `web-admin/src/api/doclib.ts` 增加:
`saveDoclibBatchShares(ids: string[], userIds: string[])` → `POST .../files/batch-shares`
单文档 `saveDoclibShares` 可保留供兼容/测试,页面路径以批量为准。
## UI 规范
- 沿用现有 `doclib-dialog dialog-form-layout`、Fresh Mint、P12 类弹窗;不新造布局与主色。
- 按钮图标规则与现页一致(Close / Check 等已有用法)。
## 验收
1. 多选可管理文档后可打开「设置分享」;含不可管理项时按钮禁用。
2. 打开时勾选为交集;无共同分享人时为空。
3. 保存后所选文档分享人员均等于弹窗所选集合(含清空)。
4. 故意混入无权限文档(若后端被直接调用)→ 整批不改。
5. 详情页单文档分享仍可用。
6. 单测覆盖交集函数与 `actionsFor.share`;后端/服务层覆盖批量成功与权限失败回滚(按项目既有测试方式)。
## 风险
- `set_shares` 重构时须保证单文档路径回归(commit 时机、上传人排除、软删恢复历史 share 行)。
- 批量上限 100 与删除一致;超大选区依赖现有表格多选,不另做分片 UI。
## 状态
设计已获用户确认(会话中「按照推荐」方案 A,「OK,请继续」批准落盘)。待用户审阅本文件后进入实现计划。
@@ -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` 单据列表文案仍为「审批中」,验收时需知悉;若后续需要可另开刷数任务。
+4
View File
@@ -1,3 +1,7 @@
# 单包工程也须声明 packages,否则 pnpm 9+ 报 “packages field missing or empty”
packages:
- '.'
allowBuilds:
'@parcel/watcher': true
'@tailwindcss/oxide': true
+19
View File
@@ -3,6 +3,9 @@ import test from 'node:test'
import {
buyPartyName,
certFaceFields,
certKindLabel,
certKindTagType,
compareCertUploadTime,
expiryWarning,
salePartyName
} from '../src/views/pl/quota-cert/cert-list-face'
@@ -49,3 +52,19 @@ test('only certificates expiring within seven days show a red warning label', ()
assert.equal(expiryWarning('2026-11-12', today), null)
assert.equal(expiryWarning('', today), null)
})
test('merged certificate lists follow upload time, then id, descending', () => {
assert.equal(certKindLabel('transport'), '运输证')
assert.equal(certKindLabel('purchase'), '购买证')
assert.equal(certKindTagType('transport'), 'warning')
assert.equal(certKindTagType('purchase'), 'success')
const rows = [
{ id: 'b', createTime: '2026-11-01 10:00:00' },
{ id: 'a', createTime: '2026-11-05 10:00:00' },
{ id: 'c', createTime: '2026-11-05 10:00:00' }
].sort(compareCertUploadTime)
assert.deepEqual(
rows.map((row) => row.id),
['c', 'a', 'b']
)
})
@@ -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'
)
})
+5
View File
@@ -67,6 +67,11 @@ export const saveDoclibShares = (id: string, userIds: string[]) =>
url: `${base}/files/${encodeURIComponent(id)}/shares`,
data: { userIds }
})
export const saveDoclibBatchShares = (ids: string[], userIds: string[]) =>
request.post<DoclibDocument[]>({
url: `${base}/files/batch-shares`,
data: { ids, userIds }
})
export const deleteDoclibFiles = (ids: string[]) =>
request.post({ url: `${base}/files/batch-delete`, data: { ids } })
export const downloadDoclibFile = (id: string) =>
+4
View File
@@ -480,6 +480,8 @@ export interface PlanLoad {
notes?: string
generateDibang?: number
dibangDirection?: string
/** 1 时本段易制毒只校验购买证 */
sameCity?: number
remark?: string
products?: PlanStopProduct[]
contracts?: PlanContractLink[]
@@ -502,6 +504,8 @@ export interface PlanDelivery {
notes?: string
generateDibang?: number
dibangDirection?: string
/** 1 时本段易制毒只校验购买证 */
sameCity?: number
remark?: string
deliveryMethod?: string
products?: PlanStopProduct[]
+2
View File
@@ -285,6 +285,8 @@ export interface QuotaSourceOption {
contractId: string
contractProductId: string
partyName?: string
/** 本段勾选同市时只要求购买证 */
sameCity?: boolean
}
export interface QuotaSourcePair {
sourceStopType: 'load' | 'delivery'
@@ -162,6 +162,15 @@
@update:model-value="patchDelivery(index, { notes: $event })"
/>
</el-form-item>
<el-form-item label="是否同市" class="form-col-span-2">
<el-checkbox
:model-value="delivery.sameCity === 1"
:disabled="readonly"
@update:model-value="(checked) => patchDelivery(index, { sameCity: checked ? 1 : 0 })"
>
同市只需购买证
</el-checkbox>
</el-form-item>
<el-form-item label="地磅单" class="form-col-span-2">
<div class="pl-plan-dibang-row">
<el-checkbox
@@ -714,6 +714,7 @@
mergePlanProductsFromLoadMirror,
shouldRetainPlanProductsWhileLoadEmpty,
resolveStopDibangDefaults,
toxicStopCertBindingMessage,
sumStopProductsPlanQty,
type PlanDeliveryForm,
type PlanLoadForm,
@@ -2619,19 +2620,7 @@
}
function toxicContractCertBindingMessage(rows: QuotaSourceOption[]) {
const missing = new Map<string, string>()
for (const row of rows) {
if (isPlInternalCustomerName(row.partyName)) continue
const certificates = row.certificates || []
const ready =
certificates.some((cert) => cert.certType === 'purchase') &&
certificates.some((cert) => cert.certType === 'transport')
if (ready) continue
const key = row.contractId || row.contractNo || row.productName || row.sourceStopProductId
missing.set(key, row.contractNo || '未编号合同')
}
if (!missing.size) return ''
return `合同${[...missing.values()].join('、')}尚未绑定购买证和运输证,请先到合同中绑定后再安排运输`
return toxicStopCertBindingMessage(rows)
}
async function performArrangeAfterSave(
@@ -110,6 +110,15 @@
@update:model-value="patch({ notes: $event })"
/>
</el-form-item>
<el-form-item label="是否同市" class="form-col-span-2">
<el-checkbox
:model-value="modelValue.sameCity === 1"
:disabled="readonly"
@update:model-value="patch({ sameCity: $event ? 1 : 0 })"
>
同市只需购买证
</el-checkbox>
</el-form-item>
<el-form-item label="地磅单" class="form-col-span-2">
<div class="pl-plan-dibang-row">
<el-checkbox
@@ -78,7 +78,15 @@
<section v-if="faceTabs.length" class="quota-face">
<h3 class="dialog-section-title">关联证件证面</h3>
<el-tabs v-model="faceTab" class="quota-face-tabs">
<el-tab-pane v-for="tab in faceTabs" :key="tab.key" :label="tab.label" :name="tab.key">
<el-tab-pane v-for="tab in faceTabs" :key="tab.key" :name="tab.key">
<template #label>
<span class="quota-kind-line">
<el-tag size="small" :type="certKindTagType(tab.kind)">{{
certKindLabel(tab.kind)
}}</el-tag>
<span>{{ tab.certNo }}</span>
</span>
</template>
<p v-if="!tab.cert" class="quota-note">没有读到这张证的证面档案。</p>
<template v-else>
<section
@@ -209,12 +217,9 @@
<div class="conversion-grid">
<section v-for="row in group.rows" :key="conversionKey(row)" class="conversion-card">
<div class="conversion-card-head">
<el-tag
size="small"
effect="plain"
:type="row.cert.certType === 'purchase' ? 'success' : 'primary'"
>{{ row.cert.certType === 'purchase' ? '购买证' : '运输证' }}</el-tag
>
<el-tag size="small" :type="certKindTagType(row.cert.certType)">{{
certKindLabel(row.cert.certType)
}}</el-tag>
<strong>{{ row.cert.certNo }}</strong>
</div>
<label>换算系数</label>
@@ -286,7 +291,12 @@
>
</div>
<div v-for="item in selected" :key="item.kind + item.cert.id" class="view-section">
<h4>{{ item.kind === 'purchase' ? '购买证' : '运输证' }} · {{ item.cert.certNo }}</h4>
<h4 class="quota-kind-line">
<el-tag size="small" :type="certKindTagType(item.kind)">{{
certKindLabel(item.kind)
}}</el-tag>
<span>{{ item.cert.certNo }}</span>
</h4>
<p
>{{ item.cert.buyerName || item.cert.shipperName }} →
{{ item.cert.sellerName || item.cert.consigneeName }};有效期
@@ -393,10 +403,12 @@
:key="row.certType + row.unit"
class="view-section"
>
<strong
>{{ row.certType === 'purchase' ? '购买证' : '运输证' }} · 需求
{{ qtyText(row.demand) }} {{ unitText(row.unit) }}</strong
>
<div class="quota-kind-line">
<el-tag size="small" :type="certKindTagType(row.certType)">{{
certKindLabel(row.certType)
}}</el-tag>
<strong>需求 {{ qtyText(row.demand) }} {{ unitText(row.unit) }}</strong>
</div>
<p v-if="row.availableUpperBound !== null">
适用余额至多 {{ qtyText(row.availableUpperBound) }}
{{ unitText(row.unit) }};缺口至少 {{ qtyText(row.gapLowerBound) }}
@@ -408,7 +420,13 @@
}}</p>
</div>
<el-table :data="allocations" stripe
><el-table-column prop="kind" label="证种" min-width="90" /><el-table-column
><el-table-column label="证种" min-width="110"
><template #default="{ row }"
><el-tag size="small" :type="row.kind === '运输证' ? 'warning' : 'success'">{{
row.kind
}}</el-tag></template
></el-table-column
><el-table-column
prop="certNo"
label="证号"
min-width="160" /><el-table-column
@@ -493,6 +511,7 @@
import { useUserStore } from '@/store/modules/user'
import { useDictStore } from '@/store/modules/dict'
import { getProductUnitLabel } from '@/utils/crm-product-display'
import { certKindLabel, certKindTagType } from '@/views/pl/quota-cert/cert-list-face'
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
@@ -631,7 +650,7 @@
kind,
cert,
certNo: number,
label: `${kind === 'purchase' ? '购买证' : '运输证'} ${number}`
label: `${certKindLabel(kind)} ${number}`
})
}
for (const row of plan.value?.stopProducts || []) {
@@ -1059,6 +1078,14 @@
})
</script>
<style scoped>
.quota-kind-line {
display: inline-flex;
gap: 8px;
align-items: center;
justify-content: flex-start;
max-width: 100%;
}
.quota-dialog :deep(.el-select) {
width: 100%;
}
@@ -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 }
)
}
+102 -22
View File
@@ -411,57 +411,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 +566,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 +582,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 +592,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 +600,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 +614,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',
@@ -2,7 +2,8 @@ import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
canManualToggleStopDibang,
resolveStopDibangDefaults
resolveStopDibangDefaults,
toxicStopCertBindingMessage
} from './pl-plan-stop-form'
import { isPlDibangOptionalVehiclePlate } from './pl-internal-customer'
@@ -23,6 +24,28 @@ describe('isPlDibangOptionalVehiclePlate', () => {
})
})
describe('toxicStopCertBindingMessage', () => {
it('same-city stop only requires the purchase certificate', () => {
assert.equal(
toxicStopCertBindingMessage([
{
contractId: 'c1',
contractNo: 'HT-1',
sameCity: 1,
certificates: [{ certType: 'purchase' }]
}
]),
''
)
assert.equal(
toxicStopCertBindingMessage([
{ contractId: 'c1', contractNo: 'HT-1', sameCity: true, certificates: [] }
]),
'合同HT-1的同市段尚未绑定购买证,请先到合同中绑定后再安排运输'
)
})
})
describe('resolveStopDibangDefaults + canManualToggleStopDibang', () => {
it('internal + normal vehicle: auto-check, not manual', () => {
assert.deepEqual(resolveStopDibangDefaults(INTERNAL, 'load', '皖A11111'), {
+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'
}
+55
View File
@@ -102,6 +102,8 @@ export interface PlanLoadForm {
notes: string
generateDibang: number
dibangDirection: string
/** 1 时本段易制毒只校验购买证 */
sameCity: number
contracts: PlanContractLink[]
products: PlanStopProductFormRow[]
}
@@ -119,6 +121,8 @@ export interface PlanDeliveryForm {
notes: string
generateDibang: number
dibangDirection: string
/** 1 时本段易制毒只校验购买证 */
sameCity: number
contracts: PlanContractLink[]
products: PlanStopProductFormRow[]
}
@@ -146,6 +150,49 @@ export function resolveStopDibangDefaults(
return { generateDibang: 1, dibangDirection: 'inbound' }
}
/** 安排前检查易制毒段的合同证件。同市段只要求购买证。 */
export function toxicStopCertBindingMessage(
rows: Array<{
partyName?: string | null
sameCity?: boolean | number | null
certificates?: { certType?: string | null }[] | null
contractId?: string | null
contractNo?: string | null
productName?: string | null
sourceStopProductId?: string | null
}>
): string {
const missingBoth = new Map<string, string>()
const missingPurchase = new Map<string, string>()
for (const row of rows) {
if (isPlInternalCustomerName(row.partyName)) continue
const certificates = row.certificates || []
const hasPurchase = certificates.some((cert) => cert.certType === 'purchase')
const hasTransport = certificates.some((cert) => cert.certType === 'transport')
const sameCity = row.sameCity === true || Number(row.sameCity) === 1
const key = row.contractId || row.contractNo || row.productName || row.sourceStopProductId || ''
const label = row.contractNo || '未编号合同'
if (sameCity) {
if (!hasPurchase) missingPurchase.set(key, label)
continue
}
if (hasPurchase && hasTransport) continue
missingBoth.set(key, label)
}
const parts: string[] = []
if (missingBoth.size) {
parts.push(
`合同${[...missingBoth.values()].join('、')}尚未绑定购买证和运输证,请先到合同中绑定后再安排运输`
)
}
if (missingPurchase.size) {
parts.push(
`合同${[...missingPurchase.values()].join('、')}的同市段尚未绑定购买证,请先到合同中绑定后再安排运输`
)
}
return parts.join(';')
}
/** 仅「内部客户 + 地磅可选车」允许用户手动勾选地磅单 */
export function canManualToggleStopDibang(
customerName?: string | null,
@@ -433,6 +480,7 @@ export function createEmptyLoadForm(): PlanLoadForm {
notes: '',
generateDibang: 0,
dibangDirection: 'none',
sameCity: 0,
contracts: [],
products: []
}
@@ -451,6 +499,7 @@ export function createEmptyDeliveryForm(seq = 1): PlanDeliveryForm {
notes: '',
generateDibang: 0,
dibangDirection: 'none',
sameCity: 0,
contracts: [],
products: []
}
@@ -540,6 +589,7 @@ export function mapLoadFromApi(
notes: load.notes || '',
generateDibang: dibang.generateDibang,
dibangDirection: dibang.dibangDirection,
sameCity: Number(load.sameCity) === 1 ? 1 : 0,
contracts: (load.contracts || []).map((item) => ({
contractId: String(item.contractId || ''),
contractNo: item.contractNo
@@ -574,6 +624,7 @@ export function mapDeliveriesFromApi(
notes: row.notes || '',
generateDibang: dibang.generateDibang,
dibangDirection: dibang.dibangDirection,
sameCity: Number(row.sameCity) === 1 ? 1 : 0,
contracts: (row.contracts || []).map((item) => ({
contractId: String(item.contractId || ''),
contractNo: item.contractNo
@@ -617,6 +668,7 @@ export function mapLegacyPlanToStops(plan: Plan): {
notes: plan.pickupNotes || '',
generateDibang: loadDefaults.generateDibang,
dibangDirection: loadDefaults.dibangDirection,
sameCity: 0,
contracts: (plan.supplierContracts || []).map((item) => ({
contractId: String(item.contractId || ''),
contractNo: item.contractNo
@@ -636,6 +688,7 @@ export function mapLegacyPlanToStops(plan: Plan): {
notes: plan.destinationNotes || '',
generateDibang: deliveryDefaults.generateDibang,
dibangDirection: deliveryDefaults.dibangDirection,
sameCity: 0,
contracts: (plan.demanderContracts || []).map((item) => ({
contractId: String(item.contractId || ''),
contractNo: item.contractNo
@@ -900,6 +953,7 @@ export function buildLoadPayload(load: PlanLoadForm) {
notes: load.notes || undefined,
generateDibang: load.generateDibang,
dibangDirection: load.dibangDirection,
sameCity: load.sameCity === 1 ? 1 : 0,
contracts: load.contracts,
products: load.products.map(buildStopProductPayload)
}
@@ -919,6 +973,7 @@ export function buildDeliveriesPayload(deliveries: PlanDeliveryForm[]) {
notes: row.notes || undefined,
generateDibang: row.generateDibang,
dibangDirection: row.dibangDirection,
sameCity: row.sameCity === 1 ? 1 : 0,
contracts: row.contracts,
products: row.products.map(buildStopProductPayload)
}))
@@ -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
}
@@ -11,11 +11,13 @@
:close-on-press-escape="!saving"
:show-close="!saving"
>
<p class="doclib-summary">{{ document?.name }}</p>
<p class="doclib-summary">{{ summaryText }}</p>
<el-form label-position="top">
<el-form-item>
<template #label
>分享给<span class="form-label-tip">可多选,取消选择即收回访问权限</span></template
>分享给<span class="form-label-tip"
>可多选,取消选择即收回访问权限;保存后对所选文档整份覆盖</span
></template
>
<el-select
v-model="userIds"
@@ -56,15 +58,15 @@
import { Close, Check } from '@element-plus/icons-vue'
import {
fetchDoclibUsers,
saveDoclibShares,
saveDoclibBatchShares,
type DoclibDocument,
type DoclibPerson
} from '@/api/doclib'
import { createLatestRequest } from './model'
const props = defineProps<{ modelValue: boolean; document: DoclibDocument | null }>()
import { createLatestRequest, shareUserIntersection } from './model'
const props = defineProps<{ modelValue: boolean; documents: DoclibDocument[] }>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: [document: DoclibDocument]
saved: [documents: DoclibDocument[]]
}>()
const visible = computed({
get: () => props.modelValue,
@@ -77,17 +79,31 @@
const userIds = ref<string[]>([])
const people = ref<DoclibPerson[]>([])
const requests = createLatestRequest()
const ownerIds = computed(
() => new Set(props.documents.map((doc) => doc.ownerId).filter(Boolean))
)
const summaryText = computed(() => {
const names = props.documents.map((doc) => doc.name).filter(Boolean)
if (!names.length) return '未选择文档'
if (names.length === 1) return names[0]
const shown = names.slice(0, 5)
const more = names.length > 5 ? `等 ${names.length} 个` : ''
return `已选 ${names.length} 个文档:${shown.join('、')}${more ? ` ${more}` : ''}`
})
const options = computed(() => {
const candidates = new Map((props.document?.shares || []).map((person) => [person.id, person]))
const candidates = new Map<string, DoclibPerson>()
for (const doc of props.documents) {
for (const person of doc.shares || []) candidates.set(person.id, person)
}
people.value.forEach((person) => candidates.set(person.id, person))
return [...candidates.values()].filter((person) => person.id !== props.document?.ownerId)
return [...candidates.values()].filter((person) => !ownerIds.value.has(person.id))
})
watch(
() => props.modelValue,
(value) => {
requests.invalidate()
if (!value) return
userIds.value = (props.document?.shares || []).map((person) => person.id)
userIds.value = shareUserIntersection(props.documents)
people.value = []
void searchUsers('')
}
@@ -109,12 +125,17 @@
}
}
async function save() {
if (!props.document?.canManage || saving.value) return
if (!props.documents.length || props.documents.some((doc) => !doc.canManage) || saving.value) {
return
}
saving.value = true
try {
const updated = await saveDoclibShares(props.document.id, userIds.value)
const updated = await saveDoclibBatchShares(
props.documents.map((doc) => doc.id),
userIds.value
)
ElMessage.success('分享设置已保存')
emit('saved', updated)
emit('saved', updated || [])
emit('update:modelValue', false)
} catch {
/* 请求层统一展示错误 */
@@ -44,3 +44,11 @@ test('专用上传请求提交原文件及目标目录,清空分享提交空
assert.equal(calls[1].method, 'put')
assert.deepEqual(calls[1].config.data, { userIds: [] })
})
test('批量分享提交文档 id 列表与人员 id', async () => {
const { api, calls } = apiAdapter()
await api.saveDoclibBatchShares(['id/a', 'id/b'], ['u1', 'u2'])
assert.equal(calls[0].method, 'post')
assert.equal(calls[0].config.url, '/web/doclib/files/batch-shares')
assert.deepEqual(calls[0].config.data, { ids: ['id/a', 'id/b'], userIds: ['u1', 'u2'] })
})
@@ -95,6 +95,12 @@ await context.route('**/api/**', async (route) => {
return ok({ list, total: list.length })
}
if (path === '/web/doclib/users') return ok([{ id: 'other', name: '同事', deptName: '生产部' }])
if (path === '/web/doclib/files/batch-shares' && req.method() === 'POST') {
const body = req.postDataJSON()
sharedUsers = body.userIds
file.shares = sharedUsers.map((id) => ({ id, name: '同事', deptName: '生产部' }))
return ok([file])
}
if (path.endsWith('/shares')) {
sharedUsers = req.postDataJSON().userIds
file.shares = sharedUsers.map((id) => ({ id, name: '同事', deptName: '生产部' }))
+16 -8
View File
@@ -78,7 +78,7 @@
>
<el-button
:disabled="!actions.share || loading || busy"
@click="openShare(selected[0])"
@click="openShare(selected)"
><el-icon><Share /></el-icon>设置分享</el-button
>
<el-button
@@ -195,7 +195,7 @@
@download="download([$event])"
@view="openDetail"
/>
<ShareDialog v-model="shareVisible" :document="shareDocument" @saved="shareSaved" />
<ShareDialog v-model="shareVisible" :documents="shareDocuments" @saved="shareSaved" />
<el-dialog
v-model="folderVisible"
:title="renameMode ? '重命名文件夹' : '新建文件夹'"
@@ -489,14 +489,22 @@
}
}
const shareVisible = ref(false)
const shareDocument = ref<DoclibDocument | null>(null)
function openShare(row: DoclibDocument) {
if (!row?.canManage) return
shareDocument.value = row
const shareDocuments = ref<DoclibDocument[]>([])
function openShare(rows: DoclibDocument | DoclibDocument[] | null | undefined) {
const list = (Array.isArray(rows) ? rows : rows ? [rows] : []).filter(
(row): row is DoclibDocument => !!row?.id && row.canManage
)
if (!list.length) return
shareDocuments.value = list
shareVisible.value = true
}
function shareSaved(row: DoclibDocument) {
if (detail.value?.id === row.id) detail.value = row
function shareSaved(rows: DoclibDocument[]) {
const updated = Array.isArray(rows) ? rows : []
const current = detail.value
if (current) {
const match = updated.find((row) => row.id === current.id)
if (match) detail.value = match
}
void refresh()
}
const detailVisible = ref(false)
+22 -2
View File
@@ -14,7 +14,7 @@ const node = (id: string, scope: 'mine' | 'shared', children: any[] = []) => ({
children
})
test('分享接收人只能下载,混合选择不能批量删除', () => {
test('可管理文档可多选分享,混合选择不能分享', () => {
assert.deepEqual(actionsFor([{ canManage: false }]), {
share: false,
download: true,
@@ -25,10 +25,30 @@ test('分享接收人只能下载,混合选择不能批量删除', () => {
download: true,
delete: false
})
assert.deepEqual(actionsFor([{ canManage: true }]), { share: true, download: true, delete: true })
assert.deepEqual(actionsFor([{ canManage: true }]), {
share: true,
download: true,
delete: true
})
assert.deepEqual(actionsFor([{ canManage: true }, { canManage: true }]), {
share: true,
download: true,
delete: true
})
assert.deepEqual(actionsFor([]), { share: false, download: false, delete: false })
})
test('分享人初始勾选取所选文档交集', async () => {
const { shareUserIntersection } = await import('./model')
const docs = [
{ shares: [{ id: 'a' }, { id: 'b' }] },
{ shares: [{ id: 'b' }, { id: 'c' }] }
]
assert.deepEqual(shareUserIntersection(docs), ['b'])
assert.deepEqual(shareUserIntersection([{ shares: [{ id: 'a' }] }, { shares: [] }]), [])
assert.deepEqual(shareUserIntersection([]), [])
})
test('同一个目录在不同范围有独立树节点,已删除目录不再命中', () => {
const mine = node('root', 'mine', [node('child', 'mine')])
const shared = node('root', 'shared')
+18 -1
View File
@@ -2,12 +2,29 @@ import type { DoclibFolder } from '@/api/doclib'
export function actionsFor(rows: Array<{ canManage: boolean }>) {
return {
share: rows.length === 1 && rows[0].canManage,
share: rows.length > 0 && rows.every((row) => row.canManage),
download: rows.length > 0,
delete: rows.length > 0 && rows.every((row) => row.canManage)
}
}
/** 多选打开分享弹窗时,初始勾选为各文档现有分享人的交集。 */
export function shareUserIntersection(
documents: Array<{ shares?: Array<{ id: string }> | null }>
): string[] {
if (!documents.length) return []
let ids = new Set(
(documents[0].shares || []).map((person) => String(person.id || '').trim()).filter(Boolean)
)
for (const doc of documents.slice(1)) {
const next = new Set(
(doc.shares || []).map((person) => String(person.id || '').trim()).filter(Boolean)
)
ids = new Set([...ids].filter((id) => next.has(id)))
}
return [...ids]
}
export function findFolder(nodes: DoclibFolder[], id: string): DoclibFolder | undefined {
for (const node of nodes) {
if (node.id === id) return node
@@ -16,8 +16,8 @@
<div class="mp-wv-card__no">{{ row.certNo }}</div>
<span
class="status-tag mp-cert-kind-tag"
:class="row.certType === 'transport' ? 'status-tag--warning' : 'status-tag--primary'"
>{{ row.certType === 'transport' ? '运输证' : '购买证' }}</span
:class="`status-tag--${certKindMobileTone(row.certType)}`"
>{{ certKindLabel(row.certType) }}</span
>
</div>
<span class="status-tag" :class="`status-tag--${statusTag(row.status)}`">{{
@@ -109,7 +109,14 @@
import CertConfirmSheet from './CertConfirmSheet.vue'
import { formatDateTime } from '@/utils/format'
import { formatCertQty } from '@/views/pl/quota-cert/model'
import { buyPartyName, expiryWarning, salePartyName } from '@/views/pl/quota-cert/cert-list-face'
import {
buyPartyName,
certKindLabel,
certKindMobileTone,
compareCertUploadTime,
expiryWarning,
salePartyName
} from '@/views/pl/quota-cert/cert-list-face'
import { useDictStore } from '@/store/modules/dict'
import { getProductUnitLabel } from '@/utils/crm-product-display'
import { certStatusLabels } from './face-model'
@@ -209,9 +216,11 @@
const kinds: CertType[] = props.kind ? [props.kind as CertType] : ['purchase', 'transport']
const lists = await Promise.all(kinds.map((item) => quotaCertApi.list(item, params)))
if (current !== epoch) return
rows.value = kinds.flatMap((item, index) =>
lists[index].map((row) => ({ ...row, certType: row.certType || item }))
)
rows.value = kinds
.flatMap((item, index) =>
lists[index].map((row) => ({ ...row, certType: row.certType || item }))
)
.sort(compareCertUploadTime)
} catch {
if (current === epoch) error.value = '证件加载失败,请重试'
} finally {
@@ -41,7 +41,14 @@
计划数量 {{ qtyText(group.source.quantity) }} {{ unitText(group.source.unit) }}
</p>
<div v-for="row in group.rows" :key="conversionKey(row)" class="mp-cert-factor">
<strong>{{ row.cert.certType === 'purchase' ? '购买证' : '运输证' }} {{ row.cert.certNo }}</strong>
<strong class="mp-cert-kind-heading">
<span
class="status-tag mp-cert-kind-tag"
:class="`status-tag--${certKindMobileTone(row.cert.certType)}`"
>{{ certKindLabel(row.cert.certType) }}</span
>
<span>{{ row.cert.certNo }}</span>
</strong>
<van-field
v-model="coefficients[conversionKey(row)]"
label="换算系数"
@@ -91,7 +98,14 @@
<section v-if="isPerStop && faceCards.length" class="mp-wv-section">
<h3>关联证件证面</h3>
<article v-for="face in faceCards" :id="faceDomId(face.key)" :key="face.key" class="mp-cert-face-card">
<h4>{{ face.label }}</h4>
<h4 class="mp-cert-kind-heading">
<span
class="status-tag mp-cert-kind-tag"
:class="`status-tag--${certKindMobileTone(face.kind)}`"
>{{ certKindLabel(face.kind) }}</span
>
<span>{{ face.certNo }}</span>
</h4>
<p v-if="!face.cert" class="plan-empty-hint">没有读到这张证的证面档案。</p>
<template v-else>
<div v-for="(group, groupIndex) in groupsOf(face.cert, face.kind)" :key="groupIndex">
@@ -158,6 +172,7 @@
sourceLabel,
unitConversions
} from '@/components/business/pl-plan-quota-dialog/stop-model'
import { certKindLabel, certKindMobileTone } from '@/views/pl/quota-cert/cert-list-face'
import { formatCertQty } from '@/views/pl/quota-cert/model'
import { useDictStore } from '@/store/modules/dict'
import { getProductUnitLabel } from '@/utils/crm-product-display'
@@ -190,7 +205,13 @@
const sourceOptions = computed(() => plan.value?.sourceOptions || [])
const faceCards = computed(() => {
const seen = new Set<string>()
const tabs: { key: string; label: string; kind: CertType; cert: QuotaCert | null }[] = []
const tabs: {
key: string
label: string
kind: CertType
certNo: string
cert: QuotaCert | null
}[] = []
const push = (kind: string, id: string, certNo: string) => {
if ((kind !== 'purchase' && kind !== 'transport') || !id || seen.has(`${kind}:${id}`)) return
seen.add(`${kind}:${id}`)
@@ -200,7 +221,8 @@
key: `${kind}:${id}`,
kind,
cert,
label: `${kind === 'purchase' ? '购买证' : '运输证'} ${number}`
certNo: number,
label: `${certKindLabel(kind)} ${number}`
})
}
for (const row of plan.value?.stopProducts || []) {
@@ -60,7 +60,14 @@
</button>
</template>
</template>
<p v-else class="mp-cert-link-note">{{ currentLabel }}</p>
<p v-else class="mp-cert-kind-heading">
<span
class="status-tag mp-cert-kind-tag"
:class="`status-tag--${certKindMobileTone(certType)}`"
>{{ certKindLabel(certType) }}</span
>
<span v-if="certNo">{{ certNo }}</span>
</p>
<template v-if="certId">
<h3>适用产品</h3>
@@ -139,6 +146,7 @@
type QuotaCert,
type ScopeProduct
} from '@/api/pl/quota-cert'
import { certKindLabel, certKindMobileTone } from '@/views/pl/quota-cert/cert-list-face'
import { bindingPayload, replaceCertProducts } from '@/views/pl/quota-cert/model'
import { getProductUnitLabel } from '@/utils/crm-product-display'
import { useDictStore } from '@/store/modules/dict'
@@ -178,9 +186,6 @@
let epoch = 0
let searchTimer = 0
const currentLabel = computed(() =>
[certType.value === 'purchase' ? '购买证' : '运输证', certNo.value].filter(Boolean).join(' ')
)
const pickerRows = computed(() => {
const used = new Set(draft.value.map((product) => product.contractProductId))
return lines.value.filter((row) => row.id && !used.has(row.id))
@@ -15,9 +15,13 @@
@click="open(row)"
>
<div class="mp-wv-card__head">
<div>
<div class="mp-cert-card__main">
<div class="mp-wv-card__no">{{ row.certNo }}</div>
<div class="mp-wv-card__sub">{{ row.certType === 'transport' ? '运输证' : '购买证' }}</div>
<span
class="status-tag mp-cert-kind-tag"
:class="`status-tag--${certKindMobileTone(row.certType)}`"
>{{ certKindLabel(row.certType) }}</span
>
</div>
<span class="status-tag" :class="`status-tag--${row.status === 'active' ? 'success' : 'info'}`">
{{ certListStatus(row.status) }}
@@ -61,7 +65,11 @@
import { useDictStore } from '@/store/modules/dict'
import { getProductUnitLabel } from '@/utils/crm-product-display'
import { certListStatus } from './usage-model'
import { expiryWarning } from '@/views/pl/quota-cert/cert-list-face'
import {
certKindLabel,
certKindMobileTone,
expiryWarning
} from '@/views/pl/quota-cert/cert-list-face'
const props = defineProps<{ keyword: string; kind: string }>()
const router = useRouter()
@@ -512,6 +512,8 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { fetchCustomerOptions } from '@/api/crm/customer'
import { listContract } from '@/api/crm/contract'
import type { ContractVO } from '@/api/crm/contract/types'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
@@ -540,11 +542,14 @@
} from '@/views/pl/quota-cert/model'
import {
bindingsFromPurchaseCerts,
contractsForCustomer,
exactCustomerMatches,
matchPurchaseCert,
scopeProductsFromLines
} from '@/views/pl/quota-cert/contract-binder-rules'
import {
certArchiveContractListQuery,
filterAvailableBindingContracts
} from '@/views/pl/quota-cert/cert-archive-contract-query'
import { getProductUnitLabel } from '@/utils/crm-product-display'
import { useDictStore } from '@/store/modules/dict'
import { showCrmConfirmDialog } from '@/utils/mp-crm-confirm'
@@ -928,19 +933,39 @@
if (current === epoch) ElMessage.warning('客户匹配失败,请手工选择')
}
}
function toContractOption(row: ContractVO): ContractOption {
return {
id: String(row.id),
contractNo: row.contractNo || '',
contractName: row.contractName || '',
contractType: row.contractType,
customerId: row.customerId != null ? String(row.customerId) : '',
customerName: row.customerName,
status: row.status,
signDate: row.signDate || null
}
}
async function searchContracts(keyword = '') {
const current = epoch
const query = certArchiveContractListQuery(bindingCustomer.value.id, keyword)
if (!query) {
contractOptions.value = []
return
}
try {
const rows = await quotaCertApi.contracts(keyword)
const result = await listContract(query)
if (current !== epoch) return
const taken = new Set(
(form.value.contractBindings || []).map((row: CertificateContractBinding) => row.contractId)
const taken = (form.value.contractBindings || []).map(
(row: CertificateContractBinding) => row.contractId
)
contractOptions.value = contractsForCustomer(rows, bindingCustomer.value).filter(
(row) => !taken.has(row.id)
contractOptions.value = filterAvailableBindingContracts(result.rows || [], taken).map(
toContractOption
)
} catch {
if (current === epoch) contractOptions.value = []
if (current === epoch) {
contractOptions.value = []
ElMessage.warning('合同加载失败,请重试')
}
}
}
function openContracts() {
@@ -37,6 +37,24 @@
min-height: calc(100dvh - 132px);
}
.mp-cert-kind-tag {
display: inline-flex;
flex: 0 0 auto;
align-self: flex-start;
width: fit-content;
max-width: 100%;
white-space: nowrap;
vertical-align: middle;
}
.mp-cert-kind-heading {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: flex-start;
}
.mp-plan-cert-list .mp-wv-flat-card {
.mp-cert-card__main {
display: flex;
@@ -46,10 +64,7 @@
}
.mp-cert-kind-tag {
width: fit-content;
max-width: 100%;
margin-top: 4px;
white-space: nowrap;
}
.mp-wv-card__no {
@@ -48,7 +48,11 @@
</div>
</div>
<div class="mp-cert-tags">
<span class="status-tag status-tag--info">{{ cert.certType === 'purchase' ? '购买证' : '运输证' }}</span>
<span
class="status-tag mp-cert-kind-tag"
:class="`status-tag--${certKindMobileTone(cert.certType)}`"
>{{ certKindLabel(cert.certType) }}</span
>
<span class="status-tag" :class="`status-tag--${cert.linkStatus === 'active' ? 'success' : 'info'}`">
{{ linkLabel(cert.linkStatus) }}
</span>
@@ -145,7 +149,14 @@
<div class="mp-wv-card__kv">
<div class="mp-wv-card__kv-row"><span>规格</span><span>{{ row.model }}</span></div>
<div class="mp-wv-card__kv-row"><span>产品单位</span><span>{{ unit(row.unit) || '—' }}</span></div>
<div class="mp-wv-card__kv-row"><span>证件类型</span><span>{{ row.certKind }}</span></div>
<div class="mp-wv-card__kv-row">
<span>证件类型</span>
<span
class="status-tag mp-cert-kind-tag"
:class="`status-tag--${certKindMobileTone(row.certType)}`"
>{{ row.certKind }}</span
>
</div>
<div class="mp-wv-card__kv-row"><span>证件编号</span><span>{{ row.certNo }}</span></div>
<div class="mp-wv-card__kv-row"><span>证面单位</span><span>{{ unit(row.certUnit) || '—' }}</span></div>
<div class="mp-wv-card__kv-row"><span>产品预占</span><span>{{ qty(row.reservedQty) }}</span></div>
@@ -188,7 +199,11 @@
type StatisticsProduct
} from '@/api/pl/quota-cert'
import { bindingPayload, formatCertQty } from '@/views/pl/quota-cert/model'
import { certFaceFields } from '@/views/pl/quota-cert/cert-list-face'
import {
certFaceFields,
certKindLabel,
certKindMobileTone
} from '@/views/pl/quota-cert/cert-list-face'
import { contractTypeLabel } from '@/views/pl/quota-statistics/labels'
import { showCrmConfirmDialog } from '@/utils/mp-crm-confirm'
import CertLinkSheet from './CertLinkSheet.vue'
@@ -1,5 +1,5 @@
import type { ContractStatistics } from '@/api/pl/quota-cert'
import { certFaceFields, expiryWarning } from '@/views/pl/quota-cert/cert-list-face'
import { certFaceFields, certKindLabel, expiryWarning } from '@/views/pl/quota-cert/cert-list-face'
import { statusLabel } from '@/views/pl/quota-statistics/labels'
export function certDetailStatus(status?: string | null) {
@@ -19,7 +19,8 @@ export function contractProductRows(detail: ContractStatistics) {
productName: product.productName || '—',
model: product.model || '—',
unit: product.unit,
certKind: cert.certType === 'purchase' ? '购买证' : '运输证',
certType: cert.certType,
certKind: certKindLabel(cert.certType),
certNo: cert.certNo || '—',
certUnit: cert.quantityUnit,
reservedQty: product.reservedQty,
@@ -9,7 +9,14 @@
<div v-else-if="loading" class="mp-cert-page-loading"><van-loading /></div>
<template v-else-if="detail">
<section class="mp-wv-section">
<h3>{{ detail.certType === 'transport' ? '运输证' : '购买证' }} {{ detail.certNo }}</h3>
<h3 class="mp-cert-kind-heading">
<span
class="status-tag mp-cert-kind-tag"
:class="`status-tag--${certKindMobileTone(detail.certType)}`"
>{{ certKindLabel(detail.certType) }}</span
>
<span>{{ detail.certNo }}</span>
</h3>
<div class="detail-field-grid">
<div class="mp-wv-kv"><span>证面品名</span><span>{{ detail.productNameOnCert || '—' }}</span></div>
<div class="mp-wv-kv">
@@ -101,6 +108,7 @@
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
import { quotaStatisticsApi, type CertificateStatistics, type CertType } from '@/api/pl/quota-cert'
import { certKindLabel, certKindMobileTone } from '@/views/pl/quota-cert/cert-list-face'
import { formatCertQty } from '@/views/pl/quota-cert/model'
import { useDictStore } from '@/store/modules/dict'
import { getProductUnitLabel } from '@/utils/crm-product-display'
@@ -116,6 +116,13 @@
:readonly="c.props.readonly"
@update:model-value="c.patchDelivery(index, { notes: $event })"
/>
<van-cell class="mp-stop-toggle" title="是否同市" label="同市只需购买证"
><template #right-icon
><van-switch
:model-value="delivery.sameCity === 1"
:disabled="c.props.readonly"
@update:model-value="c.patchDelivery(index, { sameCity: $event ? 1 : 0 })" /></template
></van-cell>
<van-cell
class="mp-stop-toggle"
title="生成地磅单"
@@ -61,6 +61,13 @@
:readonly="c.props.readonly"
@update:model-value="c.patch({ notes: $event })"
/>
<van-cell class="mp-stop-toggle" title="是否同市" label="同市只需购买证"
><template #right-icon
><van-switch
:model-value="c.props.modelValue.sameCity === 1"
:disabled="c.props.readonly"
@update:model-value="c.patch({ sameCity: $event ? 1 : 0 })" /></template
></van-cell>
<van-cell class="mp-stop-toggle" title="生成地磅单" :label="c.dibangDirectionLabel"
><template #right-icon
><van-switch
@@ -0,0 +1,135 @@
import type { ColumnFilterDef } from '@/composables/useClientColumnFilters'
const RECHARGE_TYPE_OPTIONS = [{ value: 'etc', label: 'ETC' }]
const RECHARGE_STATUS_OPTIONS = [
{ value: 'draft', label: '草稿' },
{ value: 'in_approval', label: '审批中' },
{ value: 'pending', label: '发放中' },
{ value: 'rejected', label: '已驳回' },
{ value: 'disbursed', label: '已发放' },
{ value: 'cancelled', label: '已作废' }
]
/** ETC 申请列表列头漏斗(服务端)。 */
export const plVehicleRechargeApplyColumnFilterDefs: ColumnFilterDef[] = [
{ prop: 'applyNo', label: '申请单号', type: 'text', queryKey: 'applyNo' },
{ prop: 'licensePlate', label: '车牌号', type: 'text', queryKey: 'licensePlate' },
{
prop: 'rechargeType',
label: '充值类型',
type: 'enum',
queryKey: 'rechargeType',
enumOptions: RECHARGE_TYPE_OPTIONS
},
{
prop: 'applyDate',
label: '申请日期',
type: 'datetime',
queryKeyFrom: 'applyDateFrom',
queryKeyTo: 'applyDateTo'
},
{ prop: 'odometerKm', label: '期初公里数', type: 'number', queryKeyPrefix: 'odometerKm' },
{ prop: 'applicantName', label: '申请人', type: 'text', queryKey: 'applicantName' },
{ prop: 'remark', label: '备注', type: 'text', queryKey: 'remark' },
{
prop: 'status',
label: '状态',
type: 'enum',
queryKey: 'status',
enumOptions: RECHARGE_STATUS_OPTIONS
},
{
prop: 'createTime',
label: '创建时间',
type: 'datetime',
queryKeyFrom: 'createTimeFrom',
queryKeyTo: 'createTimeTo'
}
]
/** ETC 审批列表列头漏斗(服务端;在申请 defs 基础上增加当前节点)。 */
export const plVehicleRechargeApprovalColumnFilterDefs: ColumnFilterDef[] = [
...plVehicleRechargeApplyColumnFilterDefs.filter((d) => d.prop !== 'createTime'),
{ prop: 'currentNodeName', label: '当前节点', type: 'text', queryKey: 'currentNodeName' },
{
prop: 'createTime',
label: '创建时间',
type: 'datetime',
queryKeyFrom: 'createTimeFrom',
queryKeyTo: 'createTimeTo'
}
]
/** ETC 发放看板列头漏斗(服务端)。 */
export const plVehicleRechargeDisburseColumnFilterDefs: ColumnFilterDef[] = [
{ prop: 'applyNo', label: '申请单号', type: 'text', queryKey: 'applyNo' },
{
prop: 'applyDate',
label: '申请日期',
type: 'datetime',
queryKeyFrom: 'applyDateFrom',
queryKeyTo: 'applyDateTo'
},
{ prop: 'applicantName', label: '申请人', type: 'text', queryKey: 'applicantName' },
{ prop: 'odometerKm', label: '期初公里数', type: 'number', queryKeyPrefix: 'odometerKm' },
{
prop: 'applyStatus',
label: '申请状态',
type: 'enum',
queryKey: 'applyStatus',
enumOptions: RECHARGE_STATUS_OPTIONS
},
{ prop: 'licensePlate', label: '车牌号', type: 'text', queryKey: 'licensePlate' },
{
prop: 'rechargeType',
label: '充值类型',
type: 'enum',
queryKey: 'rechargeType',
enumOptions: RECHARGE_TYPE_OPTIONS
},
{ prop: 'rechargeOrderNo', label: '充值订单号', type: 'text', queryKey: 'rechargeOrderNo' },
{
prop: 'rechargeDate',
label: '充值日期',
type: 'datetime',
queryKeyFrom: 'rechargeDateFrom',
queryKeyTo: 'rechargeDateTo'
},
{ prop: 'cardNo', label: '卡号', type: 'text', queryKey: 'cardNo' },
{ prop: 'gasStation', label: '加油站/渠道', type: 'text', queryKey: 'gasStation' },
{ prop: 'amount', label: '金额(元)', type: 'number', queryKeyPrefix: 'amount' },
{
prop: 'recordCreateByName',
label: '登记人',
type: 'text',
queryKey: 'recordCreateByName'
}
]
/** ETC 审批人设置(客户端)。与油卡共用同一套节点筛选定义。 */
export const plVehicleRechargeWorkflowColumnFilterDefs: ColumnFilterDef[] = [
{ prop: 'nodeName', label: '节点名称', type: 'text' },
{
prop: 'actionType',
label: '节点动作',
type: 'enum',
enumOptions: [
{ value: 'approve', label: '审批' },
{ value: 'disburse', label: '发放登记' }
],
getValue: (row) => row.actionType || row.action_type || 'approve'
},
{ prop: 'nodeOrder', label: '节点顺序', type: 'number' },
{ prop: 'approverName', label: '审批人', type: 'text' },
{
prop: 'configStatus',
label: '配置状态',
type: 'enum',
enumOptions: [
{ value: 'configured', label: '已配置' },
{ value: 'unconfigured', label: '未配置' }
],
getValue: (row) => ((row.approverId ?? row.approver_id) ? 'configured' : 'unconfigured')
}
]
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,835 @@
<!-- ETC充值审批(待办/已办) -->
<template>
<div class="page-container">
<section class="panel">
<PlPlanTypeTabs
:model-value="activeTab"
:tabs="APPROVAL_TABS"
@change="handleTabChange"
/>
<div class="list-toolbar">
<div class="list-toolbar-row">
<span class="list-toolbar-selected">已选 {{ selectedRows.length }} 项</span>
<el-button
v-if="hasActiveFilters"
class="btn-ghost"
@click="clearAllFilters"
>
<el-icon><Filter /></el-icon>清空列过滤
</el-button>
<el-button
v-if="activeTab === 'pending'"
class="btn-main"
type="primary"
:disabled="!canApproveSelected"
@click="openHandleDialog(selectedRows[0])"
>
<el-icon><Check /></el-icon>处理
</el-button>
<el-button class="btn-ghost" :disabled="selectedRows.length !== 1" @click="openView(selectedRows[0])">
<el-icon><View /></el-icon>查看
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="fetchData" />
</div>
</div>
<div class="list-body list-wrap">
<ListTable
table-key="pl-etc-vehicle-recharge-approval"
class="list-table"
:data="tableData"
v-loading="loading"
stripe
@selection-change="(rows) => (selectedRows = rows)"
@row-dblclick="openView"
>
<el-table-column type="selection" width="48" align="center" />
<el-table-column type="index" label="序号" width="68" align="center" />
<el-table-column prop="applyNo" label="申请单号" min-width="160" show-overflow-tooltip>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">申请单号</span>
<ColumnHeaderFilter
prop="applyNo"
label="申请单号"
type="text"
:is-active="isFilterActive('applyNo')"
:model-value="filters.applyNo || null"
@update:model-value="(v) => setFilterState('applyNo', v)"
/>
</div>
</template>
<template #default="{ row }">
<el-link type="primary" @click="openView(row)">{{ row.applyNo }}</el-link>
</template>
</el-table-column>
<el-table-column prop="licensePlate" label="车牌号" min-width="160" show-overflow-tooltip align="center">
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">车牌号</span>
<ColumnHeaderFilter
prop="licensePlate"
label="车牌号"
type="text"
:is-active="isFilterActive('licensePlate')"
:model-value="filters.licensePlate || null"
@update:model-value="(v) => setFilterState('licensePlate', v)"
/>
</div>
</template>
<template #default="{ row }">
{{ formatPlVehiclePlateDisplay(row.licensePlate, undefined, vehicleTrailerLookup) }}
</template>
</el-table-column>
<el-table-column prop="rechargeType" label="充值类型" min-width="100" align="center">
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">充值类型</span>
<ColumnHeaderFilter
prop="rechargeType"
label="充值类型"
type="enum"
:enum-options="enumOptionsFor('rechargeType')"
:is-active="isFilterActive('rechargeType')"
:model-value="filters.rechargeType || null"
@update:model-value="(v) => setFilterState('rechargeType', v)"
/>
</div>
</template>
<template #default="{ row }">
<el-tag :type="getPlRechargeTypeTagType(row.rechargeType)" size="small">
{{ getPlRechargeTypeLabel(row.rechargeType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="applyDate" label="申请日期" min-width="120" align="center">
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">申请日期</span>
<ColumnHeaderFilter
prop="applyDate"
label="申请日期"
type="datetime"
:is-active="isFilterActive('applyDate')"
:model-value="filters.applyDate || null"
@update:model-value="(v) => setFilterState('applyDate', v)"
/>
</div>
</template>
</el-table-column>
<el-table-column prop="odometerKm" label="期初公里数" min-width="110" align="center">
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">期初公里数</span>
<ColumnHeaderFilter
prop="odometerKm"
label="期初公里数"
type="number"
:is-active="isFilterActive('odometerKm')"
:model-value="filters.odometerKm || null"
@update:model-value="(v) => setFilterState('odometerKm', v)"
/>
</div>
</template>
<template #default="{ row }">{{ row.odometerKm ?? '—' }}</template>
</el-table-column>
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center">
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">申请人</span>
<ColumnHeaderFilter
prop="applicantName"
label="申请人"
type="text"
:is-active="isFilterActive('applicantName')"
:model-value="filters.applicantName || null"
@update:model-value="(v) => setFilterState('applicantName', v)"
/>
</div>
</template>
</el-table-column>
<el-table-column prop="attachmentUrls" label="附件" min-width="220" align="left" header-align="left">
<template #default="{ row }">
<PlPlanDocumentAttachmentCell
:attachments="toRechargeAttachmentItems(row.attachmentUrls)"
/>
</template>
</el-table-column>
<el-table-column
prop="remark"
label="备注"
min-width="160"
align="center"
show-overflow-tooltip
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">备注</span>
<ColumnHeaderFilter
prop="remark"
label="备注"
type="text"
:is-active="isFilterActive('remark')"
:model-value="filters.remark || null"
@update:model-value="(v) => setFilterState('remark', v)"
/>
</div>
</template>
</el-table-column>
<el-table-column prop="currentNodeName" label="当前节点" min-width="120" show-overflow-tooltip>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">当前节点</span>
<ColumnHeaderFilter
prop="currentNodeName"
label="当前节点"
type="text"
:is-active="isFilterActive('currentNodeName')"
:model-value="filters.currentNodeName || null"
@update:model-value="(v) => setFilterState('currentNodeName', v)"
/>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" min-width="100" align="center">
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">状态</span>
<ColumnHeaderFilter
prop="status"
label="状态"
type="enum"
:enum-options="enumOptionsFor('status')"
:is-active="isFilterActive('status')"
:model-value="filters.status || null"
@update:model-value="(v) => setFilterState('status', v)"
/>
</div>
</template>
<template #default="{ row }">
<el-tag :type="getPlRechargeApplyStatusTagType(row.status)" size="small">
{{ getPlRechargeApplyStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="170" align="center">
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">创建时间</span>
<ColumnHeaderFilter
prop="createTime"
label="创建时间"
type="datetime"
:is-active="isFilterActive('createTime')"
:model-value="filters.createTime || null"
@update:model-value="(v) => setFilterState('createTime', v)"
/>
</div>
</template>
<template #default="{ row }">{{ formatPlDateTime(row.createTime) }}</template>
</el-table-column>
</ListTable>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="pager.page"
v-model:page-size="pager.pageSize"
:total="pager.total"
layout="total, sizes, prev, pager, next"
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
</div>
</section>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewDetail?.applyNo || '申请详情'"
:sections="viewSections"
width="960px"
modal-class="order-module-dialog-mask"
append-to-body
>
<template v-if="activeTab === 'pending' && viewDetail" #hero-actions>
<el-button type="primary" @click="openHandleDialog(viewDetail)">
<el-icon><Check /></el-icon>处理
</el-button>
</template>
<template #approval-flow>
<BxApprovalFlowTabs
section-title="审批记录"
:nodes="viewApprovalFlowNodes"
:history="viewApprovalHistoryItems"
/>
</template>
<template #apply-attachments>
<AttachmentUploadField
:model-value="viewDetail?.attachmentUrls || ''"
disabled
compact
/>
</template>
<template #record-attachments>
<AttachmentUploadField
:model-value="viewDetail?.record?.attachmentUrls || ''"
disabled
compact
/>
</template>
</SystemViewDialog>
<el-dialog
v-model="handleVisible"
:title="handleDetail?.applyNo || '审批处理'"
width="1280px"
align-center
class="dialog-form-layout"
:close-on-click-modal="false"
destroy-on-close
>
<el-form label-position="top" class="dialog-form">
<!-- 上方:审批信息 -->
<div class="handle-section">
<div class="handle-section__title">审批信息</div>
<div class="form-grid-4">
<el-form-item label="当前节点">
<el-input :model-value="handleDetail?.currentNodeName || '—'" disabled />
</el-form-item>
<el-form-item label="节点动作">
<el-input :model-value="isDisburseNode ? '发放登记' : '审批'" disabled />
</el-form-item>
<template v-if="isDisburseNode">
<el-form-item label="充值日期" required>
<el-date-picker
v-model="disburseForm.rechargeDate"
type="date"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="充值金额" required>
<el-input-number
v-model="disburseForm.amount"
:min="0.01"
:precision="2"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="卡号">
<el-input v-model="disburseForm.cardNo" placeholder="卡号" />
</el-form-item>
<el-form-item label="加油站/渠道">
<el-select
v-model="disburseForm.gasStation"
allow-create
filterable
clearable
placeholder="请选择或输入"
style="width: 100%"
>
<el-option v-for="s in PL_GAS_STATION_PRESETS" :key="s" :label="s" :value="s" />
</el-select>
</el-form-item>
<el-form-item label="备注" class="form-col-span-half">
<el-input v-model="opinion" type="textarea" :rows="2" placeholder="选填;驳回时请填写原因" />
</el-form-item>
<el-form-item label="发放附件" class="form-col-span-2">
<AttachmentUploadField
ref="disburseAttachmentRef"
v-model="disburseForm.attachmentUrls"
folder="pl/vehicle-recharge/record"
accept="image/*,.pdf"
:limit="5"
/>
</el-form-item>
</template>
<el-form-item v-else label="备注" class="form-col-span-2">
<el-input
v-model="opinion"
type="textarea"
:rows="3"
placeholder="默认同意;驳回时请说明原因"
/>
</el-form-item>
</div>
</div>
<!-- 充值申请详情(只读信息元素,对齐查看弹窗) -->
<div class="handle-section">
<div class="handle-section__title">ETC充值申请详情</div>
<ViewKvGrid :fields="handleApplyDetailFields" :columns="4" />
<div class="handle-apply-attachments">
<div class="handle-apply-attachments__label">附件</div>
<AttachmentUploadField
:model-value="handleDetail?.attachmentUrls || ''"
disabled
compact
/>
</div>
</div>
<!-- 审批记录 -->
<div class="handle-section">
<BxApprovalFlowTabs
section-title="审批记录"
:nodes="handleApprovalFlowNodes"
:history="handleApprovalHistoryItems"
/>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="handleVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="danger" class="btn-ghost" :loading="submitting" @click="handleReject">
<el-icon><CloseBold /></el-icon>驳回
</el-button>
<el-button
v-if="!isDisburseNode"
type="primary"
class="btn-main"
:loading="submitting"
@click="handleApprove"
>
<el-icon><Check /></el-icon>通过
</el-button>
<el-button
v-else
type="primary"
class="btn-main"
:loading="submitting"
@click="handleDisburse"
>
<el-icon><Check /></el-icon>确认发放
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import mittBus from '@/utils/sys/mittBus'
import { computed, onMounted, reactive, ref } from 'vue'
import { Check, Close, CloseBold, Filter, View } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import ColumnHeaderFilter from '@/components/business/column-header-filter/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import PlPlanDocumentAttachmentCell from '@/components/business/pl-plan-document-attachment-cell/index.vue'
import PlPlanTypeTabs from '@/components/business/pl-plan-type-tabs/index.vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import ViewKvGrid from '@/components/business/system-view-dialog/ViewKvGrid.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import BxApprovalFlowTabs from '@/components/bx/base/BxApprovalFlowTabs.vue'
import { useServerColumnFilters } from '@/composables/useServerColumnFilters'
import { plVehicleRechargeApprovalColumnFilterDefs } from '@/views/pl/etc-vehicle-recharge-apply/columnFilterDefs'
import {
vehicleApi,
vehicleRechargeApplyApi,
vehicleRechargeApprovalApi,
type PlanDocumentAttachmentItem,
type Vehicle,
type VehicleRechargeApply
} from '@/api/pl'
import { pickBxPageResult } from '@/utils/http/bx'
import { fetchAllPagedRecords } from '@/utils/fetch-paged-list'
import {
buildVehicleTrailerLookup,
formatPlDateTime,
formatPlVehiclePlateDisplay,
getPlRechargeApplyStatusLabel,
getPlRechargeApplyStatusTagType,
getPlRechargeTypeLabel,
getPlRechargeTypeTagType,
mapVehicleRechargeApprovalFlowToNodes,
PL_GAS_STATION_PRESETS
} from '@/utils/pl-display'
import { refreshMenuBadges } from '@/utils/menu-badge-registry'
import { useMenuStore } from '@/store/modules/menu'
const {
filters,
hasActiveFilters,
setFilterState,
clearAllFilters,
isFilterActive,
toQueryParams
} = useServerColumnFilters(plVehicleRechargeApprovalColumnFilterDefs, {
onChange: () => {
pager.page = 1
fetchData()
}
})
function enumOptionsFor(prop: string) {
const def = plVehicleRechargeApprovalColumnFilterDefs.find((d) => d.prop === prop)
return (def?.enumOptions || []).map((o) => ({ ...o, count: 0 }))
}
const APPROVAL_TABS = [
{ label: '待我审批', name: 'pending' },
{ label: '我已办', name: 'done' }
]
const menuStore = useMenuStore()
async function refreshListAndBadges() {
await fetchData()
void refreshMenuBadges(menuStore.menuList)
}
const DEFAULT_APPROVAL_OPINION = '同意'
const loading = ref(false)
const submitting = ref(false)
const activeTab = ref<'pending' | 'done'>('pending')
const tableData = ref<VehicleRechargeApply[]>([])
const selectedRows = ref<VehicleRechargeApply[]>([])
const pager = reactive({ page: 1, pageSize: 20, total: 0 })
const vehicleOptions = ref<Vehicle[]>([])
const vehicleTrailerLookup = computed(() => buildVehicleTrailerLookup(vehicleOptions.value))
const viewVisible = ref(false)
const viewDetail = ref<VehicleRechargeApply | null>(null)
const handleVisible = ref(false)
const handleDetail = ref<VehicleRechargeApply | null>(null)
let viewRequestId = 0
let handleRequestId = 0
watch(viewVisible, (visible) => {
if (!visible) viewRequestId += 1
}, { flush: 'sync' })
watch(handleVisible, (visible) => {
if (!visible) handleRequestId += 1
}, { flush: 'sync' })
const opinion = ref(DEFAULT_APPROVAL_OPINION)
const disburseForm = reactive({
rechargeDate: '',
amount: null as number | null,
cardNo: '',
gasStation: '',
attachmentUrls: ''
})
const disburseAttachmentRef = ref<InstanceType<typeof AttachmentUploadField>>()
const canApproveSelected = computed(
() => activeTab.value === 'pending' && selectedRows.value.length === 1
)
const isDisburseNode = computed(
() => (handleDetail.value?.currentNodeActionType || 'approve') === 'disburse'
)
const viewApprovalFlowNodes = computed(() =>
mapVehicleRechargeApprovalFlowToNodes(viewDetail.value?.approvalFlow || [])
)
const viewApprovalHistoryItems = computed(() =>
mapVehicleRechargeApprovalFlowToNodes(viewDetail.value?.approvalHistory || [], {
withRoundPrefix: true
})
)
const handleApprovalFlowNodes = computed(() =>
mapVehicleRechargeApprovalFlowToNodes(handleDetail.value?.approvalFlow || [])
)
const handleApprovalHistoryItems = computed(() =>
mapVehicleRechargeApprovalFlowToNodes(handleDetail.value?.approvalHistory || [], {
withRoundPrefix: true
})
)
const handleApplyDetailFields = computed((): ViewField[] => {
const d = handleDetail.value
if (!d) return []
return [
{ label: '申请单号', value: d.applyNo },
{ label: '申请人', value: d.applicantName || '—' },
{
label: '车牌号',
value: formatPlVehiclePlateDisplay(d.licensePlate, undefined, vehicleTrailerLookup.value)
},
{
label: '充值类型',
value: getPlRechargeTypeLabel(d.rechargeType),
tagType: getPlRechargeTypeTagType(d.rechargeType)
},
{ label: '申请日期', value: d.applyDate },
{ label: '期初公里数', value: d.odometerKm != null ? String(d.odometerKm) : '—' },
{
label: '状态',
value: getPlRechargeApplyStatusLabel(d.status),
tagType: getPlRechargeApplyStatusTagType(d.status)
},
{ label: '创建时间', value: formatPlDateTime(d.createTime) },
{ label: '备注', value: d.remark || '—', span: true }
]
})
function toRechargeAttachmentItems(raw?: string | null): PlanDocumentAttachmentItem[] {
return String(raw || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map((url, index) => ({
url,
label: `附件${index + 1}`
}))
}
function resetOpinionForDetail(detail: VehicleRechargeApply | null) {
const disburse = (detail?.currentNodeActionType || 'approve') === 'disburse'
opinion.value = disburse ? '' : DEFAULT_APPROVAL_OPINION
}
const viewSections = computed(() => {
const d = viewDetail.value
if (!d) return []
const record = d.record
const sections: Array<Record<string, unknown>> = [
{
title: '申请信息',
fields: [
{ label: '申请单号', value: d.applyNo },
{
label: '车牌号',
value: formatPlVehiclePlateDisplay(d.licensePlate, undefined, vehicleTrailerLookup.value)
},
{
label: '充值类型',
value: getPlRechargeTypeLabel(d.rechargeType),
tagType: getPlRechargeTypeTagType(d.rechargeType)
},
{ label: '申请日期', value: d.applyDate },
{ label: '期初公里数', value: d.odometerKm != null ? String(d.odometerKm) : '—' },
{ label: '申请人', value: d.applicantName || '—' },
{
label: '状态',
value: getPlRechargeApplyStatusLabel(d.status),
tagType: getPlRechargeApplyStatusTagType(d.status)
},
{ label: '当前节点', value: d.currentNodeName || '—' },
{ label: '备注', value: d.remark || '—', span: 2 as const }
]
},
{ title: '申请附件', fields: [], slot: 'apply-attachments' },
{ title: '', fields: [], slot: 'approval-flow' }
]
if (record) {
sections.push({
title: '发放信息',
fields: [
{ label: '充值订单号', value: record.rechargeOrderNo || '—', long: false },
{ label: '充值日期', value: record.rechargeDate || '—' },
{
label: '充值金额',
value: record.amount != null ? `${Number(record.amount).toFixed(2)} 元` : '—'
},
{ label: '卡号', value: record.cardNo || '—', long: false, fullSpan: false },
{ label: '加油站/渠道', value: record.gasStation || '—' },
{ label: '发放人', value: record.createByName || '—' },
{ label: '备注', value: record.remark || '—', span: true }
],
slotTitle: '发放附件',
slot: 'record-attachments'
})
} else {
sections.push({
title: '发放信息',
fields: [{ label: '发放信息', value: '暂无', span: true }]
})
}
return sections
})
async function loadVehicleOptions() {
try {
vehicleOptions.value = await fetchAllPagedRecords<Vehicle>(async (page, pageSize) => {
const res = await vehicleApi.list({ page, pageSize })
return pickBxPageResult<Vehicle>(res)
})
} catch {
vehicleOptions.value = []
}
}
async function fetchData() {
loading.value = true
try {
const api = activeTab.value === 'pending' ? vehicleRechargeApprovalApi.pending : vehicleRechargeApprovalApi.done
const params: Record<string, any> = {
page: pager.page,
pageSize: pager.pageSize,
...toQueryParams()
}
delete params.rechargeTypeNe
delete params.rechargeTypeIn
params.rechargeType = 'etc'
const res = await api(params)
const data: any = (res as any)?.data ?? res
tableData.value = data?.items || []
pager.total = data?.total || 0
mittBus.emit('rechargeSummaryRefresh')
selectedRows.value = []
} finally {
loading.value = false
}
}
function handleTabChange(name: string) {
activeTab.value = name === 'done' ? 'done' : 'pending'
pager.page = 1
fetchData()
}
async function openView(row: VehicleRechargeApply) {
const requestId = ++viewRequestId
viewDetail.value = row
viewVisible.value = true
try {
const res = await vehicleRechargeApplyApi.get(row.id)
if (requestId === viewRequestId && viewVisible.value) viewDetail.value = (res as any)?.data ?? res
} catch {
// keep list row as fallback
}
}
async function openHandleDialog(row: VehicleRechargeApply) {
const requestId = ++handleRequestId
handleDetail.value = row
resetOpinionForDetail(row)
disburseForm.rechargeDate = new Date().toISOString().slice(0, 10)
disburseForm.amount = null
disburseForm.cardNo = ''
disburseForm.gasStation = ''
disburseForm.attachmentUrls = ''
handleVisible.value = true
try {
const res = await vehicleRechargeApplyApi.get(row.id)
if (requestId !== handleRequestId || !handleVisible.value) return
handleDetail.value = (res as any)?.data ?? res
resetOpinionForDetail(handleDetail.value)
} catch {
// keep list row as fallback
}
}
async function handleApprove() {
if (!handleDetail.value) return
submitting.value = true
try {
await vehicleRechargeApprovalApi.approve(handleDetail.value.id, {
opinion: opinion.value.trim() || DEFAULT_APPROVAL_OPINION
})
ElMessage.success('已通过')
handleVisible.value = false
viewVisible.value = false
await refreshListAndBadges()
} finally {
submitting.value = false
}
}
async function handleReject() {
if (!handleDetail.value) return
const text = opinion.value.trim()
if (!text || text === DEFAULT_APPROVAL_OPINION) {
ElMessage.warning('驳回时请填写备注')
return
}
await ElMessageBox.confirm('确定驳回该申请吗?', '提示', { type: 'warning' })
submitting.value = true
try {
await vehicleRechargeApprovalApi.reject(handleDetail.value.id, { opinion: text })
ElMessage.success('已驳回')
handleVisible.value = false
viewVisible.value = false
await refreshListAndBadges()
} finally {
submitting.value = false
}
}
async function handleDisburse() {
if (!handleDetail.value) return
if (!disburseForm.rechargeDate || !disburseForm.amount || disburseForm.amount <= 0) {
ElMessage.warning('请填写充值日期和金额')
return
}
if (disburseAttachmentRef.value?.hasUploading?.()) {
ElMessage.warning('附件上传中,请稍候')
return
}
submitting.value = true
try {
await vehicleRechargeApprovalApi.disburse(handleDetail.value.id, {
applyId: handleDetail.value.id,
licensePlate: handleDetail.value.licensePlate,
rechargeType: 'etc',
rechargeDate: disburseForm.rechargeDate,
amount: disburseForm.amount,
cardNo: disburseForm.cardNo || undefined,
gasStation: disburseForm.gasStation || undefined,
attachmentUrls: disburseForm.attachmentUrls || undefined
})
ElMessage.success('发放登记成功')
handleVisible.value = false
viewVisible.value = false
await refreshListAndBadges()
} finally {
submitting.value = false
}
}
onMounted(() => {
loadVehicleOptions()
fetchData()
})
</script>
<style scoped lang="scss">
.handle-section {
& + & {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--mint-border, #e2e8f0);
}
&__title {
margin-bottom: 12px;
font-size: 14px;
font-weight: 600;
color: var(--mint-text, #1e293b);
line-height: 1.4;
&::before {
content: '';
display: inline-block;
width: 3px;
height: 14px;
margin-right: 8px;
vertical-align: -2px;
border-radius: 2px;
background: var(--primary, #10b981);
}
}
}
.handle-apply-attachments {
margin-top: 14px;
&__label {
margin-bottom: 6px;
font-size: 13px;
font-weight: 500;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
}
}
/* 四列栅格中占两列(如:加油站右侧的备注) */
.form-grid-4 > .form-col-span-half {
grid-column: span 2;
}
</style>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,663 @@
<!-- ETC充值审批人设置 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<section class="panel">
<div class="wf-info-bar">
<span><b>流程名称:</b>{{ wfDef.wfName || '-' }}</span>
<span>
<b>状态:</b>
<el-tag :type="statusTagType(wfDef.status)" size="small">
{{ statusLabel(wfDef.status) }}
</el-tag>
</span>
<span class="form-tip">ETC 与油卡共用同一套审批人配置</span>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="节点名称/审批人"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<el-button class="btn-main" type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>新增节点
</el-button>
<el-button
class="btn-ghost"
:disabled="selectedRows.length !== 1"
@click="handleToolbarEdit"
>
<el-icon><EditPen /></el-icon>编辑
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length !== 1 || !canDeleteByCreator(selectedRows[0])"
:title="selectedRows.length === 1 && !canDeleteByCreator(selectedRows[0]) ? CREATOR_DELETE_TIP : ''"
:loading="loadingMap.rowDelete"
@click="handleToolbarDelete"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button class="btn-ghost" :disabled="selectedRows.length !== 1" @click="handleToolbarMoveUp">
<el-icon><ArrowUp /></el-icon>上移
</el-button>
<el-button class="btn-ghost" :disabled="selectedRows.length !== 1" @click="handleToolbarMoveDown">
<el-icon><ArrowDown /></el-icon>下移
</el-button>
<el-button class="btn-ghost" :loading="loadingMap.save" @click="saveNodes">
<el-icon><Upload /></el-icon>保存配置
</el-button>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="loadWfDef"
@reset-columns="resetToDefault"
/>
</div>
</div>
<div class="list-body list-wrap">
<div class="list-table-viewport-anchor">
<ListTable
table-key="pl-etc-vehicle-recharge-workflow"
class="list-table"
:data="filteredNodes"
v-loading="loading"
stripe
@selection-change="onSelectionChange"
@row-dblclick="openEditDialog"
>
<el-table-column type="selection" width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" width="68" align="center" header-align="center" />
<el-table-column
prop="nodeName"
label="节点名称"
min-width="180"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">节点名称</span>
<ColumnHeaderFilter
prop="nodeName"
label="节点名称"
type="text"
:is-active="isFilterActive('nodeName')"
:model-value="filters.nodeName || null"
@update:model-value="(v) => setFilterState('nodeName', v)"
/>
</div>
</template>
</el-table-column>
<el-table-column
prop="actionType"
label="节点动作"
min-width="120"
align="center"
header-align="center"
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">节点动作</span>
<ColumnHeaderFilter
prop="actionType"
label="节点动作"
type="enum"
:enum-options="collectUniqueOptions('actionType')"
:is-active="isFilterActive('actionType')"
:model-value="filters.actionType || null"
@update:model-value="(v) => setFilterState('actionType', v)"
/>
</div>
</template>
<template #default="{ row }">
<el-tag
:type="(row.actionType || row.action_type || 'approve') === 'disburse' ? 'success' : 'primary'"
size="small"
>
{{ (row.actionType || row.action_type || 'approve') === 'disburse' ? '发放登记' : '审批' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('nodeOrder')"
prop="nodeOrder"
label="节点顺序"
min-width="100"
align="center"
header-align="center"
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">节点顺序</span>
<ColumnHeaderFilter
prop="nodeOrder"
label="节点顺序"
type="number"
:is-active="isFilterActive('nodeOrder')"
:model-value="filters.nodeOrder || null"
@update:model-value="(v) => setFilterState('nodeOrder', v)"
/>
</div>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('approverName')"
prop="approverName"
label="审批人"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">审批人</span>
<ColumnHeaderFilter
prop="approverName"
label="审批人"
type="text"
:is-active="isFilterActive('approverName')"
:model-value="filters.approverName || null"
@update:model-value="(v) => setFilterState('approverName', v)"
/>
</div>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('configStatus')"
prop="configStatus"
label="配置状态"
min-width="110"
align="center"
header-align="center"
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">配置状态</span>
<ColumnHeaderFilter
prop="configStatus"
label="配置状态"
type="enum"
:enum-options="collectUniqueOptions('configStatus')"
:is-active="isFilterActive('configStatus')"
:model-value="filters.configStatus || null"
@update:model-value="(v) => setFilterState('configStatus', v)"
/>
</div>
</template>
<template #default="{ row }">
<el-tag :type="(row.approverId ?? row.approver_id) ? 'success' : 'warning'" size="small">
{{ (row.approverId ?? row.approver_id) ? '已配置' : '未配置' }}
</el-tag>
</template>
</el-table-column>
</ListTable>
</div>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
</div>
</div>
</section>
<el-dialog
v-model="dialogs.addNode"
title="新增节点"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="addFormRef"
:model="formData"
:rules="formRules"
label-position="top"
class="bx-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="节点名称" prop="nodeName">
<el-input v-model="formData.nodeName" placeholder="请输入节点名称" />
</el-form-item>
<el-form-item label="节点顺序">
<el-input-number v-model="formData.nodeOrder" controls-position="right" :min="1" style="width: 100%" />
</el-form-item>
<el-form-item label="节点动作" prop="actionType">
<el-select v-model="formData.actionType" placeholder="请选择" style="width: 100%">
<el-option label="审批" value="approve" />
<el-option label="发放登记" value="disburse" />
</el-select>
</el-form-item>
<el-form-item label="审批人" style="grid-column: 1 / -1">
<el-select
v-model="formData.approverId"
placeholder="请选择审批人"
clearable
filterable
style="width: 100%"
@change="handleApproverChange"
>
<el-option v-for="u in userOptions" :key="u.id" :label="u.displayName" :value="u.id" />
</el-select>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogs.addNode = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="loadingMap.addNodeSubmit" @click="submitAddNode">
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="dialogs.editNode"
title="编辑节点"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="editFormRef"
:model="formData"
:rules="formRules"
label-position="top"
class="bx-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="节点名称" prop="nodeName">
<el-input v-model="formData.nodeName" placeholder="请输入节点名称" />
</el-form-item>
<el-form-item label="节点顺序">
<el-input-number v-model="formData.nodeOrder" controls-position="right" :min="1" style="width: 100%" />
</el-form-item>
<el-form-item label="节点动作" prop="actionType">
<el-select v-model="formData.actionType" placeholder="请选择" style="width: 100%">
<el-option label="审批" value="approve" />
<el-option label="发放登记" value="disburse" />
</el-select>
</el-form-item>
<el-form-item label="审批人" style="grid-column: 1 / -1">
<el-select
v-model="formData.approverId"
placeholder="请选择审批人"
clearable
filterable
style="width: 100%"
@change="handleApproverChange"
>
<el-option v-for="u in userOptions" :key="u.id" :label="u.displayName" :value="u.id" />
</el-select>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogs.editNode = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="loadingMap.editNodeSubmit" @click="submitEditNode">
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import ColumnHeaderFilter from '@/components/business/column-header-filter/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { useClientColumnFilters } from '@/composables/useClientColumnFilters'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import {
Plus,
EditPen,
Delete,
Close,
Check,
Upload,
ArrowUp,
ArrowDown
} from '@element-plus/icons-vue'
import { workflowApi } from '@/api/bx'
import { fetchUserOptions } from '@/api/admin'
import { statusLabel, statusTagType } from '@/utils/bx/format'
import { plVehicleRechargeWorkflowColumnFilterDefs } from '@/views/pl/etc-vehicle-recharge-apply/columnFilterDefs'
type DialogKey = 'addNode' | 'editNode'
const loadingMap = reactive<Record<string, boolean>>({})
const dialogs = reactive<Record<DialogKey, boolean>>({
addNode: false,
editNode: false
})
const quickSearchKeyword = ref('')
const appliedQuickSearch = ref('')
const listColumnDefs = [
{ prop: 'nodeOrder', label: '节点顺序' },
{ prop: 'approverName', label: '审批人' },
{ prop: 'configStatus', label: '配置状态' }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('pl-etc-vehicle-recharge-workflow-list-columns', listColumnDefs)
const loading = ref(false)
const selectedRows = ref<Record<string, any>[]>([])
const wfDef = ref<Record<string, any>>({})
const allNodes = computed(() => wfDef.value.nodes || [])
const quickFilteredNodes = computed(() => {
const kw = appliedQuickSearch.value.trim().toLowerCase()
if (!kw) return allNodes.value
return allNodes.value.filter(
(n: any) =>
String(n.nodeName || '')
.toLowerCase()
.includes(kw) ||
String(n.approverName || '')
.toLowerCase()
.includes(kw)
)
})
const {
filters,
filteredRows: filteredNodes,
setFilterState,
isFilterActive,
collectUniqueOptions
} = useClientColumnFilters(quickFilteredNodes, plVehicleRechargeWorkflowColumnFilterDefs)
const nodes = allNodes
const userOptions = ref<
{ id: string; realName: string; username: string; roles: string; displayName: string }[]
>([])
const formData = reactive({
nodeName: '',
nodeOrder: 1,
actionType: 'approve' as 'approve' | 'disburse',
approverId: null as string | null,
approverName: ''
})
const formRules = {
nodeName: [{ required: true, message: '节点名称不能为空', trigger: 'blur' }],
actionType: [{ required: true, message: '请选择节点动作', trigger: 'change' }]
}
const addFormRef = ref<FormInstance>()
const editFormRef = ref<FormInstance>()
async function loadUsers() {
try {
const res = await fetchUserOptions()
const data: any = (res as any)?.data ?? res ?? []
userOptions.value = (Array.isArray(data) ? data : []).map((u: any) => {
const name = u.label || u.nickName || ''
const account = u.userNo || ''
return {
id: String(u.value ?? u.id ?? ''),
realName: name,
username: account,
roles: '',
displayName: account ? `${name}(${account})` : name
}
})
} catch {
userOptions.value = []
}
}
async function loadWfDef() {
loading.value = true
try {
const res = await workflowApi.getByBizType('vehicle_recharge')
wfDef.value = res.data ?? {}
} catch {
wfDef.value = {}
} finally {
loading.value = false
}
}
onMounted(() => {
loadWfDef()
loadUsers()
})
function onSelectionChange(rows: Record<string, any>[]) {
selectedRows.value = rows
}
function handleQuickSearch() {
appliedQuickSearch.value = quickSearchKeyword.value.trim()
}
function handleQuickSearchClear() {
quickSearchKeyword.value = ''
appliedQuickSearch.value = ''
}
function handleToolbarEdit() {
if (selectedRows.value.length !== 1) {
ElMessage.warning('请先勾选一条节点')
return
}
openEditDialog(selectedRows.value[0])
}
function handleToolbarDelete() {
if (selectedRows.value.length !== 1) {
ElMessage.warning('请先勾选一条节点')
return
}
deleteNode(selectedRows.value[0])
}
function handleToolbarMoveUp() {
if (selectedRows.value.length !== 1) return
moveUp(selectedRows.value[0])
}
function handleToolbarMoveDown() {
if (selectedRows.value.length !== 1) return
moveDown(selectedRows.value[0])
}
function buildNodePayload(list: Record<string, any>[]) {
return list.map((n, i) => ({
id: n.id != null ? String(n.id) : undefined,
nodeName: n.nodeName,
nodeOrder: i + 1,
actionType: n.actionType ?? n.action_type ?? 'approve',
approverId: n.approverId ?? n.approver_id ?? null,
approverName: n.approverName ?? n.approver_name ?? ''
}))
}
async function saveNodes() {
loadingMap.save = true
try {
await workflowApi.saveNodes(wfDef.value.id, buildNodePayload(nodes.value))
ElMessage.success('保存成功')
await loadWfDef()
} catch (e: any) {
ElMessage.error(e.message || '保存失败')
} finally {
loadingMap.save = false
}
}
function openAddDialog() {
Object.assign(formData, {
nodeName: '',
nodeOrder: nodes.value.length + 1,
actionType: 'approve',
approverId: null,
approverName: ''
})
dialogs.addNode = true
}
function openEditDialog(node: Record<string, any>) {
Object.assign(formData, {
nodeName: node.nodeName,
nodeOrder: node.nodeOrder,
actionType: (node.actionType ?? node.action_type ?? 'approve') as 'approve' | 'disburse',
approverId: node.approverId ?? node.approver_id ?? null,
approverName: node.approverName ?? node.approver_name ?? ''
})
dialogs.editNode = true
}
function handleApproverChange(userId: string | null) {
if (userId == null) {
formData.approverName = ''
return
}
const found = userOptions.value.find((u) => u.id === userId)
formData.approverName = found ? found.realName : ''
}
async function submitAddNode() {
const valid = await addFormRef.value?.validate().catch(() => false)
if (!valid) return
loadingMap.addNodeSubmit = true
try {
const newNodes = [
...nodes.value,
{
nodeName: formData.nodeName,
nodeOrder: formData.nodeOrder,
actionType: formData.actionType,
approverId: formData.approverId,
approverName: formData.approverName
}
]
await workflowApi.saveNodes(wfDef.value.id, buildNodePayload(newNodes))
ElMessage.success('新增成功')
dialogs.addNode = false
await loadWfDef()
} catch (e: any) {
ElMessage.error(e.message || '新增失败')
} finally {
loadingMap.addNodeSubmit = false
}
}
async function submitEditNode() {
const valid = await editFormRef.value?.validate().catch(() => false)
if (!valid) return
loadingMap.editNodeSubmit = true
try {
const newNodes = nodes.value.map((n: Record<string, any>) =>
n.nodeOrder === formData.nodeOrder
? {
...n,
nodeName: formData.nodeName,
actionType: formData.actionType,
approverId: formData.approverId,
approverName: formData.approverName
}
: n
)
await workflowApi.saveNodes(wfDef.value.id, buildNodePayload(newNodes))
ElMessage.success('编辑成功')
dialogs.editNode = false
await loadWfDef()
} catch (e: any) {
ElMessage.error(e.message || '编辑失败')
} finally {
loadingMap.editNodeSubmit = false
}
}
async function deleteNode(node: Record<string, any>) {
if (!canDeleteByCreator(node)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
await ElMessageBox.confirm(`确定要删除节点「${node.nodeName}」吗?`, '删除确认', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning'
})
loadingMap.rowDelete = true
try {
const newNodes = nodes.value.filter((n: Record<string, any>) => n.id !== node.id)
await workflowApi.saveNodes(wfDef.value.id, buildNodePayload(newNodes))
ElMessage.success('删除成功')
await loadWfDef()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error(e.message || '删除失败')
} finally {
loadingMap.rowDelete = false
}
}
async function moveUp(node: Record<string, any>) {
const idx = nodes.value.findIndex((n: Record<string, any>) => n.id === node.id)
if (idx <= 0) return
const arr = [...nodes.value]
const temp = arr[idx]
arr[idx] = arr[idx - 1]
arr[idx - 1] = temp
arr.forEach((n: Record<string, any>, i: number) => (n.nodeOrder = i + 1))
try {
await workflowApi.saveNodes(wfDef.value.id, buildNodePayload(arr))
ElMessage.success('上移成功')
await loadWfDef()
} catch (e: any) {
ElMessage.error(e.message || '上移失败')
}
}
async function moveDown(node: Record<string, any>) {
const arr = [...nodes.value]
const idx = arr.findIndex((n: Record<string, any>) => n.id === node.id)
if (idx < 0 || idx >= arr.length - 1) return
const temp = arr[idx]
arr[idx] = arr[idx + 1]
arr[idx + 1] = temp
arr.forEach((n: Record<string, any>, i: number) => (n.nodeOrder = i + 1))
try {
await workflowApi.saveNodes(wfDef.value.id, buildNodePayload(arr))
ElMessage.success('下移成功')
await loadWfDef()
} catch (e: any) {
ElMessage.error(e.message || '下移失败')
}
}
</script>
@@ -219,6 +219,18 @@
min-width="160"
show-overflow-tooltip
/>
<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>
</ListTable>
</div>
<div ref="docReminderBelowRef">
@@ -230,6 +242,19 @@
</div>
</div>
</section>
<PlVehicleFormDialog
v-model="vehicleFormVisible"
:vehicle-type="vehicleFormType"
:vehicle-id="editingOwnerId"
@saved="handleOwnerFormSaved"
/>
<PlPersonnelFormDialog
v-model="personnelFormVisible"
:affiliation="personnelFormAffiliation"
:personnel-id="editingOwnerId"
@saved="handleOwnerFormSaved"
/>
</div>
</template>
@@ -238,8 +263,22 @@ import ListQuickSearchInput from '@/components/business/list-quick-search-input/
import ColumnHeaderFilter from '@/components/business/column-header-filter/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
import { CircleCheck, Filter } from '@element-plus/icons-vue'
import { inventoryApi, type DocumentReminder } from '@/api/pl'
import PlVehicleFormDialog from '@/components/business/pl-vehicle-form-dialog/index.vue'
import PlPersonnelFormDialog from '@/components/business/pl-personnel-form-dialog/index.vue'
import { CircleCheck, Filter, EditPen } from '@element-plus/icons-vue'
import {
inventoryApi,
vehicleApi,
personnelApi,
type DocumentReminder,
type VehicleDetail,
type PersonnelDetail
} from '@/api/pl'
import { pickBxData } from '@/utils/http/bx'
import {
resolveDocumentReminderFormMode,
DOCUMENT_REMINDER_JUMP_UNSUPPORTED
} from '@/utils/pl-document-reminder-jump'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { useServerColumnFilters } from '@/composables/useServerColumnFilters'
import { useColumnSort } from '@/composables/useColumnSort'
@@ -330,6 +369,13 @@ const { tableHeight, recalc: recalcTableHeight } = useListTableViewportHeight({
tableRef: docReminderTableRef
})
const maintainLoadingId = ref<string | null>(null)
const vehicleFormVisible = ref(false)
const personnelFormVisible = ref(false)
const vehicleFormType = ref<'self' | 'carrier'>('carrier')
const personnelFormAffiliation = ref<'self' | 'carrier'>('self')
const editingOwnerId = ref<string | null>(null)
const loading = ref(false)
const resolveLoading = ref(false)
const reminderList = ref<DocumentReminder[]>([])
@@ -445,6 +491,59 @@ function handleToolbarResolve() {
handleResolve(selectedRow.value)
}
function handleOwnerFormSaved() {
ElMessage.success('保存成功')
fetchData()
}
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 mode = null as ReturnType<typeof resolveDocumentReminderFormMode>
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 || '')
mode = resolveDocumentReminderFormMode({ 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 || ''
)
mode = resolveDocumentReminderFormMode({ ownerType, personnelAffiliation })
} else {
ElMessage.warning(DOCUMENT_REMINDER_JUMP_UNSUPPORTED)
return
}
if (!mode) {
ElMessage.warning(DOCUMENT_REMINDER_JUMP_UNSUPPORTED)
return
}
editingOwnerId.value = ownerId
vehicleFormVisible.value = false
personnelFormVisible.value = false
await nextTick()
if (mode.kind === 'vehicle') {
vehicleFormType.value = mode.vehicleType
vehicleFormVisible.value = true
} else {
personnelFormAffiliation.value = mode.affiliation
personnelFormVisible.value = true
}
} catch {
ElMessage.error('无法打开主体档案,请确认数据存在且有权限')
} finally {
maintainLoadingId.value = null
}
}
onMounted(() => {
fetchData()
nextTick(() => recalcTableHeight())
@@ -562,6 +562,7 @@ import { useColumnFacetOptions } from '@/composables/useColumnFacetOptions'
import { plPersonnelColumnFilterDefs } from '@/views/pl/personnel/columnFilterDefs'
import { useListTableViewportHeight } from '@/composables/useListTableViewportHeight'
import { useListMediaActive } from '@/composables/useListMediaActive'
import { useRouteEditIdOpen } from '@/composables/useRouteEditIdOpen'
import { pickBxPageResult } from '@/utils/http/bx'
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
import { formatDateTime } from '@/utils/format'
@@ -1172,6 +1173,10 @@ async function handleEdit(row: Personnel) {
}
}
useRouteEditIdOpen(async (id) => {
await handleEdit({ id } as Personnel)
})
async function handleSubmit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid || !validateCerts()) return
+12 -5
View File
@@ -86,7 +86,13 @@
header-align="center"
:index="getDisplayRowIndex"
/>
<el-table-column prop="planNo" label="计划编号" min-width="180" show-overflow-tooltip>
<el-table-column
v-if="isColumnVisible('planNo')"
prop="planNo"
label="计划编号"
min-width="180"
show-overflow-tooltip
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">计划编号</span>
@@ -1075,6 +1081,7 @@ const planList = ref<Plan[]>([])
const PRICE_COLUMN_PROPS = new Set(['unitPrice', 'amount'])
const allListColumnDefs = [
{ prop: 'planNo', label: '计划编号' },
{ prop: 'planDate', label: '计划日期' },
{ prop: 'deliveryTimes', label: '送货时间' },
{ prop: 'archiveStatus', label: '归档状态' },
@@ -1116,7 +1123,7 @@ const {
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('pl-plan-archive-list-columns-v16', allListColumnDefs)
} = usePersistedListColumns('pl-plan-archive-list-columns-v17', allListColumnDefs)
const {
filters,
@@ -1165,7 +1172,7 @@ function enumOptionsFor(prop: string) {
}
const visibleFilterProps = computed(() => {
const props: string[] = ['planNo']
const props: string[] = []
for (const def of listColumnDefs.value) {
if (isColumnVisible(def.prop)) props.push(def.prop)
}
@@ -1475,10 +1482,10 @@ const planArchiveExportColumns = computed(() => {
const field = PLAN_ARCHIVE_EXPORT_COLUMN_FIELD_MAP[col] || col
if (!cols.includes(field)) cols.push(field)
}
if (!cols.includes('planNo')) cols.unshift('planNo')
if (isColumnVisible('planNo') && !cols.includes('planNo')) cols.unshift('planNo')
const planNoIdx = cols.indexOf('planNo')
if (!cols.includes('stopSegment')) {
cols.splice(planNoIdx + 1, 0, 'stopSegment')
cols.splice((planNoIdx >= 0 ? planNoIdx : -1) + 1, 0, 'stopSegment')
}
if (!cols.includes('rowNo')) cols.unshift('rowNo')
const modelIdx = cols.indexOf('model')
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import {
certArchiveContractListQuery,
filterAvailableBindingContracts
} from './cert-archive-contract-query'
test('证件建档合同查询必须带客户 ID,不能只靠前端再过滤最近合同', () => {
assert.deepEqual(certArchiveContractListQuery('cust-1', ' HT-01 '), {
pageNum: 1,
pageSize: 50,
keyword: 'HT-01',
customerId: 'cust-1',
excludeStatus: 'invalid',
includeProducts: false
})
assert.equal(certArchiveContractListQuery(''), null)
assert.equal(certArchiveContractListQuery(' '), null)
assert.deepEqual(certArchiveContractListQuery('cust-2', ' ').keyword, undefined)
})
test('已关联合同与不可选状态不进入添加列表', () => {
const rows = [
{ id: 'a', status: 'approved' },
{ id: 'b', status: 'executing' },
{ id: 'c', status: 'draft' },
{ id: 'd', status: 'approved' },
{ id: '', status: 'approved' }
]
assert.deepEqual(
filterAvailableBindingContracts(rows, ['d', '']).map((item) => item.id),
['a', 'b']
)
})
@@ -0,0 +1,49 @@
/** 证件建档添加合同时的 CRM 列表查询与本地过滤。 */
export interface CertArchiveContractListQuery {
pageNum: number
pageSize: number
keyword?: string
customerId: string
excludeStatus: 'invalid'
includeProducts: false
}
/** 已选客户后按客户 ID 分页查询,避免只取最近合同再前端过滤。 */
export function certArchiveContractListQuery(
customerId: string,
keyword = ''
): CertArchiveContractListQuery | null {
const id = String(customerId || '').trim()
if (!id) return null
const text = keyword.trim()
return {
pageNum: 1,
pageSize: 50,
keyword: text || undefined,
customerId: id,
excludeStatus: 'invalid',
includeProducts: false
}
}
const DEFAULT_STATUSES = ['approved', 'executing']
/** 去掉已关联合同和不在可选状态内的行。 */
export function filterAvailableBindingContracts<T extends { id?: string | number; status?: string }>(
rows: T[],
takenIds: Iterable<string>,
selectableStatuses: string[] = DEFAULT_STATUSES
): T[] {
const taken = new Set(
[...takenIds].map((id) => String(id || '').trim()).filter(Boolean)
)
return rows.filter((row) => {
const id = String(row.id || '').trim()
if (!id || taken.has(id)) return false
if (selectableStatuses.length && !selectableStatuses.includes(String(row.status || ''))) {
return false
}
return true
})
}
@@ -34,6 +34,28 @@ export function certFaceFields(row: CertListFace) {
]
}
export function certKindLabel(certType?: string | null) {
return certType === 'transport' ? '运输证' : '购买证'
}
export function certKindTagType(certType?: string | null) {
return certType === 'transport' ? 'warning' : 'success'
}
export function certKindMobileTone(certType?: string | null) {
return certType === 'transport' ? 'warning' : 'primary'
}
export function compareCertUploadTime<T extends { createTime?: string | null; id?: string | null }>(
a: T,
b: T
) {
return (
String(b.createTime || '').localeCompare(String(a.createTime || '')) ||
String(b.id || '').localeCompare(String(a.id || ''))
)
}
export function expiryWarning(validTo?: string | null, today = new Date()) {
const target = calendarDate(validTo)
if (!target) return null
+11 -8
View File
@@ -66,8 +66,8 @@
><el-tag
v-else-if="column.prop === 'certType'"
size="small"
:type="row.certType === 'transport' ? 'warning' : 'success'"
>{{ row.certType === 'transport' ? '运输证' : '购买证' }}</el-tag
:type="certKindTagType(row.certType)"
>{{ certKindLabel(row.certType) }}</el-tag
><el-tag
v-else-if="column.prop === 'status'"
size="small"
@@ -629,7 +629,14 @@
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import CertContractBinder from './CertContractBinder.vue'
import { bindingsFromPurchaseCerts, matchPurchaseCert } from './contract-binder-rules'
import { buyPartyName, expiryWarning, salePartyName } from './cert-list-face'
import {
buyPartyName,
certKindLabel,
certKindTagType,
compareCertUploadTime,
expiryWarning,
salePartyName
} from './cert-list-face'
import ContractScopeDialog from './ContractScopeDialog.vue'
import SystemViewDialog, {
type ViewField,
@@ -1089,11 +1096,7 @@
rows.value = [
...purchases.map((row) => presentCert(row, 'purchase')),
...transports.map((row) => presentCert(row, 'transport'))
].sort(
(a, b) =>
String(b.createTime || '').localeCompare(String(a.createTime || '')) ||
String(b.id).localeCompare(String(a.id))
)
].sort(compareCertUploadTime)
} finally {
if (current === listEpoch && !disposed) loading.value = false
}
@@ -42,7 +42,10 @@
</el-select>
</el-form-item>
</el-form>
<p v-else class="quota-contract-cert-dialog__current">{{ currentCertLabel }}</p>
<p v-else class="quota-contract-cert-dialog__current">
<el-tag size="small" :type="certKindTagType(certType)">{{ certKindLabel(certType) }}</el-tag>
<span v-if="certNo">{{ certNo }}</span>
</p>
<CertContractBinder
v-if="ready && certId && contractId"
:key="binderKey"
@@ -68,6 +71,7 @@
type ScopeProduct
} from '@/api/pl/quota-cert'
import CertContractBinder from '../quota-cert/CertContractBinder.vue'
import { certKindLabel, certKindTagType } from '../quota-cert/cert-list-face'
import { bindingPayload, replaceCertProducts } from '../quota-cert/model'
const emit = defineEmits<{ saved: [] }>()
@@ -91,10 +95,6 @@
() =>
`${mode.value}|${certType.value}|${certId.value}|${savedProducts.value.map((row) => row.contractProductId).join(',')}`
)
const currentCertLabel = computed(() =>
[certType.value === 'purchase' ? '购买证' : '运输证', certNo.value].filter(Boolean).join(' ')
)
function certOptionLabel(item: QuotaCert) {
const name = [item.certNo, item.productNameOnCert].filter(Boolean).join(' · ')
if (item.status === 'void') return `${name}(已作废)`
@@ -209,6 +209,10 @@
<style scoped>
.quota-contract-cert-dialog__current {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
margin: 0 0 12px;
color: #1e293b;
font-weight: 600;
@@ -6,8 +6,15 @@
v-for="cert in contract?.certificates || []"
:key="certKey(cert)"
:name="certKey(cert)"
:label="certTabLabel(cert)"
>
<template #label>
<span class="quota-cert-tab-label">
<el-tag size="small" :type="certKindTagType(cert.certType)">{{
certKindLabel(cert.certType)
}}</el-tag>
<span>{{ cert.certNo }}</span>
</span>
</template>
<section class="quota-cert-summary">
<header class="quota-cert-summary__head">
<strong>{{ cert.certNo }}</strong>
@@ -16,8 +23,8 @@
有效期:{{ cert.validFrom || '—' }} 至 {{ cert.validTo || '—' }}
</span>
<div class="quota-cert-summary__tags">
<el-tag size="small">{{
cert.certType === 'purchase' ? '购买证' : '运输证'
<el-tag size="small" :type="certKindTagType(cert.certType)">{{
certKindLabel(cert.certType)
}}</el-tag>
<el-tag size="small" :type="cert.linkStatus === 'active' ? 'success' : 'info'">{{
linkLabel(cert.linkStatus)
@@ -90,7 +97,13 @@
<el-table-column prop="productName" label="产品" min-width="160" />
<el-table-column prop="model" label="规格" min-width="120" />
<el-table-column prop="unit" label="产品单位" min-width="100" />
<el-table-column prop="certKind" label="证件类型" min-width="100" />
<el-table-column label="证件类型" min-width="110">
<template #default="{ row }">
<el-tag size="small" :type="certKindTagType(row.certType)">{{
row.certKind
}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="certNo" label="证件编号" min-width="160" />
<el-table-column prop="certUnit" label="证面单位" min-width="100" />
<el-table-column label="产品预占" min-width="120"
@@ -230,7 +243,12 @@
import { useDictStore } from '@/store/modules/dict'
import { getProductUnitLabel } from '@/utils/crm-product-display'
import { formatCertQty } from '../quota-cert/model'
import { certFaceFields, expiryWarning } from '../quota-cert/cert-list-face'
import {
certFaceFields,
certKindLabel,
certKindTagType,
expiryWarning
} from '../quota-cert/cert-list-face'
import StatisticsProducts from './StatisticsProducts.vue'
import { statusLabel } from './labels'
const props = withDefaults(
@@ -252,10 +270,6 @@
function certKey(cert: { certType: string; certId: string }) {
return `${cert.certType}:${cert.certId}`
}
function certTabLabel(cert: ContractStatistics['certificates'][number]) {
const kind = cert.certType === 'purchase' ? '购买证' : '运输证'
return `${kind} ${cert.certNo || ''}`.trim()
}
watch(
() =>
props.mode === 'contract'
@@ -282,7 +296,8 @@
productName: product.productName || '—',
model: product.model || '—',
unit: unitLabel(product.unit) || '—',
certKind: cert.certType === 'purchase' ? '购买证' : '运输证',
certType: cert.certType,
certKind: certKindLabel(cert.certType),
certNo: cert.certNo || '—',
certUnit: unitLabel(cert.quantityUnit) || '—',
reservedQty: product.reservedQty,
@@ -475,4 +490,10 @@
}
}
}
.quota-cert-tab-label {
display: inline-flex;
gap: 6px;
align-items: center;
}
</style>
@@ -144,9 +144,11 @@
/> </div
></template>
<template #default="{ row }"
><span v-if="column.prop === 'certType'">{{
row.certType === 'transport' ? '运输证' : '购买证'
}}</span
><el-tag
v-if="column.prop === 'certType'"
size="small"
:type="certKindTagType(row.certType)"
>{{ certKindLabel(row.certType) }}</el-tag
><el-tag
v-else-if="column.prop === 'status'"
size="small"
@@ -205,7 +207,7 @@
import StatisticsDetail from './StatisticsDetail.vue'
import { createStatisticsLoader } from './loader'
import { statusLabel, contractTypeLabel } from './labels'
import { expiryWarning } from '../quota-cert/cert-list-face'
import { certKindLabel, certKindTagType, expiryWarning } from '../quota-cert/cert-list-face'
const props = withDefaults(
defineProps<{
mode: 'contract' | 'certificate'
@@ -556,6 +556,7 @@ import SystemViewDialog, {
} from '@/components/business/system-view-dialog/index.vue'
import { loadPersonnelWithCerts } from '@/composables/usePlVehicleViewDetail'
import { useListMediaActive } from '@/composables/useListMediaActive'
import { useRouteEditIdOpen } from '@/composables/useRouteEditIdOpen'
import { pickBxData, pickBxPageResult } from '@/utils/http/bx'
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
import { formatDateTime } from '@/utils/format'
@@ -1248,6 +1249,10 @@ async function handleEdit(row: Vehicle) {
}
}
useRouteEditIdOpen(async (id) => {
await handleEdit({ id } as Vehicle)
})
async function handleSubmit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid || !validateSubTables()) return
@@ -1,7 +1,6 @@
import type { ColumnFilterDef } from '@/composables/useClientColumnFilters'
const RECHARGE_TYPE_OPTIONS = [
{ value: 'etc', label: 'ETC' },
{ value: 'fuel', label: '油卡' },
{ value: 'shanxia', label: '山峡' },
{ value: 'bailian', label: '百链' }
@@ -10,12 +9,13 @@ const RECHARGE_TYPE_OPTIONS = [
const RECHARGE_STATUS_OPTIONS = [
{ value: 'draft', label: '草稿' },
{ value: 'in_approval', label: '审批中' },
{ value: 'pending', label: '发放中' },
{ value: 'rejected', label: '已驳回' },
{ value: 'disbursed', label: '已发放' },
{ value: 'cancelled', label: '已作废' }
]
/** ETC 申请列表列头漏斗(服务端)。 */
/** 油卡申请列表列头漏斗(服务端)。 */
export const plVehicleRechargeApplyColumnFilterDefs: ColumnFilterDef[] = [
{ prop: 'applyNo', label: '申请单号', type: 'text', queryKey: 'applyNo' },
{ prop: 'licensePlate', label: '车牌号', type: 'text', queryKey: 'licensePlate' },
@@ -52,7 +52,7 @@ export const plVehicleRechargeApplyColumnFilterDefs: ColumnFilterDef[] = [
}
]
/** ETC 审批列表列头漏斗(服务端;在申请 defs 基础上增加当前节点)。 */
/** 油卡审批列表列头漏斗(服务端;在申请 defs 基础上增加当前节点)。 */
export const plVehicleRechargeApprovalColumnFilterDefs: ColumnFilterDef[] = [
...plVehicleRechargeApplyColumnFilterDefs.filter((d) => d.prop !== 'createTime'),
{ prop: 'currentNodeName', label: '当前节点', type: 'text', queryKey: 'currentNodeName' },
@@ -65,7 +65,7 @@ export const plVehicleRechargeApprovalColumnFilterDefs: ColumnFilterDef[] = [
}
]
/** ETC 发放看板列头漏斗(服务端)。 */
/** 油卡发放看板列头漏斗(服务端)。 */
export const plVehicleRechargeDisburseColumnFilterDefs: ColumnFilterDef[] = [
{ prop: 'applyNo', label: '申请单号', type: 'text', queryKey: 'applyNo' },
{
@@ -111,7 +111,7 @@ export const plVehicleRechargeDisburseColumnFilterDefs: ColumnFilterDef[] = [
}
]
/** ETC 审批人设置(客户端)。 */
/** 油卡审批人设置(客户端)。 */
export const plVehicleRechargeWorkflowColumnFilterDefs: ColumnFilterDef[] = [
{ prop: 'nodeName', label: '节点名称', type: 'text' },
{
@@ -1,4 +1,4 @@
<!-- ETC/油卡充值申请 -->
<!-- 油卡充值申请 -->
<template>
<div class="page-container">
<section class="panel">
@@ -369,7 +369,6 @@
</el-form-item>
<el-form-item label="充值类型" prop="rechargeType">
<el-select v-model="formData.rechargeType" placeholder="请选择" style="width: 100%">
<el-option label="ETC" value="etc" />
<el-option label="油卡" value="fuel" />
<el-option label="山峡" value="shanxia" />
<el-option label="百链" value="bailian" />
@@ -716,6 +715,7 @@ function buildListQueryParams(): Record<string, any> {
const params: Record<string, any> = { ...toQueryParams(), ...toSortQueryParams() }
const keyword = quickSearchKeyword.value.trim()
if (keyword) params.q = keyword
params.rechargeTypeNe = 'etc'
return params
}
@@ -768,7 +768,7 @@ const formData = reactive({
applyNo: '',
applicantId: '',
licensePlate: '',
rechargeType: 'etc' as 'etc' | 'fuel' | 'shanxia' | 'bailian',
rechargeType: 'fuel' as 'etc' | 'fuel' | 'shanxia' | 'bailian',
applyDate: '',
odometerKm: null as number | null,
attachmentUrls: '',
@@ -1004,7 +1004,7 @@ function resetForm() {
formData.applyNo = ''
formData.applicantId = ''
formData.licensePlate = ''
formData.rechargeType = 'etc'
formData.rechargeType = 'fuel'
formData.applyDate = ''
formData.odometerKm = null
formData.attachmentUrls = ''
@@ -1244,7 +1244,9 @@ function applySummaryFilter() {
if (typeof plate !== 'string' || !['etc', 'fuel'].includes(String(kind))) return
Object.keys(filters).forEach((key) => delete filters[key])
filters.licensePlate = { text: plate }
filters.rechargeType = { text: '', selected: [String(kind)] }
if (String(kind) !== 'etc') {
filters.rechargeType = { text: '', selected: [String(kind)] }
}
quickSearchKeyword.value = ''
pagination.page = 1
const query = { ...route.query }
@@ -686,7 +686,8 @@
const res = await api({
page: pager.page,
pageSize: pager.pageSize,
...toQueryParams()
...toQueryParams(),
rechargeTypeNe: 'etc'
})
const data: any = (res as any)?.data ?? res
tableData.value = data?.items || []
@@ -1,4 +1,4 @@
<!-- ETC/油卡充值发放管理 -->
<!-- 油卡充值发放管理 -->
<template>
<div class="page-container">
<section class="panel">
@@ -460,7 +460,6 @@
</el-form-item>
<el-form-item label="充值类型" prop="rechargeType">
<el-select v-model="formData.rechargeType" placeholder="请选择" style="width: 100%">
<el-option label="ETC" value="etc" />
<el-option label="油卡" value="fuel" />
<el-option label="山峡" value="shanxia" />
<el-option label="百链" value="bailian" />
@@ -727,6 +726,7 @@ function buildListQueryParams(): Record<string, any> {
const params: Record<string, any> = { ...toQueryParams(), ...toSortQueryParams() }
const keyword = quickSearchKeyword.value.trim()
if (keyword) params.q = keyword
params.rechargeTypeNe = 'etc'
return params
}
@@ -780,7 +780,7 @@ const formData = reactive({
rechargeOrderNo: '',
applyId: '',
licensePlate: '',
rechargeType: 'etc' as 'etc' | 'fuel' | 'shanxia' | 'bailian',
rechargeType: 'fuel' as 'etc' | 'fuel' | 'shanxia' | 'bailian',
rechargeDate: '',
cardNo: '',
gasStation: '',
@@ -985,7 +985,7 @@ function resetForm() {
formData.rechargeOrderNo = ''
formData.applyId = ''
formData.licensePlate = ''
formData.rechargeType = 'etc'
formData.rechargeType = 'fuel'
formData.rechargeDate = ''
formData.cardNo = ''
formData.gasStation = ''
@@ -997,7 +997,7 @@ function resetForm() {
function handleDisburse(row: VehicleRechargeDisburseBoardItem) {
if (row.applyStatus === 'in_approval') {
ElMessage.warning('审批中的申请请到「ETC/油卡充值审批」菜单完成发放登记')
ElMessage.warning('审批中的申请请到「油卡充值审批」菜单完成发放登记')
return
}
resetForm()
@@ -1148,7 +1148,7 @@ async function handleSubmit() {
async function handleDelete(row: VehicleRechargeDisburseBoardItem) {
if (!row.recordId) return
await ElMessageBox.confirm('确定撤销该发放登记吗?关联申请将回退为待发放。', '提示', { type: 'warning' })
await ElMessageBox.confirm('确定撤销该发放登记吗?关联申请将回退为发放中。', '提示', { type: 'warning' })
deleteLoadingMap[row.recordId] = true
try {
await vehicleRechargeRecordApi.delete(row.recordId)
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more