docs: add implementation plan for reimburse server column filters.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-09-06 07:36:43 +08:00
1 parent 3fa1f8e312
commit e56e1c9144
1 file changed
+781
@@ -0,0 +1,781 @@
# 报销申请列头整库服务端筛选 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:** 后端抽取可复用的列表过滤拼装(扁平 GET/导出 body 参数);前端新增 `useServerColumnFilters`,改造 `ColumnHeaderFilter` 的 text 为模糊输入;页面去 `filter-block`,藏列时清空该列条件并重查。
**Tech Stack:** FastAPI + SQLAlchemy、Vue 3、Element Plus、既有 `ColumnHeaderFilter` / `useServerPaged` / `usePersistedListColumns`
**Spec:** `docs/superpowers/specs/2026-09-06-reimburse-apply-server-column-filters-design.md`
---
## 文件结构
| 操作 | 文件 | 职责 |
|------|------|------|
| Create | `backend/app/services/bx/reimburse_list_filters.py` | 纯函数:根据查询参数拼装 SQLAlchemy filter 列表(含金额/状态多选/单号等) |
| Modify | `backend/app/api/bx/export_common.py` | `BxApplyListExportBody` 增加列筛字段 |
| Modify | `backend/app/api/bx/reimburse.py` | `my_reimburses` / `_reimburse_list_filters` 走新拼装;导出共用 |
| Create | `backend/tests/test_bx_reimburse_list_filters.py` | 过滤拼装单测(不连库:断言表达式结构或对 mock 列) |
| Create | `web-admin/src/composables/useServerColumnFilters.ts` | 服务端列条件 state、`toQueryParams`、藏列清条件 |
| Modify | `web-admin/src/composables/useClientColumnFilters.ts` | 导出/对齐 `TextColumnFilterState`:text 改为 `{ text: string }`;enum 仍用 `selected`(见 Task 2) |
| Modify | `web-admin/src/components/business/column-header-filter/index.vue` | text UI → 模糊输入;确认后仍 emit state |
| Modify | `web-admin/src/api/bx/index.ts` | `reimburseApi.my` 参数类型扩展 |
| Modify | `web-admin/src/views/bx/reimburse/apply/index.vue` | 去顶部筛;接服务端列筛;藏列清条件 |
**不变:** CRM、其他 BX 列表、弹窗列表推广;OpenSpec 基线文档(标杆后再补)。
---
### Task 1: 后端过滤拼装函数 + 单测
**Files:**
- Create: `backend/app/services/bx/reimburse_list_filters.py`
- Create: `backend/tests/test_bx_reimburse_list_filters.py`
- [ ] **Step 1: 写失败单测(先测 amount / statusIn / applyCode)**
```python
# backend/tests/test_bx_reimburse_list_filters.py
from app.services.bx.reimburse_list_filters import build_reimburse_column_filters
from app.models.bx_models import BxReimburseApply
def test_status_in_adds_in_clause():
filters = build_reimburse_column_filters(
db=None,
tenant_no="t1",
status_in="draft,pending",
)
assert len(filters) == 1
# SQLAlchemy IN
assert "IN" in str(filters[0]).upper() or hasattr(filters[0], "right")
def test_apply_code_contains():
filters = build_reimburse_column_filters(db=None, tenant_no="t1", apply_code="BX01")
assert len(filters) == 1
assert "apply_code" in str(filters[0]).lower() or "apply_code" in repr(filters[0])
def test_amount_between_min_only():
filters = build_reimburse_column_filters(
db=None, tenant_no="t1", amount_op="between", amount_min="10", amount_max=""
)
assert len(filters) == 1
def test_amount_eq():
filters = build_reimburse_column_filters(
db=None, tenant_no="t1", amount_op="eq", amount="100"
)
assert len(filters) == 1
def test_empty_params_yield_no_filters():
filters = build_reimburse_column_filters(db=None, tenant_no="t1")
assert filters == []
```
- [ ] **Step 2: 跑测确认失败**
Run:
```bash
cd backend && source venv/bin/activate && pytest tests/test_bx_reimburse_list_filters.py -v
```
Expected: FAIL(模块不存在)
- [ ] **Step 3: 实现 `build_reimburse_column_filters`**
```python
# backend/app/services/bx/reimburse_list_filters.py
"""报销申请列表列头/导出共用过滤条件拼装。"""
from __future__ import annotations
from decimal import Decimal, InvalidOperation
from typing import Any, List, Optional
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.models.bx_models import BxReimburseApply
from app.services.bx.reimburse import ReimburseService
from app.utils.bx_date_range import append_create_time_filters
_AMOUNT_OPS = frozenset({"between", "eq", "gt", "gte", "lt", "lte"})
def _parse_decimal(raw: Optional[str]) -> Optional[Decimal]:
text = str(raw or "").strip()
if not text:
return None
try:
return Decimal(text)
except (InvalidOperation, ValueError):
return None
def build_reimburse_column_filters(
*,
db: Optional[Session],
tenant_no: str,
keyword: str = "",
status: Optional[str] = None,
status_in: Optional[str] = None,
apply_code: Optional[str] = None,
title: Optional[str] = None,
expense_name: Optional[str] = None,
expense_id: Optional[str] = None,
applicant_id: Optional[str] = None,
current_node: Optional[str] = None,
amount_op: Optional[str] = None,
amount: Optional[str] = None,
amount_min: Optional[str] = None,
amount_max: Optional[str] = None,
apply_date: Optional[str] = None,
apply_date_from: Optional[str] = None,
apply_date_to: Optional[str] = None,
ids: Optional[List[str]] = None,
) -> list:
filters: list = []
# statusIn 优先;否则兼容单值 status
statuses: List[str] = []
if status_in and str(status_in).strip():
statuses = [s.strip() for s in str(status_in).split(",") if s.strip()]
elif status and str(status).strip():
statuses = [str(status).strip()]
if statuses:
filters.append(BxReimburseApply.status.in_(statuses))
kw = (keyword or "").strip()
if kw:
filters.append(
or_(
BxReimburseApply.apply_code.contains(kw),
BxReimburseApply.applicant_name.contains(kw),
BxReimburseApply.title.contains(kw),
)
)
code = (apply_code or "").strip()
if code:
filters.append(BxReimburseApply.apply_code.contains(code))
title_kw = (title or "").strip()
if title_kw:
filters.append(BxReimburseApply.title.contains(title_kw))
node_kw = (current_node or "").strip()
if node_kw:
filters.append(BxReimburseApply.current_node.contains(node_kw))
if expense_id and str(expense_id).strip():
filters.append(BxReimburseApply.expense_id == str(expense_id).strip())
if db is not None:
expense_filter = ReimburseService.build_expense_name_condition(db, tenant_no, expense_name)
if expense_filter is not None:
filters.append(expense_filter)
else:
name_kw = (expense_name or "").strip()
if name_kw:
filters.append(BxReimburseApply.expense_name.contains(name_kw))
if applicant_id and str(applicant_id).strip():
filters.append(BxReimburseApply.applicant_id == str(applicant_id).strip())
if ids:
filters.append(BxReimburseApply.id.in_([str(i) for i in ids]))
op = (amount_op or "").strip() or "between"
if op in _AMOUNT_OPS:
if op == "between":
dmin = _parse_decimal(amount_min)
dmax = _parse_decimal(amount_max)
if dmin is not None:
filters.append(BxReimburseApply.amount >= dmin)
if dmax is not None:
filters.append(BxReimburseApply.amount <= dmax)
else:
dval = _parse_decimal(amount)
if dval is not None:
col = BxReimburseApply.amount
if op == "eq":
filters.append(col == dval)
elif op == "gt":
filters.append(col > dval)
elif op == "gte":
filters.append(col >= dval)
elif op == "lt":
filters.append(col < dval)
elif op == "lte":
filters.append(col <= dval)
append_create_time_filters(
filters,
BxReimburseApply,
apply_date=apply_date,
apply_date_from=apply_date_from,
apply_date_to=apply_date_to,
)
return filters
```
注意:单测里 `db=None` 时费用项目走主表 `contains` 分支即可。
- [ ] **Step 4: 再跑测**
Run:
```bash
cd backend && source venv/bin/activate && pytest tests/test_bx_reimburse_list_filters.py -v
```
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add backend/app/services/bx/reimburse_list_filters.py backend/tests/test_bx_reimburse_list_filters.py
git commit -m "feat(bx): add shared reimburse list column filter builder."
```
---
### Task 2: 接线 `my` 列表与导出 body
**Files:**
- Modify: `backend/app/api/bx/export_common.py`
- Modify: `backend/app/api/bx/reimburse.py`
- [ ] **Step 1: 扩展 `BxApplyListExportBody`**
在 `BxApplyListExportBody` 增加(保持 `populate_by_name` + alias):
```python
apply_code: Optional[str] = Field(default=None, alias="applyCode")
title: Optional[str] = None
current_node: Optional[str] = Field(default=None, alias="currentNode")
status_in: Optional[str] = Field(default=None, alias="statusIn")
amount_op: Optional[str] = Field(default=None, alias="amountOp")
amount: Optional[str] = None
amount_min: Optional[str] = Field(default=None, alias="amountMin")
amount_max: Optional[str] = Field(default=None, alias="amountMax")
```
- [ ] **Step 2: 改写 `_reimburse_list_filters`**
```python
from app.services.bx.reimburse_list_filters import build_reimburse_column_filters
def _reimburse_list_filters(body: BxApplyListExportBody, db: Session, tenant_no: str) -> list:
return build_reimburse_column_filters(
db=db,
tenant_no=tenant_no,
keyword=body.keyword or "",
status=body.status,
status_in=body.status_in,
apply_code=body.apply_code,
title=body.title,
expense_name=body.expense_name,
expense_id=body.expense_id,
applicant_id=body.applicant_id,
current_node=body.current_node,
amount_op=body.amount_op,
amount=body.amount,
amount_min=body.amount_min,
amount_max=body.amount_max,
apply_date=body.apply_date,
apply_date_from=body.apply_date_from,
apply_date_to=body.apply_date_to,
ids=body.ids,
)
```
- [ ] **Step 3: 扩展 `my_reimburses` 查询参数并调用拼装**
在 `my_reimburses` 增加 Query 参数:`applyCode`, `title`, `currentNode`, `statusIn`, `amountOp`, `amount`, `amountMin`, `amountMax`(保留既有 `status`/`keyword`/`expenseName`/`applyDate*`)。
组装 filters 时:
1. 先加「我的」范围 `or_(applicant_id == user.id, create_by == user.id)`
2. 再 `extend(build_reimburse_column_filters(...))`(把 Query 传入)
删除函数内手写的 keyword/status/expense/date 重复逻辑。
- [ ] **Step 4: 冒烟检查签名**
Run:
```bash
cd backend && source venv/bin/activate && pytest tests/test_bx_reimburse_list_filters.py tests/test_bx_reimburse_my_page_size.py -v
```
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add backend/app/api/bx/export_common.py backend/app/api/bx/reimburse.py
git commit -m "feat(bx): wire reimburse my list and export to column filters."
```
---
### Task 3: 前端类型与 `useServerColumnFilters`
**Files:**
- Modify: `web-admin/src/composables/useClientColumnFilters.ts`(统一 text state 形状,供组件共用类型)
- Create: `web-admin/src/composables/useServerColumnFilters.ts`
- [ ] **Step 1: 调整共享类型**
在 `useClientColumnFilters.ts` 中:
```typescript
/** 文本列:服务端模糊 / 客户端也可只用 text */
export interface TextColumnFilterState {
text: string
/** @deprecated 旧客户端勾选;服务端 text 不用 */
selected?: string[]
keyword?: string
}
export function isColumnFilterActive(
type: ColumnFilterType,
state?: ColumnFilterState | null
): boolean {
if (!state) return false
if (type === 'text') {
const s = state as TextColumnFilterState
if (String(s.text || '').trim()) return true
return Array.isArray(s.selected) && s.selected.length > 0
}
if (type === 'enum') {
const s = state as TextColumnFilterState
return Array.isArray(s.selected) && s.selected.length > 0
}
// number / datetime 保持原逻辑
...
}
```
客户端 `matchOne` 对 `text`:若 `text` 有值则 `String(raw).includes(text)`;否则仍按 `selected`(兼容旧页,本仓库目前仅 apply 页使用)。
- [ ] **Step 2: 实现 `useServerColumnFilters`**
```typescript
// web-admin/src/composables/useServerColumnFilters.ts
import { computed, reactive, watch, type Ref } from 'vue'
import {
isColumnFilterActive,
type ColumnFilterDef,
type ColumnFilterState
} from '@/composables/useClientColumnFilters'
export function useServerColumnFilters(
columnDefs: ColumnFilterDef[],
options?: {
initialFilters?: Record<string, ColumnFilterState | null>
onChange?: () => void
}
) {
const defMap = new Map(columnDefs.map((d) => [d.prop, d]))
const filters = reactive<Record<string, ColumnFilterState>>({})
function applyInitial() {
const init = options?.initialFilters || {}
Object.keys(filters).forEach((k) => delete filters[k])
for (const [prop, state] of Object.entries(init)) {
const def = defMap.get(prop)
if (!def || !state || !isColumnFilterActive(def.type, state)) continue
filters[prop] = JSON.parse(JSON.stringify(state))
}
}
applyInitial()
const activeFilterProps = computed(() =>
Object.keys(filters).filter((prop) => {
const def = defMap.get(prop)
return def && isColumnFilterActive(def.type, filters[prop])
})
)
const hasActiveFilters = computed(() => activeFilterProps.value.length > 0)
function setFilterState(prop: string, state: ColumnFilterState | null) {
const def = defMap.get(prop)
if (!def) return
if (!state || !isColumnFilterActive(def.type, state)) {
delete filters[prop]
} else {
filters[prop] = JSON.parse(JSON.stringify(state))
}
options?.onChange?.()
}
function clearFilter(prop: string) {
if (!(prop in filters)) return
delete filters[prop]
options?.onChange?.()
}
function clearAllFilters() {
const had = Object.keys(filters).length > 0
Object.keys(filters).forEach((k) => delete filters[k])
if (had) options?.onChange?.()
}
function resetToInitial() {
applyInitial()
options?.onChange?.()
}
function isFilterActive(prop: string) {
const def = defMap.get(prop)
if (!def) return false
return isColumnFilterActive(def.type, filters[prop])
}
/** 隐藏列:清该列条件;若确实清过则触发 onChange */
function onColumnHidden(prop: string) {
if (!(prop in filters)) return
delete filters[prop]
options?.onChange?.()
}
function toQueryParams(): Record<string, string> {
const q: Record<string, string> = {}
for (const prop of Object.keys(filters)) {
const def = defMap.get(prop)
const state = filters[prop]
if (!def || !isColumnFilterActive(def.type, state)) continue
if (def.type === 'datetime') {
const s = state as { from?: string; to?: string }
if (s.from) q.applyDateFrom = s.from
if (s.to) q.applyDateTo = s.to
// 通用:若 prop 不是 applyTime,也可映射为 `${prop}From` — 本页仅 applyTime
continue
}
if (def.type === 'number') {
const s = state as { op: string; value: string; min: string; max: string }
q.amountOp = s.op || 'between'
if (s.op === 'between') {
if (s.min !== '' && s.min != null) q.amountMin = String(s.min)
if (s.max !== '' && s.max != null) q.amountMax = String(s.max)
} else if (s.value !== '' && s.value != null) {
q.amount = String(s.value)
}
continue
}
if (def.type === 'enum') {
const s = state as { selected?: string[] }
if (s.selected?.length) q.statusIn = s.selected.join(',')
continue
}
// text:prop 直接作为 query key(applyCode/title/expenseName/currentNode)
const s = state as { text?: string }
const text = String(s.text || '').trim()
if (text) q[prop] = text
}
return q
}
/**
* 监听可见列:从有→无时 onColumnHidden。
* visibleProps: Ref<string[]> 当前可见的业务列 prop 列表。
*/
function watchVisibleColumns(visibleProps: Ref<string[]>) {
watch(
visibleProps,
(next, prev) => {
if (!prev) return
const nextSet = new Set(next || [])
for (const prop of prev) {
if (!nextSet.has(prop)) onColumnHidden(prop)
}
},
{ deep: true }
)
}
return {
filters,
hasActiveFilters,
activeFilterProps,
setFilterState,
clearFilter,
clearAllFilters,
resetToInitial,
isFilterActive,
onColumnHidden,
toQueryParams,
watchVisibleColumns
}
}
```
说明:`toQueryParams` 本页 datetime/number/enum 写死映射到报销 API 字段名(`applyDateFrom`/`amountOp`/`statusIn`)。若日后推广,可在 `ColumnFilterDef` 增加可选 `queryKey` 映射;本轮 YAGNI,仅在注释标明。
- [ ] **Step 3: Commit**
```bash
git add web-admin/src/composables/useClientColumnFilters.ts web-admin/src/composables/useServerColumnFilters.ts
git commit -m "feat(web-admin): add useServerColumnFilters for list column queries."
```
---
### Task 4: 改造 `ColumnHeaderFilter` 文本模式
**Files:**
- Modify: `web-admin/src/components/business/column-header-filter/index.vue`
- [ ] **Step 1: text 面板改为模糊输入**
将 `type === 'text'` 从「搜索可选值 + 勾选列表」改为:
```vue
<template v-if="type === 'text'">
<div class="field">
<label>模糊检索</label>
<el-input
v-model="draftText"
clearable
placeholder="输入关键字,包含即匹配"
size="small"
@keyup.enter="confirm"
/>
</div>
<p class="field-tip">整库模糊匹配本列</p>
</template>
<template v-else-if="type === 'enum'">
<!-- 保留原勾选列表;options 来自 enumOptions;可去掉「本页」文案,改为「可选值」 -->
...
</template>
```
- [ ] **Step 2: draft / reset / buildState**
```typescript
const draftText = ref('')
// resetDraftFromModel for text:
draftText.value = (m as TextColumnFilterState)?.text || ''
// buildState for text:
return { text: draftText.value.trim() }
// typeHint for text:
'文本:输入关键字模糊匹配(整库)'
```
enum 仍用 `draftSelected` + `selected`。
保留既有 `onDatePickerVisibleChange` / `holdPopoverOpen` 逻辑。
- [ ] **Step 3: Commit**
```bash
git add web-admin/src/components/business/column-header-filter/index.vue
git commit -m "feat(web-admin): use fuzzy text input in column header filter."
```
---
### Task 5: 改造【报销申请】页面
**Files:**
- Modify: `web-admin/src/api/bx/index.ts`
- Modify: `web-admin/src/views/bx/reimburse/apply/index.vue`
- [ ] **Step 1: 扩展 `reimburseApi.my` 类型**
```typescript
my(params: {
page: number
pageSize: number
status?: string
statusIn?: string
keyword?: string
applyCode?: string
title?: string
expenseName?: string
currentNode?: string
amountOp?: string
amount?: string
amountMin?: string
amountMax?: string
} & BxApplyDateFilter) {
return bxRequest.get('/reimburses/my', { params })
}
```
- [ ] **Step 2: 删除顶部 `filter-block` 整块模板与 `filterForm` / `handleReset` / `useDbInstantSearch` 对 filterForm 的绑定**
保留工具栏 `ListQuickSearchInput`。
- [ ] **Step 3: 用 `useServerColumnFilters` 替换 `useClientColumnFilters`**
```typescript
const columnFilterDefs: ColumnFilterDef[] = [
{ prop: 'applyTime', label: '申请时间', type: 'datetime' },
{ prop: 'applyCode', label: '报销单号', type: 'text' },
{ prop: 'title', label: '报销标题', type: 'text' },
{ prop: 'expenseName', label: '费用项目', type: 'text' },
{ prop: 'amount', label: '报销金额(元)', type: 'number' },
{
prop: 'status',
label: '状态',
type: 'enum',
enumOptions: [
{ value: 'draft', label: '待提交' },
{ value: 'pending', label: '审批中' },
{ value: 'approved', label: '审批通过' },
{ value: 'rejected', label: '审批驳回' }
]
},
{ prop: 'currentNode', label: '当前节点', type: 'text' }
]
function runListSearch() {
search(buildSearchQuery())
nextTick(() => recalcTableHeight())
}
const {
filters,
hasActiveFilters,
setFilterState,
clearAllFilters,
isFilterActive,
toQueryParams,
resetToInitial,
watchVisibleColumns
} = useServerColumnFilters(columnFilterDefs, {
initialFilters: {}, // 本页无默认;他页迁移顶部默认写这里
onChange: () => runListSearch()
})
// 表格 :data="rows"(不要再用 filteredRows)
```
列头 `ColumnHeaderFilter`:
- text/enum:`:model-value="filters.xxx || null"`,enum 的 `:options` 改为由 `enumOptions` 映射为 `{ value, label, count: 0 }`(或组件内 enum 不依赖 count)
- `@update:model-value="(v) => setFilterState('xxx', v)"`(`setFilterState` 已触发 onChange)
注意:避免「确定」时 `setFilterState` → `onChange` → search,与组件内 `close()` 打架;现逻辑即可。
- [ ] **Step 4: `buildSearchQuery`**
```typescript
function buildSearchQuery() {
const q: Record<string, string> = { ...toQueryParams() }
const kw = quickSearchKeyword.value.trim()
if (kw) q.keyword = kw
return q
}
```
快捷搜索:`handleQuickSearch` / `handleQuickSearchClear` 调 `runListSearch()`,**不要**清空列条件。
「清空列过滤」按钮:`clearAllFilters()`(已 onChange)。
原 `handleSearch` 若被工具栏刷新使用:改为 `runListSearch()`,且**不要**再清空 `quickSearchKeyword`(或刷新保留两边条件)。
- [ ] **Step 5: 藏列清条件**
```typescript
const visibleFilterProps = computed(() => {
const props: string[] = ['applyCode'] // 报销单号列不可隐藏时始终算可见
for (const def of listColumnDefs) {
if (isColumnVisible(def.prop)) props.push(def.prop)
}
return props
})
watchVisibleColumns(visibleFilterProps)
```
「重置列显示」:
```typescript
function handleResetColumns() {
resetToDefault()
resetToInitial()
}
```
模板 `@reset-columns="handleResetColumns"`。
- [ ] **Step 6: enum options 供给**
```typescript
function enumOptionsFor(prop: string) {
const def = columnFilterDefs.find((d) => d.prop === prop)
return (def?.enumOptions || []).map((o) => ({ ...o, count: 0 }))
}
```
状态列:`:options="enumOptionsFor('status')"`,去掉 `collectUniqueOptions`。
- [ ] **Step 7: 手工验收清单(本地 dev)**
1. 无顶部筛选区。
2. 金额介于、状态多选、单号模糊、日期段均跨页生效,`total` 变化。
3. 快捷搜索与列条件 AND。
4. 隐藏「状态」列会清状态条件并刷新。
5. 导出与当前筛选一致。
6. 选申请时间开始日期后弹窗不关,可继续选结束日期。
- [ ] **Step 8: Commit**
```bash
git add web-admin/src/api/bx/index.ts web-admin/src/views/bx/reimburse/apply/index.vue
git commit -m "feat(bx): server-side column filters on reimburse apply list."
```
- [ ] **Step 9: 更新知识图谱**
```bash
./scripts/update-graph.sh all
```
(若只改了前端/后端之一,可只更新对应侧。)
---
## Spec 覆盖自检
| Spec 要求 | Task |
|-----------|------|
| 去顶部 filter-block | Task 5 |
| 列头 + 快捷搜索 AND | Task 5 `buildSearchQuery` |
| 日期段 / 数字 / 枚举 / 文本模糊 | Task 4 + 5;后端 Task 1–2 |
| 整库服务端、total 正确 | Task 2 + 5 |
| 原顶部字段均有列(本页已有) | 无需补列 |
| 默认值迁移(本页空) | `initialFilters: {}` |
| 藏列清条件并重查 | Task 3 `onColumnHidden` + Task 5 watch |
| 再显示不恢复条件;重置列恢复 initial | Task 5 `handleResetColumns` |
| 导出同参 | Task 2 body + Task 5 `getReimburseApplyExportFilterParams` |
| 日期弹窗不误关 | Task 4 保留既有 fix |
**无占位符:** 计划内无 TBD;`toQueryParams` 本页写死 API 字段名已标明。
---
## 执行交接
Plan complete and saved to `docs/superpowers/plans/2026-09-06-reimburse-apply-server-column-filters.md`.
**两种执行方式:**
1. **Subagent-Driven(推荐)** — 每任务新开子代理,任务间复查,迭代快
2. **Inline Execution** — 本会话按 executing-plans 连续执行,设检查点
选哪一种?