Files
yunke/yunke-server/tests/test_staff_summary.py
T
浅念 feb3b98665 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>
2026-09-07 14:13:09 +08:00

249 lines
10 KiB
Python
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.
"""stat_daily_staff 维护层:派生/幂等/判别法/未映射兜底。
判别法(9-03 先例):rollup 只读 msg_daily_stats——回填后删光 message 明细,
员工汇总重算仍出完整数据。
"""
from __future__ import annotations
import datetime as dt
from app.db.models import (
Message,
MsgDailyStats,
StatDailyStaff,
WechatAccount,
WhatsappAccount,
)
from app.storage import staff_summary
def _mk_msg(msg_id: str, ts: int, is_send: bool | None) -> Message:
return Message(
msg_id=msg_id,
channel="wechat",
owner_account_id="wx1",
talker_id="t1",
timestamp=ts,
is_send=is_send,
text="x",
fetched_at=dt.datetime(2026, 9, 4, 12, 0),
)
async def _seed_pair(session) -> None:
"""两账号同一员工(wx1/wx2→u1) + 未映射账号 wx3,20260904 各有消息。"""
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),
MsgDailyStats(channel="wechat", owner_account_id="wx1", peer_kind="room",
peer_id="r1", day=20260904, send_cnt=3, recv_cnt=0,
last_ts=1756900000000, last_is_send=True),
MsgDailyStats(channel="wechat", owner_account_id="wx2", peer_kind="friend",
peer_id="t1", day=20260904, send_cnt=2, recv_cnt=1,
last_ts=1756900001000, last_is_send=False),
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()
async def _seed_wa_and_days(session) -> None:
"""wechat 20260903/20260904 两天 + whatsapp 20260904 一天(uid→wa_u1)。"""
session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三"))
session.add(WhatsappAccount(uid="wa1@x", user_id="wa_u1", user_name="李四"))
rows = [
MsgDailyStats(channel="wechat", owner_account_id="wx1", peer_kind="friend",
peer_id="t1", day=20260903, send_cnt=4, recv_cnt=2,
last_ts=1756800000000, last_is_send=True),
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),
MsgDailyStats(channel="whatsapp", owner_account_id="wa1@x", peer_kind="friend",
peer_id="p1", day=20260904, send_cnt=7, recv_cnt=3,
last_ts=1756900005000, last_is_send=True),
]
session.add_all(rows)
await session.commit()
async def test_recompute_multi_day_and_whatsapp(make_client):
"""行按 (day, channel, user_id) 正确分键:wechat 两天各一行 + whatsapp 一行;
WA 走 WhatsappAccount.uid→user_id 映射(而非 wechat 账号表)。"""
async def seed(session):
await _seed_wa_and_days(session)
n = await staff_summary.recompute_days(session, [20260903, 20260904])
await session.commit()
assert n == 3 # wechat×2天 + whatsapp×1天(空 day×channel 不计)
from sqlalchemy import select
rows = (await session.execute(select(StatDailyStaff))).scalars().all()
keys = {(r.day, r.channel, r.user_id) for r in rows}
assert keys == {
(20260903, "wechat", "u1"),
(20260904, "wechat", "u1"),
(20260904, "whatsapp", "wa_u1"),
}
wa = next(r for r in rows if r.channel == "whatsapp")
assert wa.msg_sent == 7 and wa.msg_recv == 3 and wa.active_friends == 1
await make_client(seed)
async def test_stale_day_cleanup(make_client):
"""陈旧行清理:msg_daily_stats 里已消失的日期,重算后 stat 行必须消失。
- recompute_all 全表清空自愈:day 从 msg_daily_stats 整体消失(夜间
summary.recompute_all 清空重建的典型场景)→ distinct days 不含它,
只有全表清空能救;
- recompute_days 空日 delete-before-continue:day 仍在但某渠道行删光
→ 该 day×channel 的 stat 行删除,同日 wechat 行保留。
"""
async def seed(session):
from sqlalchemy import delete, select
await _seed_wa_and_days(session)
await staff_summary.recompute_all(session) # stat 先有 3 行
await session.commit()
# 场景 1:20260903 整天从 msg_daily_stats 消失 → recompute_all 自愈
await session.execute(
delete(MsgDailyStats).where(MsgDailyStats.day == 20260903)
)
n = await staff_summary.recompute_all(session)
await session.commit()
assert n == 2 # 只剩 20260904 的 wechat + whatsapp 两个 day×channel
days = set(
(await session.execute(select(StatDailyStaff.day))).scalars().all()
)
assert days == {20260904} # 20260903 陈旧行被清
# 场景 2:20260904 的 whatsapp 行删光 → 空日重算删行,wechat 保留
await session.execute(
delete(MsgDailyStats).where(MsgDailyStats.channel == "whatsapp")
)
await staff_summary.recompute_days(session, [20260904])
await session.commit()
keys = {
(r.day, r.channel)
for r in (await session.execute(select(StatDailyStaff))).scalars().all()
}
assert keys == {(20260904, "wechat")} # whatsapp 行消失,wechat 不动
await make_client(seed)
async def test_recompute_aggregates_by_user(make_client):
"""两账号同人合并(sent=10+3+2=15,recv=5+0+1=6;t1 在 wx1/wx2 都聊 → 合并后 distinct friend={t1} + room={r1});wx3 未映射按原值。"""
async def seed(session):
await _seed_pair(session)
await staff_summary.recompute_days(session, [20260904])
await session.commit()
from sqlalchemy import select
rows = (await session.execute(select(StatDailyStaff))).scalars().all()
by_uid = {r.user_id: r for r in rows}
assert set(by_uid) == {"u1", "wx3"} # wx3 映射不上按 owner 原值
u1 = by_uid["u1"]
assert u1.msg_sent == 15 and u1.msg_recv == 6
assert u1.active_friends == 1 # t1 跨账号去重
assert u1.active_rooms == 1
assert by_uid["wx3"].active_friends == 1
await make_client(seed)
async def test_recompute_idempotent(make_client):
async def seed(session):
await _seed_pair(session)
await staff_summary.recompute_days(session, [20260904])
await staff_summary.recompute_days(session, [20260904]) # 再跑不翻倍
await session.commit()
from sqlalchemy import func, select
n = (
await session.execute(
select(func.count()).select_from(StatDailyStaff)
)
).scalar_one()
assert n == 2 # u1 + wx3 各一行
await make_client(seed)
async def test_discriminant_survives_message_delete(make_client):
"""判别法:rollup 回填后删光 message,重算结果不变(只依赖 msg_daily_stats)。"""
async def seed(session):
await _seed_pair(session)
session.add(_mk_msg("m1", 1756900000000, True))
await session.commit()
await staff_summary.recompute_days(session, [20260904])
await session.commit()
from sqlalchemy import delete, select
await session.execute(delete(Message))
await staff_summary.recompute_days(session, [20260904]) # 自愈重算
await session.commit()
rows = (await session.execute(select(StatDailyStaff))).scalars().all()
by_uid = {r.user_id: r for r in rows}
assert by_uid["u1"].msg_sent == 15 # 数据不丢
await make_client(seed)
async def test_recompute_all_and_rolling(make_client):
async def seed(session):
await _seed_pair(session)
n = await staff_summary.recompute_all(session)
await session.commit()
assert n == 1 # 全库只有一个「有数据的 day×channel」
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)