diff --git a/docs/superpowers/plans/2026-09-06-reimburse-apply-server-column-filters.md b/docs/superpowers/plans/2026-09-06-reimburse-apply-server-column-filters.md new file mode 100644 index 000000000..e0de32081 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-reimburse-apply-server-column-filters.md @@ -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 + onChange?: () => void + } +) { + const defMap = new Map(columnDefs.map((d) => [d.prop, d])) + const filters = reactive>({}) + + 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 { + const q: Record = {} + 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 当前可见的业务列 prop 列表。 + */ + function watchVisibleColumns(visibleProps: Ref) { + 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 + + + +``` + +- [ ] **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 = { ...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 连续执行,设检查点 + +选哪一种?