729710da63
Co-Authored-By: Claude Code <noreply@anthropic.com>
277 lines
11 KiB
Python
277 lines
11 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 活跃客户=窗内 (owner, peer) 去重(peer_kind='friend');
|
|
- 窗口工具:闭区间 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)。"""
|
|
s = make_session
|
|
await _seed_org(s)
|
|
s.add(_mday(20260901, new_customer_count=1, cooling_customer_count=2,
|
|
dormant_customer_count=3))
|
|
s.add(_mday(20260905, dormant_customer_count=4))
|
|
s.add(_mday(20260905, channel="whatsapp", dormant_customer_count=2))
|
|
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
|
|
|
|
m2 = await period_metrics(s, 20260901, 20260905, "wechat")
|
|
assert m2["u1"].dormant_customer == 4 # wechat 渠道内最新行
|
|
assert m2["u1"].new_customer == 0
|
|
|
|
m3 = await period_metrics(s, 20260901, 20260905, None)
|
|
assert m3["u1"].dormant_customer == 6 # wechat 4 + whatsapp 2
|
|
|
|
|
|
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
|
|
|
|
|
|
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.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
|