487f45b074
管理者反馈「纯汇总数字看不到有用信息」:overview 载荷扩 staff_ranking(全员行:msg_total rollup 优先/空表回退 msg_daily_stats 聚合,active_* 两路径统一走 msg_daily_stats 窗内 distinct peer—— stat_daily_staff 的 active_* 是当日快照,跨日 SUM/MAX 语义皆错; call_cnt 按 user_id 落窗直查;环比同期对齐)+ actions(该回未回 2h/ 真流失 30d/敏感词 pending,复用已测试查询路径各取 total)。 前端看板:toolbar 下三张可点行动卡(0 灰显仍可点,直达处置页, 随 channel 切换刷新)+ 趋势卡下员工排行表(max-height 420 滚动, 未映射小 tag,环比色同卡片惯例 None 显 '-')。 测试 574→577 全绿(rollup 聚合/回退/行动计数三新);build 过。 Co-Authored-By: Claude Code <noreply@anthropic.com>
347 lines
18 KiB
Python
347 lines
18 KiB
Python
"""A1 总览指标:卡片/首沟通口径/环比同期对齐(仅周月)/异常警示。"""
|
||
from __future__ import annotations
|
||
|
||
import datetime as dt
|
||
|
||
import pytest
|
||
|
||
from app.analytics import metrics
|
||
from app.db.models import (
|
||
Customer,
|
||
Message,
|
||
MsgDailyStats,
|
||
PhoneCall,
|
||
SensitiveHit,
|
||
SensitiveWord,
|
||
Staff,
|
||
StatDailyStaff,
|
||
WechatAccount,
|
||
)
|
||
|
||
_TODAY = 20260904 # 测试统一用固定 day 键,monkeypatch today_key
|
||
|
||
|
||
async def _seed(session):
|
||
session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三"))
|
||
# 昨日(20260903):u1 90 条;前 7 日均值(20260827-0902):每天 200 → 偏离 -55%>50% 触发
|
||
# (阈值是严格 >0.5——正好 -50% 不触发,seed 用 90)
|
||
for day, sent in [(20260827, 200), (20260828, 200), (20260829, 200),
|
||
(20260830, 200), (20260831, 200), (20260901, 200),
|
||
(20260902, 200), (20260903, 90)]:
|
||
session.add(StatDailyStaff(day=day, channel="wechat", user_id="u1",
|
||
msg_sent=sent, msg_recv=0,
|
||
active_friends=5, active_rooms=1))
|
||
# 当日 rollup 行(active_staff 卡片读 stat_daily_staff,必须有当日行)
|
||
session.add(StatDailyStaff(day=20260904, channel="wechat", user_id="u1",
|
||
msg_sent=15, msg_recv=7,
|
||
active_friends=1, active_rooms=1))
|
||
# 当日 + 首沟通:t1 当日首现(首沟通=1),t2 早在 20260827 就有(不算)
|
||
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))
|
||
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||
peer_kind="friend", peer_id="t2", day=20260827,
|
||
send_cnt=3, recv_cnt=0, last_ts=1787800000000,
|
||
last_is_send=True))
|
||
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||
peer_kind="room", peer_id="r1", day=20260904,
|
||
send_cnt=7, recv_cnt=0, last_ts=1788500000000,
|
||
last_is_send=True))
|
||
# 通话时间戳必须是 2026-09-04 当日(1756950000000 是 2025 年——坑)
|
||
session.add(PhoneCall(call_id="c1", channel="voice_call",
|
||
start_call_time=1788500000000, call_duration=60))
|
||
await session.commit()
|
||
|
||
|
||
async def test_overview_today(make_client, monkeypatch):
|
||
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
|
||
async def seed(session):
|
||
await _seed(session)
|
||
out = await metrics.overview(session, channel="wechat", scope="today")
|
||
assert out["cards"]["msg_total"] == 22 # (10+5)+(7+0)
|
||
assert out["cards"]["active_customers"] == 1 # peer_kind=friend 去重,群不计
|
||
assert out["cards"]["active_staff"] == 1
|
||
assert out["cards"]["call_cnt"] == 1
|
||
assert out["cards"]["first_contact"] == 1 # t1 全史 MIN(day)=今日;t2 不是
|
||
assert "msg_total_delta" not in out["cards"] # 今日无环比
|
||
assert "prev_msg_total" not in out["cards"]
|
||
|
||
await make_client(seed)
|
||
|
||
|
||
async def test_anomaly_yesterday_vs_prev7(make_client, monkeypatch):
|
||
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
|
||
async def seed(session):
|
||
await _seed(session)
|
||
a = await metrics.anomaly(session, channel="wechat")
|
||
assert a is not None
|
||
assert a["total_delta_pct"] <= -0.5 # 90 vs 均值 200 = -55%(严格 >50% 触发)
|
||
staff = {s["user_id"]: s for s in a["staff"]}
|
||
assert staff["u1"]["yesterday"] == 90
|
||
assert staff["u1"]["prev7_mean"] == 200.0
|
||
|
||
await make_client(seed)
|
||
|
||
|
||
async def test_overview_fallback_when_rollup_empty(make_client, monkeypatch):
|
||
"""stat_daily_staff 空表 → 员工卡片回退实时聚合 msg_daily_stats(部署即用)。"""
|
||
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
|
||
async def seed(session):
|
||
session.add(WechatAccount(wx_id="wx1", user_id="u1"))
|
||
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||
peer_kind="friend", peer_id="t1", day=20260904,
|
||
send_cnt=2, recv_cnt=1, last_ts=1788500000000,
|
||
last_is_send=True))
|
||
await session.commit()
|
||
out = await metrics.overview(session, channel="wechat", scope="today")
|
||
assert out["cards"]["active_staff"] == 1
|
||
assert out["cards"]["msg_total"] == 3
|
||
|
||
await make_client(seed)
|
||
|
||
|
||
async def test_anomaly_fallback_multi_account(make_client, monkeypatch):
|
||
"""回退路径同人多账号累加:wx1/wx2 同映射 u1,昨日量必须相加而非覆盖。"""
|
||
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
|
||
async def seed(session):
|
||
session.add(WechatAccount(wx_id="wx1", user_id="u1"))
|
||
session.add(WechatAccount(wx_id="wx2", user_id="u1"))
|
||
# 不 seed StatDailyStaff → 走 msg_daily_stats 回退路径。
|
||
# 昨日(20260903)各 100(合计 200);prev7 每天各 50(日均 100)→ 偏离 +100% 触发
|
||
for day in [20260827, 20260828, 20260829, 20260830,
|
||
20260831, 20260901, 20260902, 20260903]:
|
||
sent = 100 if day == 20260903 else 50
|
||
for wx in ("wx1", "wx2"):
|
||
session.add(MsgDailyStats(channel="wechat", owner_account_id=wx,
|
||
peer_kind="friend", peer_id="t1", day=day,
|
||
send_cnt=sent, recv_cnt=0,
|
||
last_ts=1788500000000,
|
||
last_is_send=True))
|
||
await session.commit()
|
||
a = await metrics.anomaly(session, channel="wechat")
|
||
assert a is not None
|
||
staff = {s["user_id"]: s for s in a["staff"]}
|
||
# 修复前:后写账号覆盖前写,u1 昨日只剩 100;修复后两账号累加 = 200
|
||
assert staff["u1"]["yesterday"] == 200
|
||
assert staff["u1"]["prev7_mean"] == 100.0
|
||
|
||
await make_client(seed)
|
||
|
||
|
||
def test_prev_scope_days_aligned():
|
||
"""环比窗=同期对齐(非滚动紧邻):周各日-7天;月=上月同日短月钳月末。"""
|
||
week = metrics.scope_day_keys("week", 20260902) # 周三:本周一..三
|
||
assert week == [20260831, 20260901, 20260902]
|
||
# 上周周一..上周三(各日-7)——不是跨周末的滚动紧邻窗
|
||
assert metrics._prev_scope_days("week", week) == [20260824, 20260825, 20260826]
|
||
month = metrics.scope_day_keys("month", 20260902)
|
||
assert month == [20260901, 20260902]
|
||
assert metrics._prev_scope_days("month", month) == [20260801, 20260802]
|
||
# 月末短月钳制:2026 非闰年 3/31→2/28;闰年 2024 3/31→2/29;跨年 1/1→上年 12/1
|
||
assert metrics._prev_scope_days("month", [20260331]) == [20260228]
|
||
assert metrics._prev_scope_days("month", [20240331]) == [20240229]
|
||
assert metrics._prev_scope_days("month", [20260101]) == [20251201]
|
||
|
||
|
||
async def test_overview_trend_days_validation(make_client, monkeypatch):
|
||
"""trend_days 越界(1..90 外)直接 ValueError,不静默出奇怪窗口。"""
|
||
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
|
||
async def seed(session):
|
||
with pytest.raises(ValueError, match="trend_days"):
|
||
await metrics.overview(session, channel="wechat", scope="today",
|
||
trend_days=0)
|
||
with pytest.raises(ValueError, match="trend_days"):
|
||
await metrics.overview(session, channel="wechat", scope="today",
|
||
trend_days=91)
|
||
|
||
await make_client(seed)
|
||
|
||
|
||
async def test_week_delta_none_when_prev_week_empty(make_client, monkeypatch):
|
||
"""上周边无数据(prev_total=0)→ delta=None,不除零不误报。"""
|
||
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
|
||
async def seed(session):
|
||
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||
peer_kind="friend", peer_id="t1", day=20260904,
|
||
send_cnt=5, recv_cnt=0, last_ts=1788500000000,
|
||
last_is_send=True))
|
||
await session.commit()
|
||
out = await metrics.overview(session, channel="wechat", scope="week")
|
||
assert out["cards"]["prev_msg_total"] == 0
|
||
assert out["cards"]["msg_total_delta"] is None
|
||
|
||
await make_client(seed)
|
||
|
||
|
||
async def test_anomaly_none_when_no_prev_data(make_client, monkeypatch):
|
||
"""全员无前 7 日数据(totals_p<=0)→ 异常警示 None(无基数可算)。"""
|
||
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
|
||
async def seed(session):
|
||
session.add(StatDailyStaff(day=20260903, channel="wechat", user_id="u1",
|
||
msg_sent=100, msg_recv=0,
|
||
active_friends=1, active_rooms=0))
|
||
await session.commit()
|
||
assert await metrics.anomaly(session, channel="wechat") is None
|
||
|
||
await make_client(seed)
|
||
|
||
|
||
async def test_anomaly_new_staff_detail_and_sort(make_client, monkeypatch):
|
||
"""新员工(前7日无量、昨日>0)进明细带 new 标记;明细按昨日量降序。"""
|
||
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
|
||
async def seed(session):
|
||
for day, sent in [(20260827, 100), (20260828, 100), (20260829, 100),
|
||
(20260830, 100), (20260831, 100), (20260901, 100),
|
||
(20260902, 100), (20260903, 250)]:
|
||
session.add(StatDailyStaff(day=day, channel="wechat", user_id="u1",
|
||
msg_sent=sent, msg_recv=0,
|
||
active_friends=1, active_rooms=0))
|
||
# u2 昨日新出现(前 7 日无量)——修复前从明细里消失
|
||
session.add(StatDailyStaff(day=20260903, channel="wechat", user_id="u2",
|
||
msg_sent=80, msg_recv=0,
|
||
active_friends=1, active_rooms=0))
|
||
await session.commit()
|
||
a = await metrics.anomaly(session, channel="wechat")
|
||
assert a is not None
|
||
assert [s["user_id"] for s in a["staff"]] == ["u1", "u2"] # 降序:250 > 80
|
||
staff = {s["user_id"]: s for s in a["staff"]}
|
||
assert staff["u2"]["new"] is True
|
||
assert staff["u2"]["yesterday"] == 80
|
||
assert "new" not in staff["u1"] # 老员工不带标记
|
||
|
||
await make_client(seed)
|
||
|
||
|
||
async def test_staff_ranking(make_client, monkeypatch):
|
||
"""员工活跃排行:rollup 供 msg_total;active_* 统一走 msg_daily_stats 去重。
|
||
|
||
_TODAY=20260904 周五 → 本周 [0831..0904],上周 [0824..0828]。
|
||
rollup 的 active_friends 故意 seed 999——若实现误用 rollup 聚合(跨日
|
||
SUM/MAX 皆错)会立刻暴露;正确值=窗内 distinct peer(u1: f1+f2)。
|
||
"""
|
||
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
|
||
week = [20260831, 20260901, 20260902, 20260903, 20260904]
|
||
prev_week = [20260824, 20260825, 20260826, 20260827, 20260828]
|
||
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 本周 100(5×20),u2 50(5×10);active_* 999=诱饵(不得被采用)
|
||
for d in week:
|
||
session.add(StatDailyStaff(day=d, channel="wechat", user_id="u1",
|
||
msg_sent=20, msg_recv=0,
|
||
active_friends=999, active_rooms=999))
|
||
session.add(StatDailyStaff(day=d, channel="wechat", user_id="u2",
|
||
msg_sent=10, msg_recv=0,
|
||
active_friends=1, active_rooms=0))
|
||
# u3 仅存在于 rollup(无账号映射/无 staff 行)→ 未映射,名字回退 user_id
|
||
session.add(StatDailyStaff(day=20260904, channel="wechat", user_id="u3",
|
||
msg_sent=10, msg_recv=0,
|
||
active_friends=0, active_rooms=0))
|
||
# 上周 rollup:u1 80(5×16) → msg_delta=(100-80)/80=0.25;u2 无前窗→None
|
||
for d in prev_week:
|
||
session.add(StatDailyStaff(day=d, channel="wechat", user_id="u1",
|
||
msg_sent=16, msg_recv=0,
|
||
active_friends=1, active_rooms=0))
|
||
# 活跃指标真源:wx1→u1 两 friend(f1/f2 不同日)+一 room;wx2→u2 一 friend
|
||
for day, kind, peer, owner in [
|
||
(20260831, "friend", "f1", "wx1"), (20260904, "friend", "f2", "wx1"),
|
||
(20260902, "room", "r1", "wx1"), (20260903, "friend", "f1", "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 一通落窗(1788500000000=2026-09-04 当日);u2 的在 2025 年不计
|
||
session.add(PhoneCall(call_id="cr1", channel="voice_call", user_id="u1",
|
||
start_call_time=1788500000000, call_duration=60))
|
||
session.add(PhoneCall(call_id="cr2", channel="voice_call", user_id="u2",
|
||
start_call_time=1756950000000, call_duration=60))
|
||
await session.commit()
|
||
|
||
out = await metrics.staff_ranking(session, channel="wechat", scope="week")
|
||
assert [r["user_id"] for r in out] == ["u1", "u2", "u3"] # msg_total 降序
|
||
r1, r2, r3 = out
|
||
assert r1["name"] == "张三" and r1["unmapped"] is False
|
||
assert r1["msg_total"] == 100 # rollup 聚合
|
||
assert r1["active_friends"] == 2 # distinct peer,非 rollup 的 999
|
||
assert r1["active_rooms"] == 1
|
||
assert r1["call_cnt"] == 1
|
||
assert r1["msg_delta"] == 0.25 # (100-80)/80
|
||
assert r2["msg_total"] == 50 and r2["active_friends"] == 1
|
||
assert r2["active_rooms"] == 0 and r2["call_cnt"] == 0 # 窗外通话不计
|
||
assert r2["msg_delta"] is None # 前窗无数据
|
||
assert r3["name"] == "u3" and r3["unmapped"] is True
|
||
assert r3["msg_total"] == 10 and r3["active_friends"] == 0
|
||
|
||
await make_client(seed)
|
||
|
||
|
||
async def test_staff_ranking_fallback_when_rollup_empty(make_client, monkeypatch):
|
||
"""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))
|
||
# 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))
|
||
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 无环比
|
||
assert out[1]["msg_total"] == 2 and out[1]["unmapped"] is True
|
||
assert out[1]["name"] == "wx9"
|
||
|
||
await make_client(seed)
|
||
|
||
|
||
async def test_action_counts(make_client, monkeypatch):
|
||
"""预警行动入口:unreplied 走真实路径,churn/sensitive 各计 pending/真流失。"""
|
||
monkeypatch.setattr(metrics, "_now_ms", lambda: 1756950000000)
|
||
async def seed(session):
|
||
now = 1756950000000
|
||
h3 = 3 * 3600 * 1000
|
||
# 该回未回 1 条:客户尾向 3h 前 + 有效 text 消息
|
||
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||
peer_kind="friend", peer_id="t1", day=20260904,
|
||
send_cnt=5, recv_cnt=3, last_ts=now - h3,
|
||
last_is_send=False))
|
||
session.add(Message(msg_id="t1m", channel="wechat", owner_account_id="wx1",
|
||
talker_id="t1", timestamp=now - h3, is_send=False,
|
||
type="text", text="在吗,方案发我一下",
|
||
fetched_at=dt.datetime(2026, 9, 4, 12, 0)))
|
||
# 真流失 1 条:沉默 45 天 + 历史 ≥5
|
||
day_ms = 86_400_000
|
||
session.add(Customer(friend_id="c_real", channel="wechat",
|
||
owner_account_id="wx1", user_id="u1",
|
||
last_chat_time=now - 45 * day_ms, delete_flag=0))
|
||
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||
peer_kind="friend", peer_id="c_real", day=20260601,
|
||
send_cnt=6, recv_cnt=0, last_ts=now - 50 * day_ms,
|
||
last_is_send=True))
|
||
# 敏感词:1 pending + 1 confirmed(只计 pending)
|
||
w = SensitiveWord(word="测试词")
|
||
session.add(w)
|
||
await session.flush()
|
||
session.add(SensitiveHit(word_id=w.id, msg_id="m1", channel="wechat",
|
||
disposition="pending"))
|
||
session.add(SensitiveHit(word_id=w.id, msg_id="m2", channel="wechat",
|
||
disposition="confirmed"))
|
||
await session.commit()
|
||
|
||
out = await metrics.action_counts(session, channel="wechat")
|
||
assert out == {"unreplied_2h": 1, "churn_real_30d": 1,
|
||
"sensitive_pending": 1}
|
||
|
||
await make_client(seed)
|