Files
yunke/docs/superpowers/specs/2026-08-07-yunke-staff-chat-stats-design.md
T

130 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 微信员工 沟通数/及时回复 5 数字 modal(Phase A)设计
> 日期:2026-08-07|分支:`feat/yunke-data-mirror`|路线图 #2 Phase A
> 前置:Plan 1(StaffList 微信员工列表已对齐云客,含 `YkStaffDetailModal` 通用件、数字→modal 模式);message 表已同步(含 `owner_account_id`/`talker_id`/`is_send`/`timestamp`)。
> 数据来源:**全本地 message 表聚合**(用户定,符合 [[yunke-verify-data-in-browser]] 全本地原则;数字口径**近似**云客——已与用户确认接受)。
## 1. 目标与范围
**目标**:StaffList(微信员工列表)每行的「沟通数」「及时回复」复合列里 **5 个数字可点 → 弹 modal 展示对应客户列表**,对齐云客。数据**从本地 message 表实时聚合**(按 员工微信 × 客户 × 日期窗),运行时不碰云客。
**5 个数字**(云客 `/cms/wechat/wechatlist` 微信员工列表,真机核实 2026-08-07):
- 沟通数:沟通人数 / 有效沟通数 / 跟进客户数
- 及时回复:员工最后回复数 / 未回复客户数
**在本 plan(Phase A)**:
- 后端:新聚合 endpoint `GET /api/staff-chat-stats`(per 员工微信 + 日期窗 → 5 个汇总数 + 客户明细行)。
- 前端:StaffList 5 个数字可点 → 复用 `YkStaffDetailModal` 弹对应客户子集 + 日期选择器(默认近 3 天)。
**不在本 plan(Phase B / 后续)**:
- 员工姓名 → 员工详情(降级 drawer)、主 badge → 聊天窗口(Phase B,小前端)。
- modal 内「客户微信/员工微信 → 聊天记录」二级可点(Phase B/后)。
- WA 员工表同类数字 modal(WA 员工表数字 modal 待复核,本 plan 只做微信员工)。
- 与云客数字**精确对齐**(口径近似,见 §4)。
## 2. 数据来源(真机核实)
云客微信员工列表两个接口(2026-08-07 真机):
- 行的 5 数字 ← `POST /wechatweb/statistics/wxStatsByTypes`(per 员工聚合,固定窗)。
- 数字 modal 内容 ← `POST /wechatweb/statistics/wxChatInfo`(body `{weChatId, userId, ymdS, ymdE, page, size}`;**5 个 modal 共用此 endpoint**,仅筛选/展示不同;modal 带日期选择器 = 动态窗)。响应每条 = 一个客户 + `sendNum`/`receiveNum`/`noResponse`/`responseCount`/`saleSendLastNum`/`customerSendLastNum` 等字段。
**我们的方案**(用户定):**不抓 wxStatsByTypes/wxChatInfo**,从本地 `message` 表实时聚合(动态日期窗没法预抓,且全本地原则)。message 表字段齐(smoke.db 实测 wechat 消息 `owner_account_id`/`talker_id`/`is_send`/`timestamp` 全非空)。
## 3. 数据模型 / 聚合
**无新表、无新索引**。聚合在查询时算。现有索引 `ix_message_channel_owner_talker_time(channel, owner_account_id, talker_id, timestamp)` 已覆盖(**不需要 Alembic 迁移**)。
**只聚合 1:1 私聊**(对齐云客 `wxChatInfo` 是员工×客户对,不含群):聚合 SQL 必须 `WHERE channel='wechat' AND owner_account_id=:owner AND (room_id IS NULL OR room_id = '') AND timestamp BETWEEN :start AND :end`,再 `GROUP BY talker_id`。**漏 room_id 过滤会把群消息算进客户统计**(talker 在群消息里是群成员,非客户对)。
**NULL 语义**:`timestamp IS NULL` 的行排除(窗口/排序都依赖它);`is_send IS NULL` 的行不计入 send/receive(未知方向,skip)。
**每个客户(talker_id)的聚合字段**(窗口内私聊):
- `send_count` = `is_send` True 的消息数(员工发)
- `receive_count` = `is_send` False 的消息数(客户发)
- `interaction_days` = 不同 `timestamp` 日期数(**跨库日期提取**:用 SQLAlchemy 在 epoch-秒 上算 `func.date(...)`,MySQL `DATE(FROM_UNIXTIME(ts/1000))` / SQLite `date(ts/1000,'unixepoch')`——不要写裸 SQL)
- `last_is_send` = 窗口内最后一条(按 timestamp)消息的 `is_send`(True=员工最后发,False=客户最后发)
- `last_time` = 最后一条时间
- 客户身份:`talker_id` → left join `customer` 表取 `friend_nickname`/`friend_remark`/`real_name`(缺失则用 talker_id)。
> 注:`message.is_send` 是 `Boolean`(过滤用 `is_(True)/is_(False)`,不是 `==1`);`timestamp` 是 `BigInteger` ms-epoch,nullable。
## 4. 5 数字口径(近似,从聚合字段派生)
| 数字 | 口径(本系统) | 云客原口径 |
|---|---|---|
| 沟通人数 `chat_count` | 窗口内 ≥1 条消息的去重客户数 | 同(去重沟通客户) |
| 有效沟通数 `effective_count` | 双向客户数(`send_count≥1 且 receive_count≥1`) | 双向有效沟通 |
| 跟进客户数 `follow_count` | 窗口内 `interaction_days≥2` 的客户数(**近似**,跟进精确口径云客未公开) | 跟进(精确口径未知) |
| 员工最后回复数 `last_reply_count` | `last_is_send=1` 的客户数(员工最后发) | 员工最后回复 |
| 未回复客户数 `no_reply_count` | `last_is_send=0` 的客户数(客户最后发、员工未回) | 未回复客户 |
> **近似声明**:跟进客户数口径为合理近似(云客精确标准未公开);其余 4 个与云客语义一致,但**数字可能因日期窗/数据完整性略有出入**。modal 内显示口径说明(小字)。
## 5. API
`GET /api/staff-chat-stats`(新 router,镜像现有 list endpoints 风格)
- query:`owner_account_id`(必填,员工微信号=wxid)、`start?`/`end?`(ms-epoch,默认近 3 天)。
- 响应:
```json
{
"owner_account_id": "wxid_...",
"start": ..., "end": ...,
"summary": { "chat_count": N, "effective_count": N, "follow_count": N, "last_reply_count": N, "no_reply_count": N },
"customers": [
{ "talker_id": "...", "friend_name": "...", "send_count": N, "receive_count": N,
"interaction_days": N, "last_is_send": 0|1, "last_time": <ms> }
]
}
```
- 前端 `getStaffChatStats({owner_account_id, start, end})`。
- 聚合一条 SQL(GROUP BY talker_id)+ 5 个汇总在 Python 侧从 customers 派生(或 SQL 同时算)。
## 6. 前端(StaffList + modal)
> **事实(plan 核实)**:StaffList.vue 的 5 个数字 cell **点击 handler 已存在**(现都调 `openWechatCommModal`,数据源 `getCustomers`)。Phase A 是**换数据源**(→ `getStaffChatStats`)+ 换每行 summary 数字来源,**不是加点击接线**。
- **每行 5 个数字**:现来自上游 camelCase 字段(`chatCount`/`contactCount`/`followCount`/`saleSendLastNum`/`customerSendLastNum`)。Phase A 改为:每行(按 `row.account_id` = 员工微信号)调 `getStaffChatStats({owner_account_id: row.account_id, start, end})`(默认近 3 天)取 `summary`,5 个数字映射到 `chat_count/effective_count/follow_count/last_reply_count/no_reply_count`。
- **员工最后回复率**(现 `saleSendLastNumRate`,line ~545)是比率非计数、无 Phase-A 本地等价 → **保持现状不动**(不可点),只有 5 个**计数**变本地可点。
- **点数字 → 复用 `YkStaffDetailModal`**(Plan 1 通用件,`fetcher: (page,page_size)=>Page<T>` 服务端分页):
- **分页适配(重要)**:本 endpoint 返回扁平 `{summary, customers[]}`(窗口内客户通常几十个,无需服务端分页)。StaffList 一次性取 `customers`,**在 fetcher 闭包里按 metric 过滤 + 切页包成合成 `Page<T>`** 传给 modal(不动 `YkStaffDetailModal` 的通用分页契约)。
- metric 过滤:沟通人数=全集 / 有效沟通=`send≥1&&receive≥1` / 跟进=`interaction_days≥2` / 最后回复=`last_is_send=true` / 未回复=`last_is_send=false`。
- modal 列(对齐云客):序号 / 客户微信(昵称+号) / [有效沟通 modal 多 发送数/接收数 列] / 员工信息 / 员工微信。
- **日期选择器放 StaffList**(不放 `YkStaffDetailModal`,保持其通用):StaffList 持 `start/end` 状态,传进 fetcher 闭包;改日期 → 重查 `getStaffChatStats`。
- modal 标题对齐云客(沟通人数(已去重) / 有效沟通数(已去重) / 跟进客户数 / 员工最后回复数 / 未回复客户数)+ 近似口径小字说明(跟进为近似口径)。
## 7. 关键决策(已与用户确认 2026-08-07)
1. **全本地消息聚合**(不抓 wxStatsByTypes/wxChatInfo):从 message 表算。
2. **口径近似**:跟进客户数用合理近似;数字可能与云客略出入,用户接受。
3. **范围 Phase A**:只做 5 数字 modal;姓名 drawer + 主badge + modal 内二级可点 → Phase B。
4. **默认窗近 3 天**(对齐云客 modal 默认 ymdS/ymdE)。
5. **无新表**:聚合在查询时算(动态窗不宜预存)。
## 8. 风险
- **数据完整性**:smoke.db 微信消息稀疏(4 员工×6 客户×399 条),数字小但机制成立;真实部署数据全后数字才有意义。
- **跟进口径近似**:与云客可能不一致(已声明)。
- **性能**:每行一次聚合;现有复合索引 `ix_message_channel_owner_talker_time(channel, owner_account_id, talker_id, timestamp)` 已覆盖本查询(plan 用 EXPLAIN 验证即可,**不加新索引/无 Alembic**)。
- **StaffList 行 ↔ owner_account_id 映射**:确认 StaffList 行有员工微信号字段(plan 核实 Plan 1 的 row shape)。
## 9. 文件清单(预期)
**后端**(路径前缀 `yunke-server/`):
- 新 `yunke-server/app/api/routers/staff_chat_stats.py`(`/api/staff-chat-stats` 聚合查询,含 room_id 私聊过滤)
- 改 `yunke-server/app/api/main.py`(注册 router)
- 改 `yunke-server/app/api/schemas.py`(`StaffChatStatsOut`/`StaffChatCustomerOut`)
- **不需要 Alembic**(复用现有 `ix_message_channel_owner_talker_time` 索引)
- 新测试 `yunke-server/tests/test_api_staff_chat_stats.py`(种私聊+群消息 → 验证群被过滤、5 数字 + 客户行 + 日期窗正确)
**前端**:
- 改 `yunkes/src/api/index.ts` + `types.ts`:`getStaffChatStats` / `StaffChatStats` / `StaffChatCustomer` 类型
- 改 `yunkes/src/views/StaffList.vue`:**换 5 数字数据源**(handler 已存在)→ `getStaffChatStats` summary;日期选择器状态 + fetcher 闭包(含 metric 过滤 + 合成分页);员工最后回复率保持不动
- `YkStaffDetailModal.vue` **不改**(保持通用)—— StaffList 侧包装 fetcher 适配
## 10. 验收
- 后端测试全绿(含 `test_api_staff_chat_stats.py`:种若干 message → summary 5 数字 + customers 字段正确;日期窗过滤生效)。
- `vue-tsc -b` exit 0。
- 浏览器:StaffList 微信员工行 5 数字显示且可点 → modal 弹对应客户子集;切日期窗重查;列对齐云客。
- 数据来自本地 message(network 只打 `/api/staff-chat-stats`,不碰云客)。