feat(analytics): 首响时长+回复率数据管道——消息串算法/日汇总扩展列/员工聚合

批1(A1+A2):管理层要看「每个人的响应质量」。

- 迁移 d9e0f1a2b3c4:msg_daily_stats/stat_daily_staff 各加
  cust_threads/threads_answered/fr_sum_ms(NOT NULL server_default 0,
  幂等守卫照 9b0d0c133035 惯例;models.py 双声明同步)
- summary.py _thread_stats:串=客户连续消息不被员工消息打断的段,
  is_send=None 不参与串判定;fr=员工首条回复−串末条客户消息;
  日切近似 v1(日末未闭合串计 threads 不计 answered,口径写进 docstring)
- recompute_days/recompute_all 都先按 ts 排序再算串(乱序防护)
- staff_summary 聚合三列按账号→员工累加(fr_cnt≡threads_answered 不单列)
- 测试 +10:串算法矩阵 9 例+staff 聚合传递;全量 589 绿
- 本地 86 万行回填验证:daily-stats 60s/staff 10s,rollup 总和与
  明细完全一致,随机 5 行 SQL 独立复算全对

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-07 14:13:09 +08:00
parent 39791b2a49
commit feb3b98665
6 changed files with 359 additions and 5 deletions
+41
View File
@@ -205,3 +205,44 @@ async def test_recompute_all_and_rolling(make_client):
await make_client(seed)
days = staff_summary.rolling_days(20260904, window=3)
assert days == [20260902, 20260903, 20260904]
async def test_recompute_carries_thread_stats(make_client):
"""首响/回复率三列(2026-09-07 批1)按账号→员工累加传递;未映射账号同样保留。"""
async def seed(session):
session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三"))
session.add(WechatAccount(wx_id="wx2", user_id="u1", user_name="张三"))
rows = [
MsgDailyStats(channel="wechat", owner_account_id="wx1", peer_kind="friend",
peer_id="t1", day=20260904, send_cnt=10, recv_cnt=5,
last_ts=1756900000000, last_is_send=True,
cust_threads=4, threads_answered=3, fr_sum_ms=600_000),
MsgDailyStats(channel="wechat", owner_account_id="wx2", peer_kind="room",
peer_id="r1", day=20260904, send_cnt=2, recv_cnt=1,
last_ts=1756900001000, last_is_send=False,
cust_threads=2, threads_answered=1, fr_sum_ms=240_000),
# 旧数据行(迁移前):三列 server_default 兜 0,聚合不炸
MsgDailyStats(channel="wechat", owner_account_id="wx3", peer_kind="friend",
peer_id="t9", day=20260904, send_cnt=1, recv_cnt=0,
last_ts=1756900002000, last_is_send=True),
]
session.add_all(rows)
await session.commit()
await staff_summary.recompute_days(session, [20260904])
await session.commit()
from sqlalchemy import select
by_uid = {
r.user_id: r
for r in (await session.execute(select(StatDailyStaff))).scalars().all()
}
u1 = by_uid["u1"] # wx1+wx2 合并:threads=6, answered=4, fr_sum=840000
assert u1.cust_threads == 6
assert u1.threads_answered == 4
assert u1.fr_sum_ms == 840_000
assert by_uid["wx3"].cust_threads == 0 # 未传三列 → server_default 0
assert by_uid["wx3"].threads_answered == 0
assert by_uid["wx3"].fr_sum_ms == 0
await make_client(seed)