15 KiB
报销域日期列头双三角排序 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: 在有日期列的报销相关列表上,为 datetime 列头增加与 CRM 相同的双三角整库排序(视图合并页为拉全量后的客户端排序)。
Architecture: 扩展 ColumnHeaderFilter:存在 crmColumnSort inject 且 type==='datetime' 时渲染双三角。页面接入已有 useColumnSort。后端复用 resolve_datetime_order_by;applyTime 映射 create_time。视图 mine/todo/all 已 fetchAllPagedRecords 合并双边数据,改 sortMergedRows 按当前 sort 状态排。
Tech Stack: Vue 3、ColumnHeaderFilter、useColumnSort、FastAPI、SQLAlchemy、resolve_datetime_order_by
Spec: docs/superpowers/specs/2026-09-08-bx-date-column-sort-design.md
文件结构
| 操作 | 文件 | 职责 |
|---|---|---|
| Modify | web-admin/src/components/business/column-header-filter/index.vue |
datetime + inject 时显示双三角 |
| Modify | backend/app/services/bx/base.py |
get_paged 支持可选 order_by |
| Create | backend/app/services/bx/list_sort.py |
各实体白名单封装(调用 crm resolve_datetime_order_by) |
| Create | backend/tests/test_bx_list_sort.py |
白名单映射单测 |
| Modify | backend/app/api/bx/advance.py + services/bx/advance.py |
透传 sort → order |
| Modify | backend/app/api/bx/reimburse.py + services/bx/reimburse.py |
同上 |
| Modify | backend/app/api/bx/payment.py + 付款 list 路径 |
applyTime/payTime |
| Modify | backend/app/api/bx/invoice.py + services/bx/invoice.py |
我的发票/流水 createTime |
| Modify | backend/app/api/bx/export_common.py |
BxApplyListExportBody 显式 sortField/sortOrder(可选,extra=allow 已可透传) |
| Modify | 各 views/bx/**/index.vue(见 Task 6–10) |
provide + merge query + 藏列 |
不做: expense-item、workflow、invoice-wallet/admin;非日期列。
Task 1: ColumnHeaderFilter 双三角
Files:
-
Modify:
web-admin/src/components/business/column-header-filter/index.vue -
Step 1: 改模板根结构
当前根节点是单个 el-popover。改为外包一层,在 popover 之前插入三角(与 CRM 列头「标题 | 三角 | 漏斗」一致;标题仍由页面 label 提供):
<template>
<span class="col-header-filter-root">
<span
v-if="showSort"
class="col-header-sort-pair"
@click.stop
>
<button
type="button"
class="col-header-sort-btn"
:class="{ on: sortApi!.isSortActive(prop, 'asc') }"
title="升序"
aria-label="升序"
@click="sortApi!.setSort(prop, 'asc')"
>▲</button>
<button
type="button"
class="col-header-sort-btn"
:class="{ on: sortApi!.isSortActive(prop, 'desc') }"
title="降序"
aria-label="降序"
@click="sortApi!.setSort(prop, 'desc')"
>▼</button>
</span>
<el-popover ...现有内容不变...>
...
</el-popover>
</span>
</template>
- Step 2: script 增加 inject
import { computed, inject, ref, watch } from 'vue'
// ...
const sortApi = inject<{
setSort: (prop: string, order: 'asc' | 'desc') => void
isSortActive: (prop: string, order?: 'asc' | 'desc') => boolean
} | null>('crmColumnSort', null)
const showSort = computed(() => Boolean(sortApi && props.type === 'datetime'))
样式 .col-header-sort-pair 已在同文件全局 style 中(CRM Task 6 已加),无需重复。可补:
.col-header-filter-root {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
-
Step 3: 确认无 provide 时不显示三角;CRM 的
CrmColFilterHeader仍自带三角(两套并存可接受) -
Step 4: Commit
git add web-admin/src/components/business/column-header-filter/index.vue
git commit -m "feat(web-admin): show dual carets on ColumnHeaderFilter datetime columns."
Task 2: BX 排序白名单 helper + get_paged 可选 order
Files:
-
Create:
backend/app/services/bx/list_sort.py -
Create:
backend/tests/test_bx_list_sort.py -
Modify:
backend/app/services/bx/base.py -
Step 1: 写失败测试
# backend/tests/test_bx_list_sort.py
from app.models.bx_models import BxAdvanceApply, BxReimburseApply, BxInvoice, BxInvoiceLedger
from app.services.bx.list_sort import (
order_advance,
order_reimburse,
order_invoice,
order_ledger,
)
def test_advance_apply_time_asc():
expr = order_advance("applyTime", "asc")
assert "create_time" in str(expr).lower()
assert "asc" in str(expr).lower()
def test_advance_issue_time():
expr = order_advance("issueTime", "desc")
assert "issue_time" in str(expr).lower()
def test_advance_invalid_falls_back():
default = BxAdvanceApply.create_time.desc()
assert order_advance("hack", "asc", default=default) is default
def test_reimburse_pay_time():
expr = order_reimburse("payTime", "desc")
assert "pay_time" in str(expr).lower()
def test_invoice_create_time():
expr = order_invoice("createTime", "asc")
assert "create_time" in str(expr).lower()
(若模型 import 路径不同,以仓库 app.models 实际模块为准。)
- Step 2: pytest 应 FAIL
cd backend && source venv/bin/activate && PYTHONPATH=. pytest tests/test_bx_list_sort.py -v
- Step 3: 实现
list_sort.py
# backend/app/services/bx/list_sort.py
from __future__ import annotations
from typing import Any, Optional
from app.models.bx_models import (
BxAdvanceApply,
BxInvoice,
BxInvoiceLedger,
BxReimburseApply,
)
from app.services.crm.list_sort import resolve_datetime_order_by
ADVANCE_WHITELIST = {
"applyTime": BxAdvanceApply.create_time,
"issueTime": BxAdvanceApply.issue_time,
}
REIMBURSE_WHITELIST = {
"applyTime": BxReimburseApply.create_time,
"payTime": BxReimburseApply.pay_time,
}
INVOICE_WHITELIST = {"createTime": BxInvoice.create_time}
LEDGER_WHITELIST = {"createTime": BxInvoiceLedger.create_time}
def order_advance(sort_field: Optional[str], sort_order: Optional[str], default=None):
if default is None:
default = BxAdvanceApply.create_time.desc()
return resolve_datetime_order_by(sort_field, sort_order, ADVANCE_WHITELIST, default)
def order_reimburse(sort_field: Optional[str], sort_order: Optional[str], default=None):
if default is None:
default = BxReimburseApply.create_time.desc()
return resolve_datetime_order_by(sort_field, sort_order, REIMBURSE_WHITELIST, default)
def order_invoice(sort_field: Optional[str], sort_order: Optional[str], default=None):
if default is None:
default = BxInvoice.create_time.desc()
return resolve_datetime_order_by(sort_field, sort_order, INVOICE_WHITELIST, default)
def order_ledger(sort_field: Optional[str], sort_order: Optional[str], default=None):
if default is None:
default = BxInvoiceLedger.create_time.desc()
return resolve_datetime_order_by(sort_field, sort_order, LEDGER_WHITELIST, default)
def parse_sort_params(params: dict) -> tuple:
"""从 request/body dict 取 sortField/sortOrder(兼容 snake)。"""
sf = params.get("sortField") or params.get("sort_field")
so = params.get("sortOrder") or params.get("sort_order")
return (str(sf).strip() if sf else None), (str(so).strip() if so else None)
- Step 4:
BxCrudService.get_paged增加可选order_by=None
在现有默认 order_by(sort_order, create_time) 分支前:
order_by: Optional[Any] = None,
# ...
if order_by is not None:
query = query.order_by(order_by)
elif hasattr(model, "sort_order"):
...
else:
query = query.order_by(model.create_time.desc())
- Step 5: 测试通过并 Commit
git add backend/app/services/bx/list_sort.py backend/tests/test_bx_list_sort.py backend/app/services/bx/base.py
git commit -m "feat(bx): add datetime list sort helpers and get_paged order_by."
Task 3: 备用金 API/Service 接排序
Files:
-
Modify:
backend/app/api/bx/advance.py -
Modify:
backend/app/services/bx/advance.py -
Modify:
backend/app/api/bx/export_common.py(可选显式字段) -
Step 1:
BxApplyListExportBody增加
sort_field: Optional[str] = Field(default=None, alias="sortField")
sort_order: Optional[str] = Field(default=None, alias="sortOrder")
(extra=allow 已有则 GET query 的 sortField 进 _merge_query 即可;显式字段便于导出 body。)
- Step 2: 所有走
AdvanceService.get_paged的 list/export
在调用前:
from app.services.bx.list_sort import order_advance, parse_sort_params
sf, so = parse_sort_params(params)
order = order_advance(sf, so)
rows, total = AdvanceService.get_paged(..., order_by=order)
确认 AdvanceService.get_paged 把 order_by 传给 BxCrudService.get_paged(若当前是薄封装,补参数)。
- Step 3:
pending_approval/approved_by_me
将硬编码 .order_by(BxAdvanceApply.create_time.desc()) 改为 order_by(order_advance(sf, so));sf/so 从该接口的 params/_merge_query 读取。
- Step 4: Commit
git commit -m "feat(bx): support datetime column sort on advance lists."
Task 4: 报销 API/Service 接排序
Files:
- Modify:
backend/app/api/bx/reimburse.py - Modify:
backend/app/services/bx/reimburse.py
镜像 Task 3:get_paged、pending_approval、approved_by_me 及对应 export 使用 order_reimburse。白名单含 applyTime、payTime。
git commit -m "feat(bx): support datetime column sort on reimburse lists."
Task 5: 支付发放冲抵接排序
Files:
- Modify:
backend/app/api/bx/payment.py(及实际 list/export 调用的 service)
待处理/已处理列表对 BxReimburseApply 排序时用 order_reimburse(applyTime/payTime)。冲抵记录子表(BxReimburseOffset.create_time)本批不改(非主列表漏斗列)。
git commit -m "feat(bx): support datetime column sort on payment lists."
Task 6: 我的发票夹后端
Files:
-
Modify:
backend/app/api/bx/invoice.py -
Modify:
backend/app/services/bx/invoice.py -
list_invoices:经get_paged传入order_by=order_invoice(sf, so);API_param_get/ merge 取出 sort。 -
list_ledgers:将order_by(BxInvoiceLedger.create_time.desc())换为order_ledger(sf, so)。
git commit -m "feat(bx): support datetime column sort on my invoice wallet lists."
Task 7: 接线备用金三页前端
Files:
web-admin/src/views/bx/advance/apply/index.vueweb-admin/src/views/bx/advance/approve/index.vueweb-admin/src/views/bx/advance/issue/index.vue
每页:
import { useColumnSort } from '@/composables/useColumnSort'
const {
setSort, clearSort, isSortActive, sortField, sortOrder,
toQueryParams: toSortQueryParams,
watchVisibleColumns: watchSortVisibleColumns
} = useColumnSort({ onChange: () => { /* 现有 reset page + load */ } })
provide('crmColumnSort', { setSort, clearSort, isSortActive, sortField, sortOrder })
列表/导出参数合并 ...toSortQueryParams();watchSortVisibleColumns(visibleFilterProps)。
审批/发放若双 Tab 共用一套 filters,共用一套 sort 即可。
git commit -m "feat(bx): wire date column sort on advance apply/approve/issue pages."
Task 8: 接线报销申请/审批前端
Files:
web-admin/src/views/bx/reimburse/apply/index.vueweb-admin/src/views/bx/reimburse/approve/index.vue
同 Task 7 模式。
git commit -m "feat(bx): wire date column sort on reimburse apply/approve pages."
Task 9: 接线支付页前端
Files:
web-admin/src/views/bx/payment/index.vue
同模式;payTime 仅已处理 Tab 有漏斗,藏列时 watchSortVisibleColumns 清排序。
git commit -m "feat(bx): wire date column sort on payment page."
Task 10: 接线我的发票夹前端
Files:
web-admin/src/views/bx/invoice-wallet/mine/index.vue
发票表与流水表各有 useServerColumnFilters。推荐:
- 两套
useColumnSort(invoiceSort/ledgerSort) provide时按当前 Tab 提供,或给ColumnHeaderFilter增加可选injectKey(若改组件成本高:切换 Tab 时provide无法动态——改用 同一套 sort,切 Tab 时clearSort,更简单)
选定更简单:共用一套 useColumnSort,切 Tab 时 clearSort(),请求分别带 toSortQueryParams()。
git commit -m "feat(bx): wire date column sort on my invoice wallet page."
Task 11: 视图三页(合并列表 · 客户端排序)
Files:
web-admin/src/views/bx/view/mine/index.vueweb-admin/src/views/bx/view/todo/index.vueweb-admin/src/views/bx/view/all/index.vue
现状:sortMergedRows 固定 applyTime 倒序。这些页拉全量后合并,以客户端排序为准:
function sortMergedRows(rows: any[]) {
const field = sortField.value || 'applyTime'
const order = sortOrder.value || 'desc'
return [...rows].sort((a, b) => {
const av = String(a[field] || '')
const bv = String(b[field] || '')
if (!av && !bv) return 0
if (!av) return order === 'asc' ? -1 : 1 // 空值:升序前、降序后
if (!bv) return order === 'asc' ? 1 : -1
const cmp = av.localeCompare(bv)
return order === 'asc' ? cmp : -cmp
})
}
仍 provide('crmColumnSort') 驱动列头三角;onChange → loadData()。
可选:请求时仍带 sortField 给单边 API(非必须,合并后会再排)。
git commit -m "feat(bx): wire date column sort on bx view mine/todo/all pages."
Task 12: 回归与图谱
- Step 1:
PYTHONPATH=. pytest tests/test_bx_list_sort.py tests/test_crm_list_sort.py -v - Step 2:
pnpm dlx vitest run src/composables/useColumnSort.test.ts - Step 3: 手工:备用金申请
applyTime;发放issueTime;报销申请;支付payTime;我的发票夹;我的申请单合并列表;费用项目无三角。 - Step 4:
./scripts/update-graph.sh all后仅提交图谱变更:
git commit -m "chore: update graphify after BX date column sort."
Spec 覆盖自检
| Spec 项 | Task |
|---|---|
| ColumnHeaderFilter 扩展 | 1 |
| applyTime→create_time 白名单 | 2–5 |
| 备用金/报销/支付/发票夹 | 3–6, 7–10 |
| 视图合并页 | 11(客户端排序,符合现网拉全量架构) |
| 跳过费用项目/工作流/发票夹管理 | 文件结构「不做」 |
| 空值升降序 | 2 helper + 11 sortMergedRows |
| 与 CRM 同 inject key | 1, 7–11 |
无 TBD;视图页明确为客户端排序,避免与「整库服务端」表述冲突——spec §7 已提示 union/合并风险,本计划按现网 fetchAllPagedRecords 落地。