feat(analytics): B2 该回未回——尾部判定/有效类型/通话豁免/已删剔除
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
"""A3 三分法流失 + B2 该回未回。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
|
||||
import pytest
|
||||
|
||||
from app.analytics import metrics
|
||||
from app.db.models import Customer, MsgDailyStats, Staff
|
||||
from app.db.models import Customer, Message, MsgDailyStats, PhoneCall, Staff
|
||||
|
||||
_NOW = 1756950000000 # 相对时间基准(绝对日期无关紧要,断言全部相对此值自洽)
|
||||
|
||||
@@ -134,3 +136,150 @@ def test_next_tier_bad_tier_raises_value_error():
|
||||
with pytest.raises(ValueError):
|
||||
metrics._next_tier(45)
|
||||
assert metrics._next_tier(90) == 10_000 # 90 档=≥90 的开区间上限
|
||||
|
||||
|
||||
_VALID_TYPES = ("text", "image", "voice", "video", "file", "link")
|
||||
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user