Files
yunke/yunke-server/tests/test_analytics_overview.py
T

580 lines
30 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.
"""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,
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,
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)
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)
async def test_overview_all_channel(make_client, monkeypatch):
"""看板 v2 全平台口径(channel=None):卡片=两渠道合计、by_channel 拆分、
trend=合计、trend_by_channel 双序列(仅 all 时出现;单渠道不加键)。"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session):
await _seed(session) # wechat 当日:t1 friend 15 + r1 room 7 = 22
# whatsapp 当日:w1 friend 6(全史首现=今日,首沟通落窗)
session.add(MsgDailyStats(channel="whatsapp", owner_account_id="wa1",
peer_kind="friend", peer_id="w1", day=20260904,
send_cnt=4, recv_cnt=2, last_ts=1788500000000,
last_is_send=True))
await session.commit()
out = await metrics.overview(session, channel=None, scope="today")
assert out["channel"] == "all"
# 合计卡片(peer 跨渠道不碰撞:活跃客户/首沟通直接相加)
assert out["cards"]["msg_total"] == 28 # 22 + 6
assert out["cards"]["active_customers"] == 2 # t1 + w1
assert out["cards"]["first_contact"] == 2
# 拆分小字数据源
assert out["by_channel"]["wechat"] == {
"msg_total": 22, "active_customers": 1, "first_contact": 1}
assert out["by_channel"]["whatsapp"] == {
"msg_total": 6, "active_customers": 1, "first_contact": 1}
# 趋势:合计单系列保持可用 + 双渠道序列(30 天窗末日有值)
assert len(out["trend"]) == 30
assert out["trend"][-1]["msg"] == 28
assert out["trend"][-1]["active_customers"] == 2
tbc = out["trend_by_channel"]
assert len(tbc["wechat"]) == 30 and len(tbc["whatsapp"]) == 30
assert tbc["wechat"][-1]["msg"] == 22
assert tbc["whatsapp"][-1]["msg"] == 6
# 通话量无渠道语义不拆:渠道序列 call_cnt 恒 0,总量走 trend
assert tbc["wechat"][-1]["call_cnt"] == 0
assert out["trend"][-1]["call_cnt"] == 1
# 单渠道:不加 by_channel/trend_by_channel 键(既有行为不破坏)
single = await metrics.overview(session, channel="wechat", scope="today")
assert single["cards"]["msg_total"] == 22
assert "by_channel" not in single
assert "trend_by_channel" not in single
await make_client(seed)
async def test_action_counts_all(make_client, monkeypatch):
"""all 口径行动计数:两渠道各一条未回 → unreplied_2h=2;sensitive_pending
同样跨渠道合计(只计 pending)。"""
monkeypatch.setattr(metrics, "_now_ms", lambda: 1756950000000)
async def seed(session):
now = 1756950000000
h3 = 3 * 3600 * 1000
for i, (ch, owner, peer) in enumerate(
[("wechat", "wx1", "t1"), ("whatsapp", "wa1", "w1")]
):
session.add(MsgDailyStats(channel=ch, owner_account_id=owner,
peer_kind="friend", peer_id=peer, day=20260904,
send_cnt=5, recv_cnt=3, last_ts=now - h3,
last_is_send=False))
session.add(Message(msg_id=f"m{i}", channel=ch, owner_account_id=owner,
talker_id=peer, timestamp=now - h3, is_send=False,
type="text", text="在吗",
fetched_at=dt.datetime(2026, 9, 4, 12, 0)))
w = SensitiveWord(word="测试词")
session.add(w)
await session.flush()
session.add(SensitiveHit(word_id=w.id, msg_id="s1", channel="wechat",
disposition="pending"))
session.add(SensitiveHit(word_id=w.id, msg_id="s2", channel="whatsapp",
disposition="pending"))
session.add(SensitiveHit(word_id=w.id, msg_id="s3", channel="whatsapp",
disposition="confirmed"))
await session.commit()
out = await metrics.action_counts(session, channel=None)
assert out == {"unreplied_2h": 2, "churn_real_30d": 0,
"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)