"""管理域期聚合查询判别测试(批次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