"""A3 三分法流失 + B2 该回未回。""" from __future__ import annotations import pytest from app.analytics import metrics from app.db.models import Customer, MsgDailyStats, Staff _NOW = 1756950000000 # 相对时间基准(绝对日期无关紧要,断言全部相对此值自洽) def _cust(friend_id, *, last_chat=None, user_id="u1", delete=0, block=None): return Customer(friend_id=friend_id, channel="wechat", owner_account_id="wx1", user_id=user_id, last_chat_time=last_chat, delete_flag=delete, is_block=block) async def _seed_churn(session): now = _NOW day_ms = 86_400_000 session.add(Staff(user_id="u9", name="离职的", is_on_job=False)) session.add(Staff(user_id="u1", name="在职的", is_on_job=True)) # 真流失:45 天没聊 + 历史消息 ≥5 session.add(_cust("c_real", last_chat=now - 45 * day_ms)) # 历史消息不足 5 → 不算真流失 session.add(_cust("c_few", last_chat=now - 45 * day_ms)) # 只沉默 10 天 → 不入 30 档 session.add(_cust("c_recent", last_chat=now - 10 * day_ms)) # 从未激活 session.add(_cust("c_null", last_chat=None)) # 待交接:归属离职员工 session.add(_cust("c_hand", user_id="u9", last_chat=now - 3 * day_ms)) # 已删好友 → 全部剔除 session.add(_cust("c_del", last_chat=now - 45 * day_ms, delete=1)) # 历史消息量:real=6(≥5),few=2 for peer, n in (("c_real", 6), ("c_few", 2)): session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1", peer_kind="friend", peer_id=peer, day=20260601, send_cnt=n, recv_cnt=0, last_ts=now - 50 * day_ms, last_is_send=True)) await session.commit() async def test_churn_real_tier30(make_client, monkeypatch): monkeypatch.setattr(metrics, "_now_ms", lambda: _NOW) async def seed(session): await _seed_churn(session) page = await metrics.churn(session, channel="wechat", kind="real", tier=30, min_msgs=5, page=1, page_size=20) ids = [r["friend_id"] for r in page["items"]] assert "c_real" in ids and "c_few" not in ids assert "c_recent" not in ids and "c_del" not in ids real = next(r for r in page["items"] if r["friend_id"] == "c_real") assert real["hist_msgs"] == 6 and real["active_days"] == 1 await make_client(seed) async def test_churn_inactive_and_handover(make_client, monkeypatch): monkeypatch.setattr(metrics, "_now_ms", lambda: _NOW) async def seed(session): await _seed_churn(session) p1 = await metrics.churn(session, channel="wechat", kind="inactive", tier=30, min_msgs=5, page=1, page_size=20) assert {r["friend_id"] for r in p1["items"]} == {"c_null"} p2 = await metrics.churn(session, channel="wechat", kind="handover", tier=30, min_msgs=5, page=1, page_size=20) assert {r["friend_id"] for r in p2["items"]} == {"c_hand"} await make_client(seed) async def test_churn_endpoint_422(make_client): async def seed(session): ... client = await make_client(seed) r = await client.get("/api/analytics/churn", params={"tier": 45}) assert r.status_code == 422 r2 = await client.get("/api/analytics/churn", params={"channel": "sms"}) assert r2.status_code == 422 async def test_churn_tier_membership_and_min_msgs_boundary(make_client, monkeypatch): """tier60/90 会员资格([tier, next) 区间)+ min_msgs 恰等阈值的包含边界。""" monkeypatch.setattr(metrics, "_now_ms", lambda: _NOW) day_ms = 86_400_000 async def seed(session): # 沉默 75 天:∈[60,90) → tier60 含、tier30 不含 session.add(_cust("c75", last_chat=_NOW - 75 * day_ms)) # 沉默 100 天:≥90 → tier90 含、tier60 不含 session.add(_cust("c100", last_chat=_NOW - 100 * day_ms)) # 历史消息恰好 5 条(=min_msgs,含边界)+ 沉默 45 天 → 入 tier30 session.add(_cust("c5", last_chat=_NOW - 45 * day_ms)) for peer, n in (("c75", 6), ("c100", 6), ("c5", 5)): session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1", peer_kind="friend", peer_id=peer, day=20260601, send_cnt=n, recv_cnt=0, last_ts=_NOW - 110 * day_ms, last_is_send=True)) await session.commit() t30 = await metrics.churn(session, channel="wechat", kind="real", tier=30, min_msgs=5, page=1, page_size=20) assert {r["friend_id"] for r in t30["items"]} == {"c5"} t60 = await metrics.churn(session, channel="wechat", kind="real", tier=60, min_msgs=5, page=1, page_size=20) assert {r["friend_id"] for r in t60["items"]} == {"c75"} t90 = await metrics.churn(session, channel="wechat", kind="real", tier=90, min_msgs=5, page=1, page_size=20) assert {r["friend_id"] for r in t90["items"]} == {"c100"} await make_client(seed) async def test_churn_handover_excludes_null_user(make_client, monkeypatch): """user_id NULL(未映射归属)的客户不进待交接名单。""" monkeypatch.setattr(metrics, "_now_ms", lambda: _NOW) async def seed(session): session.add(Staff(user_id="u9", name="离职的", is_on_job=False)) session.add(_cust("c_hand", user_id="u9")) session.add(_cust("c_nouser", user_id=None)) await session.commit() p = await metrics.churn(session, channel="wechat", kind="handover", tier=30, min_msgs=5, page=1, page_size=20) assert {r["friend_id"] for r in p["items"]} == {"c_hand"} await make_client(seed) def test_next_tier_bad_tier_raises_value_error(): """非法 tier 报 ValueError(与 churn 的 kind 分支报错类型一致)。""" with pytest.raises(ValueError): metrics._next_tier(45) assert metrics._next_tier(90) == 10_000 # 90 档=≥90 的开区间上限