"""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)