diff --git a/docs/superpowers/specs/2026-08-07-yunke-staff-chat-stats-design.md b/docs/superpowers/specs/2026-08-07-yunke-staff-chat-stats-design.md index 2838ffd..6a3c5ed 100644 --- a/docs/superpowers/specs/2026-08-07-yunke-staff-chat-stats-design.md +++ b/docs/superpowers/specs/2026-08-07-yunke-staff-chat-stats-design.md @@ -32,17 +32,21 @@ ## 3. 数据模型 / 聚合 -**无新表**。聚合在查询时算(`message` 表按 `owner_account_id` + 日期窗 GROUP BY `talker_id`)。 +**无新表、无新索引**。聚合在查询时算。现有索引 `ix_message_channel_owner_talker_time(channel, owner_account_id, talker_id, timestamp)` 已覆盖(**不需要 Alembic 迁移**)。 -**每个客户(talker_id)的聚合字段**(窗口内): -- `send_count` = `is_send=1` 消息数(员工发) -- `receive_count` = `is_send=0` 消息数(客户发) -- `interaction_days` = 不同 `timestamp` 日期数 -- `last_is_send` = 窗口内最后一条消息的 `is_send`(1=员工最后发,0=客户最后发) +**只聚合 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` → 关联 `customer` 表取 `friend_nickname`/`friend_remark`/`real_name`(left join,缺失则用 talker_id)。 +- 客户身份:`talker_id` → left join `customer` 表取 `friend_nickname`/`friend_remark`/`real_name`(缺失则用 talker_id)。 -**建议索引**:`message(owner_account_id, timestamp)` 加速(若不存在,plan 里加 Alembic 迁移;先确认现有索引)。 +> 注:`message.is_send` 是 `Boolean`(过滤用 `is_(True)/is_(False)`,不是 `==1`);`timestamp` 是 `BigInteger` ms-epoch,nullable。 ## 4. 5 数字口径(近似,从聚合字段派生) @@ -77,17 +81,16 @@ ## 6. 前端(StaffList + modal) -- StaffList 每行(员工微信)「沟通数」「及时回复」复合列:把现有静态数字(若有)改/补成**可点** 5 个数字。每行加载时调 `getStaffChatStats({owner_account_id: row., start, end})`(默认近 3 天)填 summary。 - - 性能:每页 N 行 × 1 次聚合;首版顺序调用可接受(消息表有索引),或后续优化为批量 endpoint。 -- 点数字 → 复用 `YkStaffDetailModal`(Plan 1 通用件,支持自定义列 + 数据源)弹客户子集: - - 沟通人数 → customers 全集 - - 有效沟通 → 过滤 `send≥1 && receive≥1` - - 跟进 → 过滤 `interaction_days≥2` - - 最后回复 → 过滤 `last_is_send=1` - - 未回复 → 过滤 `last_is_send=0` - - modal 列(对齐云客):序号 / 客户微信(昵称+号) / [有效沟通多 发送数/接收数 列] / 员工信息 / 员工微信。modal 内客户微信可点→(Phase B)。 -- modal 顶部日期选择器(el-date-picker,默认近 3 天)→ 改 window 重查。 -- 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` 服务端分页): + - **分页适配(重要)**:本 endpoint 返回扁平 `{summary, customers[]}`(窗口内客户通常几十个,无需服务端分页)。StaffList 一次性取 `customers`,**在 fetcher 闭包里按 metric 过滤 + 切页包成合成 `Page`** 传给 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) @@ -106,17 +109,17 @@ ## 9. 文件清单(预期) -**后端**: -- 新 `app/api/routers/staff_chat_stats.py`(`/api/staff-chat-stats` 聚合查询) -- 改 `app/api/main.py`(注册 router) -- 改 `app/api/schemas.py`(`StaffChatStatsOut`/`StaffChatCustomerOut`) -- (若需)Alembic 加 `message(owner_account_id, timestamp)` 索引 -- 新测试 `tests/test_api_staff_chat_stats.py`(种消息 → 聚合 5 数字 + 客户行正确) +**后端**(路径前缀 `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` 类型 -- 改 `yunkes/src/views/StaffList.vue`:5 数字可点 + 挂 modal -- 改/复用 `yunkes/src/components/YkStaffDetailModal.vue`:支持日期选择器 + 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. 验收