docs: add implementation plan for CRM customer column filters.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-09-06 08:14:54 +08:00
1 parent 16d382e6aa
commit 3ab447b735
1 file changed
+378
@@ -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<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
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** — 本会话连续执行
选哪一种?