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

11 KiB
Raw Blame History

微信员工 沟通数/及时回复 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 天)。
  • 响应:
{
  "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,不碰云客)。