1e0db3913c
Co-Authored-By: Claude Code <noreply@anthropic.com>
343 lines
17 KiB
Python
343 lines
17 KiB
Python
"""A3 三分法流失 + B2 该回未回。"""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
|
|
import pytest
|
|
|
|
from app.analytics import metrics
|
|
from app.db.models import Customer, Message, MsgDailyStats, PhoneCall, 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 的开区间上限
|
|
|
|
|
|
async def _seed_unreplied(session):
|
|
now = _NOW
|
|
h3 = 3 * 3600 * 1000
|
|
# 该回未回:t1 客户最后一条(is_send=False,text)3h 前
|
|
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)))
|
|
# 员工已回 → 不在清单
|
|
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
|
peer_kind="friend", peer_id="t2", day=20260904,
|
|
send_cnt=5, recv_cnt=3, last_ts=now - h3,
|
|
last_is_send=True))
|
|
# 客户最后一条是 unknown 类型 → 无效,不出清单
|
|
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
|
peer_kind="friend", peer_id="t3", day=20260904,
|
|
send_cnt=5, recv_cnt=3, last_ts=now - h3,
|
|
last_is_send=False))
|
|
session.add(Message(msg_id="t3m", channel="wechat", owner_account_id="wx1",
|
|
talker_id="t3", timestamp=now - h3, is_send=False,
|
|
type="unknown", text=None,
|
|
fetched_at=dt.datetime(2026, 9, 4, 12, 0)))
|
|
# 尾部 0.5h < 2h 阈值 → 不出
|
|
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
|
peer_kind="friend", peer_id="t4", day=20260904,
|
|
send_cnt=5, recv_cnt=3, last_ts=now - 1800_000,
|
|
last_is_send=False))
|
|
session.add(Message(msg_id="t4m", channel="wechat", owner_account_id="wx1",
|
|
talker_id="t4", timestamp=now - 1800_000, is_send=False,
|
|
type="text", text="刚发的",
|
|
fetched_at=dt.datetime(2026, 9, 4, 12, 0)))
|
|
# 通话豁免:最后消息 5h 前没回,但 4h 前员工打过电话 → 视为已回应
|
|
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
|
peer_kind="friend", peer_id="t5", day=20260904,
|
|
send_cnt=5, recv_cnt=3, last_ts=now - 5 * 3600 * 1000,
|
|
last_is_send=False))
|
|
session.add(Message(msg_id="t5m", channel="wechat", owner_account_id="wx1",
|
|
talker_id="t5", timestamp=now - 5 * 3600 * 1000,
|
|
is_send=False, type="text", text="hello",
|
|
fetched_at=dt.datetime(2026, 9, 4, 12, 0)))
|
|
session.add(_cust("t5", last_chat=now, user_id="u1"))
|
|
(await session.get(Customer, "t5")).phone = "13800000005"
|
|
session.add(PhoneCall(call_id="pc1", channel="voice_call",
|
|
user_id="u1", call_number="13800000005",
|
|
start_call_time=now - 4 * 3600 * 1000))
|
|
# 已删好友 → 剔除
|
|
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
|
peer_kind="friend", peer_id="t6", day=20260904,
|
|
send_cnt=5, recv_cnt=3, last_ts=now - h3,
|
|
last_is_send=False))
|
|
session.add(Message(msg_id="t6m", channel="wechat", owner_account_id="wx1",
|
|
talker_id="t6", timestamp=now - h3, is_send=False,
|
|
type="text", text="已删我的",
|
|
fetched_at=dt.datetime(2026, 9, 4, 12, 0)))
|
|
session.add(_cust("t6", last_chat=now - h3, delete=1))
|
|
await session.commit()
|
|
|
|
|
|
async def test_unreplied(make_client, monkeypatch):
|
|
monkeypatch.setattr(metrics, "_now_ms", lambda: _NOW)
|
|
async def seed(session):
|
|
await _seed_unreplied(session)
|
|
out = await metrics.unreplied(session, channel="wechat", hours=2,
|
|
page=1, page_size=20)
|
|
ids = [r["peer_id"] for r in out["items"]]
|
|
assert ids == ["t1"] # t2已回/t3无效类型/t4未超时/t5通话豁免/t6已删
|
|
row = out["items"][0]
|
|
assert row["wait_ms"] >= 3 * 3600 * 1000
|
|
assert "方案" in row["preview"]
|
|
|
|
await make_client(seed)
|
|
|
|
|
|
async def test_unreplied_call_not_by_owner_still_exempts(make_client, monkeypatch):
|
|
"""通话豁免语义=『该客户被回应过』——不强制通话是同一员工打的(spec:尾部=max(最后消息,最后通话))。"""
|
|
monkeypatch.setattr(metrics, "_now_ms", lambda: _NOW)
|
|
async def seed(session):
|
|
now = _NOW
|
|
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
|
peer_kind="friend", peer_id="t7", day=20260904,
|
|
send_cnt=1, recv_cnt=1, last_ts=now - 6 * 3600 * 1000,
|
|
last_is_send=False))
|
|
session.add(Message(msg_id="t7m", channel="wechat", owner_account_id="wx1",
|
|
talker_id="t7", timestamp=now - 6 * 3600 * 1000,
|
|
is_send=False, type="text", text="hi",
|
|
fetched_at=dt.datetime(2026, 9, 4, 12, 0)))
|
|
session.add(_cust("t7", last_chat=now))
|
|
(await session.get(Customer, "t7")).phone = "13900000007"
|
|
# 通话是别的员工(user_id=u_other)打的,晚于最后消息
|
|
session.add(PhoneCall(call_id="pc2", channel="voice_call",
|
|
user_id="u_other", call_number="13900000007",
|
|
start_call_time=now - 2 * 3600 * 1000))
|
|
out = await metrics.unreplied(session, channel="wechat", hours=2,
|
|
page=1, page_size=20)
|
|
assert all(r["peer_id"] != "t7" for r in out["items"])
|
|
|
|
await make_client(seed)
|
|
|
|
|
|
async def test_unreplied_latest_day_row_wins(make_client, monkeypatch):
|
|
"""资格判定看「最新日行」:更早的未回日不能复活。
|
|
|
|
前日客户未回(已远超 2h),但今日员工回了(最新日行 last_is_send=True,
|
|
会话尾部=员工)→ 不在清单。若候选过滤先于 row_number(在窗口子查询内
|
|
过滤),旧日行会被复活成候选——此测试钉死正确语义。
|
|
"""
|
|
monkeypatch.setattr(metrics, "_now_ms", lambda: _NOW)
|
|
async def seed(session):
|
|
now = _NOW
|
|
h3 = 3 * 3600 * 1000
|
|
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
|
peer_kind="friend", peer_id="t8", day=20260903,
|
|
send_cnt=0, recv_cnt=3, last_ts=now - 30 * 3600 * 1000,
|
|
last_is_send=False))
|
|
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
|
peer_kind="friend", peer_id="t8", day=20260904,
|
|
send_cnt=5, recv_cnt=0, last_ts=now - h3,
|
|
last_is_send=True))
|
|
session.add(Message(msg_id="t8m", channel="wechat", owner_account_id="wx1",
|
|
talker_id="t8", timestamp=now - h3, is_send=True,
|
|
type="text", text="已经回了",
|
|
fetched_at=dt.datetime(2026, 9, 4, 12, 0)))
|
|
out = await metrics.unreplied(session, channel="wechat", hours=2,
|
|
page=1, page_size=20)
|
|
assert all(r["peer_id"] != "t8" for r in out["items"])
|
|
|
|
await make_client(seed)
|
|
|
|
|
|
async def test_unreplied_endpoint_422(make_client):
|
|
async def seed(session):
|
|
...
|
|
|
|
client = await make_client(seed)
|
|
r = await client.get("/api/analytics/unreplied", params={"hours": 0})
|
|
assert r.status_code == 422
|
|
r2 = await client.get("/api/analytics/unreplied", params={"channel": "sms"})
|
|
assert r2.status_code == 422
|
|
|
|
|
|
async def test_unreplied_stats_message_drift_staff_tail_excluded(
|
|
make_client, monkeypatch
|
|
):
|
|
"""stats/message 漂移守卫:统计行客户尾向超时,但 message 真实尾部是员工回复 → 不在清单。
|
|
|
|
写钩子吞重算失败、夜间重建兜底前 stats 可能滞后(一等状态);决赛圈以
|
|
message 实尾为准——is_send=True=员工尾部=定义上非待回复(is_send=None
|
|
保留=与统计同向保守)。
|
|
"""
|
|
monkeypatch.setattr(metrics, "_now_ms", lambda: _NOW)
|
|
async def seed(session):
|
|
now = _NOW
|
|
h3 = 3 * 3600 * 1000
|
|
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
|
peer_kind="friend", peer_id="t9", day=20260904,
|
|
send_cnt=5, recv_cnt=3, last_ts=now - h3,
|
|
last_is_send=False))
|
|
# 真实尾部:员工 1h 前的回复(stats 行漂移,仍停在客户尾向 3h 前)
|
|
session.add(Message(msg_id="t9m", channel="wechat", owner_account_id="wx1",
|
|
talker_id="t9", timestamp=now - 3600 * 1000,
|
|
is_send=True, type="text", text="已经回了哈",
|
|
fetched_at=dt.datetime(2026, 9, 4, 12, 0)))
|
|
out = await metrics.unreplied(session, channel="wechat", hours=2,
|
|
page=1, page_size=20)
|
|
assert all(r["peer_id"] != "t9" for r in out["items"])
|
|
|
|
await make_client(seed)
|
|
|
|
|
|
async def test_unreplied_large_candidate_branch(make_client, monkeypatch):
|
|
"""候选 > _FINALIZE_ALL_MAX:按候选分页,total=候选数;决赛圈过滤后本页可少几行。
|
|
|
|
501 个候选只给 bulk000 一条 message 行 → 第 1 页 items 仅 1 行(而非
|
|
page_size 100),锁定「total=候选数、items 可能少几行」的文档口径;
|
|
同时证明大候选分支的决赛圈点查真实执行(有效行保留)。
|
|
"""
|
|
monkeypatch.setattr(metrics, "_now_ms", lambda: _NOW)
|
|
async def seed(session):
|
|
now = _NOW
|
|
h3 = 3 * 3600 * 1000
|
|
for i in range(metrics._FINALIZE_ALL_MAX + 1):
|
|
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
|
peer_kind="friend", peer_id=f"bulk{i:03d}",
|
|
day=20260904, send_cnt=5, recv_cnt=3,
|
|
last_ts=now - h3, last_is_send=False))
|
|
# ts 全等 → 按 peer asc 决胜,bulk000 排第 1 页首行
|
|
session.add(Message(msg_id="b0m", channel="wechat", owner_account_id="wx1",
|
|
talker_id="bulk000", timestamp=now - h3,
|
|
is_send=False, type="text", text="在吗",
|
|
fetched_at=dt.datetime(2026, 9, 4, 12, 0)))
|
|
await session.commit()
|
|
out = await metrics.unreplied(session, channel="wechat", hours=2,
|
|
page=1, page_size=100)
|
|
assert out["total"] == metrics._FINALIZE_ALL_MAX + 1 # total=候选数
|
|
ids = [r["peer_id"] for r in out["items"]]
|
|
assert ids == ["bulk000"] # 无 message 行的候选决赛圈全滤=口径内少几行
|
|
|
|
await make_client(seed)
|