feat(analytics): 管理层指标呈现——首响回复率上墙/客户资产总览/自定义日期/排行五新列

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-07 14:41:08 +08:00
parent feb3b98665
commit 085a9b4fd3
6 changed files with 606 additions and 43 deletions
+155 -3
View File
@@ -280,27 +280,37 @@ async def test_staff_ranking(make_client, monkeypatch):
async def test_staff_ranking_fallback_when_rollup_empty(make_client, monkeypatch):
"""stat_daily_staff 空表 → msg_total 回退 msg_daily_stats 聚合(部署即用)。"""
"""stat_daily_staff 空表 → msg_total/首响三列回退 msg_daily_stats 聚合(部署即用)。"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session):
session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三"))
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t1", day=20260904,
send_cnt=10, recv_cnt=5, last_ts=1788500000000,
last_is_send=True))
last_is_send=True,
cust_threads=2, threads_answered=1,
fr_sum_ms=60000))
# wx9 无账号行 → 映射不上按 owner 原值计入并标未映射
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx9",
peer_kind="friend", peer_id="t2", day=20260904,
send_cnt=2, recv_cnt=0, last_ts=1788500000000,
last_is_send=True))
last_is_send=True,
cust_threads=3, threads_answered=0,
fr_sum_ms=0))
await session.commit()
out = await metrics.staff_ranking(session, channel="wechat", scope="today")
assert [r["user_id"] for r in out] == ["u1", "wx9"]
assert out[0]["msg_total"] == 15 and out[0]["active_friends"] == 1
assert out[0]["unmapped"] is False
assert out[0]["msg_delta"] is None # 今日 scope 无环比
# 回退路径首响三列:60000/1=60000;回复率 1/2
assert out[0]["fr_avg_ms"] == 60000
assert out[0]["reply_rate"] == 0.5
assert out[1]["msg_total"] == 2 and out[1]["unmapped"] is True
assert out[1]["name"] == "wx9"
# threads>0 answered=0 → 回复率 0.0(有串无回);首响 None(无样本)
assert out[1]["reply_rate"] == 0.0
assert out[1]["fr_avg_ms"] is None
await make_client(seed)
@@ -425,3 +435,145 @@ async def test_action_counts_all(make_client, monkeypatch):
"sensitive_pending": 2}
await make_client(seed)
# ── 批2(2026-09-07):排行五新列 / B1 客户资产 / C1 自定义日期 ──────────────
async def test_staff_ranking_batch2_columns(make_client, monkeypatch):
"""排行五新列:首响均值+回复率(rollup 三列)、首沟通归属、通话时长+
接通率、删粉/拉黑(全库存量按 user_id 归属,不随窗)。"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session):
session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三"))
session.add(WechatAccount(wx_id="wx2", user_id="u2", user_name="李四"))
session.add(Staff(user_id="u1", name="张三"))
session.add(Staff(user_id="u2", name="李四"))
# rollup 当窗行:u1 threads=10 answered=4 fr=1_200_000(→300000ms/0.4);
# u2 三列缺省 0 → 两率皆 None
session.add(StatDailyStaff(day=20260904, channel="wechat", user_id="u1",
msg_sent=20, msg_recv=0, active_friends=1,
active_rooms=0, cust_threads=10,
threads_answered=4, fr_sum_ms=1_200_000))
session.add(StatDailyStaff(day=20260904, channel="wechat", user_id="u2",
msg_sent=5, msg_recv=0, active_friends=1,
active_rooms=0))
# 首沟通:f1 全史 MIN=20260831 落窗(计);f2 MIN=20260801 窗外(不计);
# r1 是 room 不计;f3 归 wx2→u2(计)
for day, kind, peer, owner in [
(20260831, "friend", "f1", "wx1"),
(20260902, "friend", "f1", "wx1"), # 同 peer 次日不影响 MIN
(20260801, "friend", "f2", "wx1"),
(20260903, "room", "r1", "wx1"),
(20260903, "friend", "f3", "wx2"),
]:
session.add(MsgDailyStats(channel="wechat", owner_account_id=owner,
peer_kind=kind, peer_id=peer, day=day,
send_cnt=1, recv_cnt=0,
last_ts=1788500000000, last_is_send=True))
# 通话:u1 三通落窗(60/0/120s → 180s、2/3 接通);u2 通话在 2025 年
for cid, uid, ts, dur in [
("c1", "u1", 1788500000000, 60),
("c2", "u1", 1788500001000, 0),
("c3", "u1", 1788500002000, 120),
("c4", "u2", 1756950000000, 60),
]:
session.add(PhoneCall(call_id=cid, channel="voice_call", user_id=uid,
start_call_time=ts, call_duration=dur))
# 客户归属存量:u1 删 1 拉黑 1;u2 双属性各 1;无归属(user_id NULL)跳过
session.add(Customer(friend_id="d1", channel="wechat", user_id="u1",
delete_flag=1))
session.add(Customer(friend_id="b1", channel="wechat", user_id="u1",
is_block=True))
session.add(Customer(friend_id="x1", channel="wechat", user_id="u2",
delete_flag=1, is_block=True))
session.add(Customer(friend_id="n1", channel="wechat", user_id=None))
await session.commit()
out = await metrics.staff_ranking(session, channel="wechat", scope="week")
by = {r["user_id"]: r for r in out}
r1, r2 = by["u1"], by["u2"]
assert r1["fr_avg_ms"] == 300000 # 1_200_000 / 4
assert r1["reply_rate"] == 0.4
assert r1["first_contact_cnt"] == 1 # f1;f2 窗外、r1 群不计
assert r1["call_seconds"] == 180
assert r1["answer_rate"] == round(2 / 3, 4)
assert r1["deleted_friends"] == 1 and r1["blocked_friends"] == 1
assert r2["fr_avg_ms"] is None and r2["reply_rate"] is None
assert r2["first_contact_cnt"] == 1 # f3
assert r2["call_seconds"] == 0 and r2["answer_rate"] is None # 窗外通话
assert r2["deleted_friends"] == 1 and r2["blocked_friends"] == 1
await make_client(seed)
async def test_customer_assets_layers(make_client, monkeypatch):
"""B1:剔除已删后按 last_chat_time 分层(从未激活单列 never);渠道计数;
已删单列。全库口径——channel=wechat 也不滤 whatsapp 客户。"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
monkeypatch.setattr(metrics, "_now_ms", lambda: 1788550000000)
async def seed(session):
now = 1788550000000
day_ms = 86_400_000
rows = [
("a1", "wechat", now - 5 * day_ms, 0), # active
("a2", "whatsapp", now - 29 * day_ms, 0), # active(边界内)
("s1", "whatsapp", now - 45 * day_ms, 0), # silent
("p1", "wechat", now - 91 * day_ms, 0), # sleeping
("n1", "whatsapp", None, 0), # never
("n2", "wechat", None, None), # never(delete_flag NULL 同未删)
]
for fid, ch, lt, df in rows:
session.add(Customer(friend_id=fid, channel=ch, last_chat_time=lt,
delete_flag=df))
# 已删不进分层、单列(deleted)
session.add(Customer(friend_id="d1", channel="wechat",
last_chat_time=now - 1 * day_ms, delete_flag=1))
await session.commit()
out = await metrics.overview(session, channel="wechat", scope="today")
assert out["customer_assets"] == {
"total": 6, "active30": 2, "silent30_90": 1, "sleeping90p": 1,
"never": 2, "wechat": 3, "whatsapp": 3, "deleted": 1,
}
await make_client(seed)
async def test_overview_custom_scope(make_client, monkeypatch):
"""C1:custom days=[start..end](含端点);环比=等长紧邻前窗滚动(各日 -N);
metrics 直调的参数防御(缺参/逆序/超 366 天)。"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session):
# 窗内 20260801..20260827:t1 10 条;紧邻前窗 20260705..20260731:t1 20 条
for day, sent in [(20260815, 10), (20260710, 20)]:
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t1", day=day,
send_cnt=sent, recv_cnt=0,
last_ts=1786000000000, last_is_send=True))
await session.commit()
out = await metrics.overview(session, channel="wechat", scope="custom",
start=20260801, end=20260827)
assert out["days"][0] == 20260801 and out["days"][-1] == 20260827
assert len(out["days"]) == 27
assert out["cards"]["msg_total"] == 10
assert out["cards"]["prev_msg_total"] == 20 # 紧邻前窗命中 07-10 行
assert out["cards"]["msg_total_delta"] == -0.5
# 排行吃同一窗(回退路径):wx1 未映射按原值出列
assert out["staff_ranking"][0]["user_id"] == "wx1"
assert out["staff_ranking"][0]["msg_total"] == 10
# 紧邻前窗逐日 -27:08-01→07-05、08-27→07-31
prev = metrics._prev_scope_days("custom", out["days"])
assert (len(prev), prev[0], prev[-1]) == (27, 20260705, 20260731)
with pytest.raises(ValueError):
await metrics.overview(session, channel="wechat", scope="custom")
with pytest.raises(ValueError):
await metrics.overview(session, channel="wechat", scope="custom",
start=20260827, end=20260801)
with pytest.raises(ValueError):
await metrics.overview(session, channel="wechat", scope="custom",
start=20240101, end=20250101) # 367 天
await make_client(seed)