docs: add implementation plan for CRM customer column filters.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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** — 本会话连续执行
|
||||
|
||||
选哪一种?
|
||||
Reference in new issue
Block a user