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

301 lines
12 KiB
Python

"""管理域期聚合查询判别测试(批次B Task B1)。
判据(v2 §3 / B1 规格):
- 分位=fact 表最近邻秩 ceil(q·n) 精确值(整除算术,无 ceil 方言坑),
多账号/跨渠道样本按员工合并;
- 宽表 SUM 按 (day∈窗, metric_version='v1', channel) 过滤;
active_rooms_avg 摊到全窗天数(含无数据日);
- 期末生命周期存量=每 (user, channel) 最新 ≤end 行,跨渠道相加;
- distinct 活跃客户=窗内 (uid, peer) 去重(peer_kind='friend')——
同一员工多账号的共享客户只计 1(与宽表日值口径一致);
- 窗口工具:闭区间 day_range/等长前窗/28 天个人基线窗;
- 幽灵员工(不在 staff)过滤;user_ids 交集;通话恒全渠道。
"""
import datetime as dt
from zoneinfo import ZoneInfo
from app.db.models import (
EmployeeCallDaily,
EmployeeMetricDaily,
FirstReplyFact,
MsgDailyStats,
Staff,
WechatAccount,
WhatsappAccount,
)
from app.management.employee_metrics import (
PeriodMetrics,
baseline_window,
day_range,
period_metrics,
prev_window,
team_medians,
)
_SH = ZoneInfo("Asia/Shanghai")
def _ts(*args) -> int:
return int(dt.datetime(*args, tzinfo=_SH).timestamp() * 1000)
async def _seed_org(s, users=("u1", "u2")):
for u in users:
s.add(Staff(user_id=u, name=u, is_on_job=True))
s.add(WechatAccount(wx_id="wx1", user_id="u1"))
s.add(WechatAccount(wx_id="wx2", user_id="u2"))
await s.commit()
def _mday(day, *, channel="wechat", user_id="u1", metric_version="v1", **kw):
cols = {
"msg_sent": 0, "msg_recv": 0, "active_friends": 0, "active_rooms": 0,
"cust_threads": 0, "threads_answered": 0, "fr_sum_ms": 0,
"new_customer_count": 0, "cooling_customer_count": 0,
"dormant_customer_count": 0, "reactivated_customer_count": 0,
"data_complete": True,
}
cols.update(kw)
return EmployeeMetricDaily(
day=day, channel=channel, user_id=user_id,
metric_version=metric_version, **cols
)
def _fact(fr_ms, *, channel="wechat", owner="wx1", peer="p1", day=20260901, i=0):
end = _ts(2026, 9, 1, 10, 0) + i * 60_000
return FirstReplyFact(
channel=channel, owner_account_id=owner, peer_id=peer,
thread_end_ts=end, reply_ts=end + fr_ms,
reply_msg_id=f"{owner}-{peer}-m{i}", day=day, fr_ms=fr_ms,
)
async def test_percentile_exact(make_session):
"""5 样本 p50=180s/p90=300s(ceil(2.5)=3、ceil(4.5)=5,1-based);
2 样本 p50=10s/p90=20s;窗外行不参与。"""
s = make_session
await _seed_org(s)
for i, sec in enumerate([60, 120, 180, 240, 300]):
s.add(_fact(sec * 1000, i=i))
for i, sec in enumerate([10, 20]):
s.add(_fact(sec * 1000, owner="wx2", peer="q1", i=i))
# 窗外(0906>end)长尾:混入则 p90=999s
s.add(_fact(999_000, i=9, day=20260906))
await s.commit()
m = await period_metrics(s, 20260901, 20260905, "wechat")
assert m["u1"].fr_p50_ms == 180_000
assert m["u1"].fr_p90_ms == 300_000
assert m["u2"].fr_p50_ms == 10_000
assert m["u2"].fr_p90_ms == 20_000
async def test_period_sums_vs_daily_rows(make_session):
"""宽表两天行 SUM 正确;days_with_data=2;active_rooms_avg 摊到全窗
5 天(含无数据日);channel/metric_version/窗外行都不计。"""
s = make_session
await _seed_org(s)
s.add(_mday(20260901, msg_sent=3, msg_recv=5, cust_threads=2,
threads_answered=1, fr_sum_ms=7_000, active_rooms=4,
reactivated_customer_count=1))
s.add(_mday(20260903, msg_sent=4, msg_recv=6, cust_threads=3,
threads_answered=2, fr_sum_ms=11_000, active_rooms=6,
reactivated_customer_count=2))
# 反例三连:错渠道 / 错版本 / 窗外
s.add(_mday(20260901, channel="whatsapp", msg_sent=100))
s.add(_mday(20260901, metric_version="v2", msg_sent=200))
s.add(_mday(20260906, msg_sent=400))
await s.commit()
m = await period_metrics(s, 20260901, 20260905, "wechat")
u1 = m["u1"]
assert u1.msg_sent == 7
assert u1.msg_recv == 11
assert u1.cust_threads == 5
assert u1.threads_answered == 3
assert u1.fr_sum_ms == 18_000
assert u1.days_with_data == 2
assert u1.active_rooms_avg == 2.0 # (4+6)/5 全窗天数,非 2 有数据日
assert u1.reactivated_customer == 3
async def test_end_of_period_stock(make_session):
"""期末存量=每渠道最新 ≤end 行:窗 [0901,0903] 取 0901 行、
[0901,0905] 取 0905 行;channel=None 跨渠道相加(4+2=6)。
end_active_friends 同源同行:wechat 7 + whatsapp 3 → 10(评分
active_share 分子,与周期 distinct 的 active_friends 字段不同口径)。"""
s = make_session
await _seed_org(s)
s.add(_mday(20260901, new_customer_count=1, cooling_customer_count=2,
dormant_customer_count=3, active_friends=7))
s.add(_mday(20260905, dormant_customer_count=4, active_friends=7))
s.add(_mday(20260905, channel="whatsapp", dormant_customer_count=2,
active_friends=3))
await s.commit()
m1 = await period_metrics(s, 20260901, 20260903, "wechat")
assert m1["u1"].new_customer == 1
assert m1["u1"].cooling_customer == 2
assert m1["u1"].dormant_customer == 3
assert m1["u1"].end_active_friends == 7 # 渠道内最新行(0901)的值
m2 = await period_metrics(s, 20260901, 20260905, "wechat")
assert m2["u1"].dormant_customer == 4 # wechat 渠道内最新行
assert m2["u1"].new_customer == 0
assert m2["u1"].end_active_friends == 7
m3 = await period_metrics(s, 20260901, 20260905, None)
assert m3["u1"].dormant_customer == 6 # wechat 4 + whatsapp 2
assert m3["u1"].end_active_friends == 10 # wechat 7 + whatsapp 3
async def test_distinct_active_cross_day(make_session):
"""同 peer 两天两行只计 1;room 行与幽灵 owner 不计。"""
s = make_session
await _seed_org(s)
for day, peer in [(20260901, "p1"), (20260902, "p1"), (20260902, "p2")]:
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id=peer, day=day))
# room 行不计入活跃客户
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="room", peer_id="r1", day=20260902))
# 幽灵 owner(account_map 无映射)不计
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx9",
peer_kind="friend", peer_id="p9", day=20260902))
await s.commit()
m = await period_metrics(s, 20260901, 20260902, "wechat")
assert m["u1"].active_friends == 2
async def test_active_friends_shared_peer_across_accounts(make_session):
"""多账号共享客户去重:u1 的 wx1/wx3 都会话 p1 → active_friends=1
(账号归并到员工后按 peer 去重,非 (owner, peer) 双计)。"""
s = make_session
await _seed_org(s)
s.add(WechatAccount(wx_id="wx3", user_id="u1"))
await s.commit()
for owner in ("wx1", "wx3"):
s.add(MsgDailyStats(channel="wechat", owner_account_id=owner,
peer_kind="friend", peer_id="p1", day=20260901))
await s.commit()
m = await period_metrics(s, 20260901, 20260905, "wechat")
assert m["u1"].active_friends == 1
assert m["u2"].active_friends == 0
def test_window_helpers():
# day_range 闭区间 + 月末边界
assert day_range(20260901, 20260903) == [20260901, 20260902, 20260903]
assert day_range(20260831, 20260901) == [20260831, 20260901]
# prev_window 等长紧邻前窗(7 天窗 → 7 天窗;单日 → 单日)
assert prev_window(20260908, 20260914) == (20260901, 20260907)
assert prev_window(20260901, 20260901) == (20260831, 20260831)
# baseline_window:紧邻窗前 28 天([0915] → [0818, 0914])
assert baseline_window(20260915) == (20260818, 20260914)
async def test_ghost_and_channel_none(make_session):
"""幽灵(不在 staff)宽表行/fact 行不计;channel=None 合并两渠道
amap——u1(wx1+wa1)的宽表 SUM 与 fact 分位都跨渠道归并;
无数据员工(u2)也以零值行出现。"""
s = make_session
await _seed_org(s)
s.add(WhatsappAccount(uid="wa1", user_id="u1"))
# 账号存在但映射到非 staff 用户 → 幽灵
s.add(WechatAccount(wx_id="wxg", user_id="ghost"))
await s.commit()
s.add(_mday(20260901, msg_sent=3))
s.add(_mday(20260901, channel="whatsapp", msg_sent=4))
s.add(_mday(20260901, user_id="ghost", msg_sent=50))
s.add(_fact(100_000, owner="wx1", i=0))
s.add(_fact(300_000, channel="whatsapp", owner="wa1", peer="w1", i=1))
s.add(_fact(50_000, owner="wxg", i=2))
await s.commit()
m = await period_metrics(s, 20260901, 20260905, None)
assert set(m) == {"u1", "u2"} # ghost 不在;零数据 u2 在
u1 = m["u1"]
assert u1.msg_sent == 7 # 两渠道行都归 u1
assert u1.days_with_data == 1 # 双渠道同日只计 1 天(DISTINCT day)
assert u1.fr_p50_ms == 100_000 # [100s,300s] 合并后 ceil(0.5*2)=1
assert u1.fr_p90_ms == 300_000 # ceil(0.9*2)=2
assert u1.reply_2h_count == 2
assert u1.reply_24h_count == 2
assert m["u2"].msg_sent == 0
assert m["u2"].fr_p50_ms is None
async def test_reply_counts(make_session):
"""fr=90min/3h/25h → 2h_count=1、24h_count=2(2h 是 24h 的子集)。"""
s = make_session
await _seed_org(s)
s.add(_fact(5_400_000, i=0)) # 90min ≤2h
s.add(_fact(10_800_000, i=1)) # 3h ∈(2h,24h]
s.add(_fact(90_000_000, i=2)) # 25h >24h
await s.commit()
m = await period_metrics(s, 20260901, 20260905, "wechat")
assert m["u1"].reply_2h_count == 1
assert m["u1"].reply_24h_count == 2
assert m["u2"].reply_2h_count == 0
async def test_user_ids_filter_and_calls(make_session):
"""user_ids 与 staff 求交(未知 id 剔除/空集→{});通话四列按窗 SUM
且不受 channel 参数影响(无渠道维,恒全渠道)。"""
s = make_session
await _seed_org(s)
s.add(_mday(20260901, user_id="u1", msg_sent=1))
s.add(_mday(20260901, user_id="u2", msg_sent=2))
s.add(EmployeeCallDaily(day=20260901, user_id="u1", call_count=3,
call_seconds=600, answered_call_count=2,
call_followup_count=1))
s.add(EmployeeCallDaily(day=20260902, user_id="u1", call_count=1,
call_seconds=60, answered_call_count=1,
call_followup_count=0))
s.add(EmployeeCallDaily(day=20260901, user_id="u2", call_count=9))
await s.commit()
m = await period_metrics(s, 20260901, 20260905, "wechat", user_ids=["u1", "nobody"])
assert set(m) == {"u1"}
assert m["u1"].msg_sent == 1
u1 = m["u1"]
assert u1.call_count == 4
assert u1.call_seconds == 660
assert u1.answered_call == 3
assert u1.call_followup == 1
empty = await period_metrics(s, 20260901, 20260905, "wechat", user_ids=[])
assert empty == {}
# channel="whatsapp" 时通话仍计入(恒全渠道)
mwa = await period_metrics(s, 20260901, 20260905, "whatsapp")
assert mwa["u1"].call_count == 4
def test_team_medians():
"""奇数样本取中位、偶数取均值;None 剔除;全 None → None;user_id 不参与。"""
def _pm(**kw):
base = dict(zip(PeriodMetrics._fields, [None] * len(PeriodMetrics._fields)))
base.update(user_id="x", days_with_data=0, msg_sent=0, msg_recv=0,
cust_threads=0, threads_answered=0, fr_sum_ms=0,
reply_2h_count=0, reply_24h_count=0, active_friends=0,
active_rooms_avg=0.0, new_customer=0, cooling_customer=0,
dormant_customer=0, reactivated_customer=0,
call_count=0, call_seconds=0, answered_call=0,
call_followup=0)
base.update(kw)
return PeriodMetrics(**base)
metrics = {
"u1": _pm(user_id="u1", msg_sent=10, fr_p50_ms=None),
"u2": _pm(user_id="u2", msg_sent=20, fr_p50_ms=100),
"u3": _pm(user_id="u3", msg_sent=30, fr_p50_ms=200),
}
med = team_medians(metrics)
assert med["msg_sent"] == 20
assert med["fr_p50_ms"] == 150.0 # None 剔除后 [100,200] 均值
assert "user_id" not in med
assert team_medians({})["msg_sent"] is None
assert team_medians({"u1": _pm()})["fr_p50_ms"] is None