diff --git a/docs/superpowers/plans/2026-09-06-crm-customer-server-column-filters.md b/docs/superpowers/plans/2026-09-06-crm-customer-server-column-filters.md new file mode 100644 index 000000000..e3859010a --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-crm-customer-server-column-filters.md @@ -0,0 +1,378 @@ +# CRM 客户域列头整库服务端筛选(首批)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:** 通用化列头服务端筛 composable;客户管理 + 客户公海主列表去掉顶部筛、挂齐列头漏斗并打整库;报销申请仅补 queryKey 配置并回归。 + +**Architecture:** 扩展 `ColumnFilterDef` 的 `queryKey*`;`toQueryParams` 按类型生成参数。后端在 `list_customers` 集中扩展过滤(`*In`、文本、日期 From/To、数字 Op),公海 list/export 复用同一拼装并固定公海范围。前端两页对齐报销申请接线模式。 + +**Tech Stack:** Vue 3、Element Plus、`ColumnHeaderFilter` / `useServerColumnFilters`、FastAPI、SQLAlchemy、`CrmCustomer` + +**Spec:** `docs/superpowers/specs/2026-09-06-crm-customer-server-column-filters-design.md` + +--- + +## 文件结构 + +| 操作 | 文件 | 职责 | +|------|------|------| +| Modify | `web-admin/src/composables/useClientColumnFilters.ts` | `ColumnFilterDef` 增加 `queryKey` / `queryKeyFrom` / `queryKeyTo` / `queryKeyPrefix` | +| Modify | `web-admin/src/composables/useServerColumnFilters.ts` | 通用 `toQueryParams`(去掉报销写死) | +| Modify | `web-admin/src/views/bx/reimburse/apply/index.vue` | `columnFilterDefs` 显式 queryKey,行为不变 | +| Create | `backend/app/services/crm/customer_list_filters.py` | 客户列筛 → SQLAlchemy 条件拼装(纯函数,可单测) | +| Create | `backend/tests/test_crm_customer_list_filters.py` | 拼装单测 | +| Modify | `backend/app/schemas/crm/customer.py` | `CustomerListBody` + `to_list_kwargs` 扩展字段 | +| Modify | `backend/app/schemas/crm/customer_pool.py` | `PoolCustomerListBody` 对齐列筛字段 | +| Modify | `backend/app/crud/crm/customer.py` | `list_customers` / `list_pool_customers` 应用新过滤 | +| Modify | `backend/app/api/crm.py` | list/export 传参走扩展 body(若已 `to_list_kwargs` 则改 schema+crud 即可) | +| Modify | `web-admin/src/views/crm/customer/index.vue` | 去顶部筛、列头漏斗、藏列、导出 | +| Modify | `web-admin/src/views/crm/customer/pool/index.vue` | 同上 | + +**不变:** 其它 CRM 页、弹窗内表、公海规则页。 + +--- + +### Task 1: 通用化 `toQueryParams` + 报销 queryKey + +**Files:** +- Modify: `web-admin/src/composables/useClientColumnFilters.ts` +- Modify: `web-admin/src/composables/useServerColumnFilters.ts` +- Modify: `web-admin/src/views/bx/reimburse/apply/index.vue` + +- [ ] **Step 1: 扩展 `ColumnFilterDef`** + +在 `useClientColumnFilters.ts` 的 `ColumnFilterDef` 增加可选: + +```typescript + queryKey?: string + queryKeyFrom?: string + queryKeyTo?: string + queryKeyPrefix?: string +``` + +- [ ] **Step 2: 重写 `toQueryParams`** + +在 `useServerColumnFilters.ts` 替换硬编码逻辑: + +```typescript +function resolveQueryKey(def: ColumnFilterDef): string { + return def.queryKey || def.prop +} + +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 + const key = resolveQueryKey(def) + + if (def.type === 'datetime') { + const s = state as DatetimeColumnFilterState + const fromKey = def.queryKeyFrom || `${key}From` + const toKey = def.queryKeyTo || `${key}To` + if (s.from) q[fromKey] = s.from + if (s.to) q[toKey] = s.to + continue + } + if (def.type === 'number') { + const s = state as NumberColumnFilterState + const prefix = def.queryKeyPrefix || key + q[`${prefix}Op`] = s.op || 'between' + if (s.op === 'between') { + if (s.min !== '' && s.min != null) q[`${prefix}Min`] = String(s.min) + if (s.max !== '' && s.max != null) q[`${prefix}Max`] = String(s.max) + } else if (s.value !== '' && s.value != null) { + q[prefix] = String(s.value) + } + continue + } + if (def.type === 'enum') { + const s = state as TextColumnFilterState + if (s.selected?.length) q[`${key}In`] = s.selected.join(',') + continue + } + const s = state as TextColumnFilterState + const text = String(s.text || '').trim() + if (text) q[key] = text + } + return q +} +``` + +更新文件头注释:说明 queryKey 约定,删除「本页仅报销」表述。 + +- [ ] **Step 3: 报销页显式 queryKey** + +```typescript +const columnFilterDefs: ColumnFilterDef[] = [ + { + prop: 'applyTime', + label: '申请时间', + type: 'datetime', + queryKeyFrom: 'applyDateFrom', + queryKeyTo: 'applyDateTo' + }, + { prop: 'applyCode', label: '报销单号', type: 'text', queryKey: 'applyCode' }, + { prop: 'title', label: '报销标题', type: 'text', queryKey: 'title' }, + { prop: 'expenseName', label: '费用项目', type: 'text', queryKey: 'expenseName' }, + { + prop: 'amount', + label: '报销金额(元)', + type: 'number', + queryKeyPrefix: 'amount' + }, + { + prop: 'status', + label: '状态', + type: 'enum', + queryKey: 'status', + enumOptions: [ /* 保持原样 */ ] + }, + { prop: 'currentNode', label: '当前节点', type: 'text', queryKey: 'currentNode' } +] +``` + +- [ ] **Step 4: Commit** + +```bash +git add web-admin/src/composables/useClientColumnFilters.ts \ + web-admin/src/composables/useServerColumnFilters.ts \ + web-admin/src/views/bx/reimburse/apply/index.vue +git commit -m "refactor(web-admin): generalize server column filter queryKey mapping." +``` + +--- + +### Task 2: 后端客户列筛拼装 + 单测 + +**Files:** +- Create: `backend/app/services/crm/customer_list_filters.py` +- Create: `backend/tests/test_crm_customer_list_filters.py` + +说明:`backend/app/services/crm/` 若无 `__init__.py` 则一并创建空文件。 + +- [ ] **Step 1: 写失败单测** + +```python +# backend/tests/test_crm_customer_list_filters.py +from sqlalchemy.dialects import mysql +from app.services.crm.customer_list_filters import apply_customer_column_filters +from app.models.crm_models import CrmCustomer # 以仓库实际模型路径为准 + + +def _compile(clause) -> str: + return str( + clause.compile(dialect=mysql.dialect(), compile_kwargs={"literal_binds": True}) + ).lower() + + +def test_level_in(): + from sqlalchemy.orm import Query + # 更简单:让 apply 返回额外 filter 列表,或接受 query 并返回 query + ... +``` + +**推荐 API 形状**(与报销 builder 类似,返回 filter list,由 crud 挂到 query): + +```python +def build_customer_column_filters(**params) -> list: + """根据列筛参数返回 SQLAlchemy 二元表达式列表(不含 tenant/del_flag/view_mode)。""" +``` + +单测覆盖至少: + +- `level_in="key,normal"` → IN +- `customer_name` 模糊 +- `deal_amount` between min only +- `create` 用 `begin_time`/`end_time`(或 create_time_start/end) +- 空参数 → `[]` + +先确认模型 import:`CrmCustomer` 路径用 `rg "class CrmCustomer" backend`。 + +- [ ] **Step 2: 实现 `build_customer_column_filters`** + +支持(与 spec §6.2 对齐,参数用 snake_case): + +- 单值与 `*_in`(逗号拆分):customer_type、level、source、status、deal_status、park_area、industry、scale +- 文本 like:customer_name(含拼音首字母,可复用现 `list_customers` 逻辑片段)、customer_no、short_name、mobile、telephone、email、website、address、province、city、district、owner_user_name、create_by_name、update_by_name、create_dept_name、remark +- 日期闭区间:create(begin/end 或 from/to)、last_time、next_time、deal_time、into_pool_time、claim_time、protect_time、update_time +- 数字:deal_amount / follow_count / contact_count / opportunity_count / contract_count 的 op+min/max/value + +`owner_user_name` 等若库表无直接列、仅有展示字段:实现时对照 `list_customers` 现有 join/字段;**无列可筛则跳过并在 commit message / DONE_WITH_CONCERNS 注明**,优先筛 `CrmCustomer` 上真实列。 + +- [ ] **Step 3: pytest PASS** + +```bash +cd backend && source venv/bin/activate && PYTHONPATH=. pytest tests/test_crm_customer_list_filters.py -v +``` + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/services/crm/customer_list_filters.py \ + backend/app/services/crm/__init__.py \ + backend/tests/test_crm_customer_list_filters.py +git commit -m "feat(crm): add customer list column filter builder." +``` + +--- + +### Task 3: Schema + CRUD + API 接线(客户 / 公海) + +**Files:** +- Modify: `backend/app/schemas/crm/customer.py` +- Modify: `backend/app/schemas/crm/customer_pool.py` +- Modify: `backend/app/crud/crm/customer.py` +- Modify: `backend/app/api/crm.py`(仅当 export/list 未走 `to_list_kwargs` 时) + +- [ ] **Step 1: 扩展 `CustomerListBody`** + +增加与前端 camelCase 对齐的 Field(`alias=`),例如: + +- `customerTypeIn`、`levelIn`、`sourceIn`、`statusIn`、`dealStatusIn`、`parkAreaIn`、`industryIn`、`scaleIn` +- 文本:`shortName`、`mobile`、… +- 日期:`lastTimeFrom`/`To`、…;创建时间继续 `beginTime`/`endTime` +- 数字:`dealAmountOp`、`dealAmount`、`dealAmountMin`、`dealAmountMax`,及其它 count 字段 + +更新 `to_list_kwargs()` 把新字段传入 CRUD。 + +- [ ] **Step 2: 扩展 `PoolCustomerListBody`** + +与客户列筛字段对齐(或嵌入/复用同一套 Optional 字段);`list_pool` / export 映射到同一 kwargs,并强制公海视图。 + +- [ ] **Step 3: `list_customers` 调用 builder** + +在现有 keyword / view_mode / 旧单值过滤之后(或用 builder 替换重复单值逻辑): + +```python +for expr in build_customer_column_filters(...): + q = q.filter(expr) +``` + +注意:`statusIn` 与 `view_mode` 冲突时仍 AND;公海路径保持 `status=pool`(或 view_mode=pool),列上再筛 status 时需产品可接受(公海页可不挂 status 漏斗或禁用与 pool 冲突的选项)——**公海页 status 列若恒为公海,漏斗可仍挂 enum,选非 pool 则结果为空,属预期。** + +- [ ] **Step 4: 确认 export 使用同一 kwargs** + +读 `backend/app/api/crm.py` 中 customer export / pool export,确保 body → `to_list_kwargs` → `list_customers`,无旁路旧过滤。 + +- [ ] **Step 5: 跑相关单测 + Commit** + +```bash +PYTHONPATH=. pytest tests/test_crm_customer_list_filters.py -v +git add backend/app/schemas/crm/customer.py backend/app/schemas/crm/customer_pool.py \ + backend/app/crud/crm/customer.py backend/app/api/crm.py +git commit -m "feat(crm): wire customer and pool list filters to column params." +``` + +--- + +### Task 4: 客户管理页 UI + +**Files:** +- Modify: `web-admin/src/views/crm/customer/index.vue` + +- [ ] **Step 1: 删除 `filter-block` 模板与 `searchForm` / `dateRange` 驱动的顶部搜索** + +保留:`el-tabs`、`ListQuickSearchInput`、工具栏。 + +- [ ] **Step 2: 增加 `columnFilterDefs` + `useServerColumnFilters`** + +按 spec 为每列声明 type;字典列: + +```typescript +enumOptions: dictStore.getOptions('crm_customer_type').map(...) +``` + +创建时间: + +```typescript +{ + prop: 'createTime', + type: 'datetime', + queryKeyFrom: 'beginTime', + queryKeyTo: 'endTime' +} +``` + +其它日期用默认 `${prop}From`/`To`(与后端 alias 一致,如 `lastTimeFrom`)。 + +- [ ] **Step 3: 各业务列 header 挂 `ColumnHeaderFilter`** + +模式同报销申请;enum 传 `enumOptionsFor(prop)`;text 不传 options。 + +- [ ] **Step 4: `buildListParams` / 查询** + +```typescript +function buildListParams() { + return { + page: pagination.page, + pageSize: pagination.pageSize, + viewMode: activeView.value, + keyword: quickSearchKeyword.value.trim() || undefined, + ...toQueryParams() + } +} +``` + +列变更 `onChange` → `page=1` 再 `loadList`。 +`watchVisibleColumns`;`handleResetColumns` = `resetToDefault` + `resetToInitial`。 +导出 `get-filter-params` 用同一套(不含 page 或按现网导出约定)。 + +- [ ] **Step 5: 工具栏「清空列过滤」按钮**(`hasActiveFilters` 时显示) + +- [ ] **Step 6: Commit** + +```bash +git add web-admin/src/views/crm/customer/index.vue +git commit -m "feat(crm): server column filters on customer list." +``` + +--- + +### Task 5: 客户公海页 UI + +**Files:** +- Modify: `web-admin/src/views/crm/customer/pool/index.vue` + +- [ ] **Step 1–5:** 与客户页同模式:去 `filter-block`;`columnFilterDefs`(可抽共享常量到 `web-admin/src/views/crm/customer/columnFilterDefs.ts` 若两页重复过多——**允许**新建该文件以 DRY);无 Tab;请求 `fetchPoolCustomerList`;导出同参。 + +- [ ] **Step 6: Commit** + +```bash +git add web-admin/src/views/crm/customer/pool/index.vue \ + web-admin/src/views/crm/customer/columnFilterDefs.ts # 若创建 +git commit -m "feat(crm): server column filters on customer pool list." +``` + +- [ ] **Step 7: 更新图谱** + +```bash +./scripts/update-graph.sh all +``` + +--- + +## Spec 覆盖自检 + +| Spec | Task | +|------|------| +| queryKey 通用化 + 报销回归 | Task 1 | +| 后端 *In / 文本 / 日期 / 数字 | Task 2–3 | +| 客户去顶部筛 + 漏斗 + Tab/快捷搜索 | Task 4 | +| 公海同上 | Task 5 | +| 藏列清条件、导出同参 | Task 4–5 | +| 弹窗/其它 CRM | 不做 | + +--- + +## 执行交接 + +Plan complete and saved to `docs/superpowers/plans/2026-09-06-crm-customer-server-column-filters.md`. + +**两种执行方式:** + +1. **Subagent-Driven(推荐)** — 每任务单独子代理,任务间复查 +2. **Inline Execution** — 本会话连续执行 + +选哪一种?