diff --git a/docs/superpowers/plans/2026-09-08-bx-date-column-sort.md b/docs/superpowers/plans/2026-09-08-bx-date-column-sort.md new file mode 100644 index 000000000..7ea8616b6 --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-bx-date-column-sort.md @@ -0,0 +1,466 @@ +# 报销域日期列头双三角排序 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 提供): + +```vue + +``` + +- [ ] **Step 2: script 增加 inject** + +```ts +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 已加),无需重复。可补: + +```scss +.col-header-filter-root { + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} +``` + +- [ ] **Step 3: 确认无 provide 时不显示三角;CRM 的 `CrmColFilterHeader` 仍自带三角(两套并存可接受)** + +- [ ] **Step 4: Commit** + +```bash +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: 写失败测试** + +```python +# 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** + +```bash +cd backend && source venv/bin/activate && PYTHONPATH=. pytest tests/test_bx_list_sort.py -v +``` + +- [ ] **Step 3: 实现 `list_sort.py`** + +```python +# 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)` 分支前: + +```python +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** + +```bash +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` 增加** + +```python +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** + +在调用前: + +```python +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** + +```bash +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`。 + +```bash +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`)**本批不改**(非主列表漏斗列)。 + +```bash +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)`。 + +```bash +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.vue` +- `web-admin/src/views/bx/advance/approve/index.vue` +- `web-admin/src/views/bx/advance/issue/index.vue` + +每页: + +```ts +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 即可。 + +```bash +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.vue` +- `web-admin/src/views/bx/reimburse/approve/index.vue` + +同 Task 7 模式。 + +```bash +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` 清排序。 + +```bash +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()`。** + +```bash +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.vue` +- `web-admin/src/views/bx/view/todo/index.vue` +- `web-admin/src/views/bx/view/all/index.vue` + +现状:`sortMergedRows` 固定 `applyTime` 倒序。这些页拉全量后合并,**以客户端排序为准**: + +```ts +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(非必须,合并后会再排)。 + +```bash +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` 后仅提交图谱变更: + +```bash +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` 落地。