"""employee_call_daily / employee_metric_daily 生成判别测试(批次A Task5/6)。 判据(v2 §1.1 #2/#5、§1.2 #9、§1.3 #11): - 通话无渠道维,独立小表;接通=duration>0;followup=24h 内任一账号有消息; - 幽灵员工(owner 映射不上/不在 staff)过滤; - 宽表消息计数 friend-only;串类与 fact 同源。 """ import datetime as dt from zoneinfo import ZoneInfo import sqlalchemy as sa from app.db.models import ( Customer, EmployeeCallDaily, EmployeeMetricDaily, FirstReplyFact, Message, MsgDailyStats, PhoneCall, Staff, WechatAccount, WhatsappAccount, ) from app.storage.employee_metrics import recompute_call_days, recompute_metric_days _SH = ZoneInfo("Asia/Shanghai") def _ts(*args) -> int: return int(dt.datetime(*args, tzinfo=_SH).timestamp() * 1000) async def _seed_org(session): session.add(Staff(user_id="u1", name="张三", is_on_job=True)) session.add(WechatAccount(wx_id="wx1", user_id="u1")) # 第二个微信号:验证 followup「任一账号」语义(owner-IN 而非单账号) session.add(WechatAccount(wx_id="wx2", user_id="u1")) # WA 账号:验证 wa_voice_call 渠道收窄正例 session.add(WhatsappAccount(uid="wa1", user_id="u1")) await session.commit() async def test_call_daily_aggregates(make_session): s = make_session await _seed_org(s) s.add(PhoneCall(call_id="c1", channel="voice_call", user_id="u1", call_number="p1", start_call_time=_ts(2026, 9, 1, 10, 0), call_duration=120)) s.add(PhoneCall(call_id="c2", channel="voice_call", user_id="u1", call_number="p2", start_call_time=_ts(2026, 9, 1, 15, 0), call_duration=0)) # 未接通 # c3:WA 语音,接通,窗口内有 WA 消息 → followup 正例(渠道收窄匹配) s.add(PhoneCall(call_id="c3", channel="wa_voice_call", user_id="u1", call_number="p3", start_call_time=_ts(2026, 9, 1, 10, 0), call_duration=60)) # c4:微信语音,接通,但窗口内只有 WA 消息 → followup 反例(渠道收窄) s.add(PhoneCall(call_id="c4", channel="voice_call", user_id="u1", call_number="p4", start_call_time=_ts(2026, 9, 1, 10, 0), call_duration=90)) # c1 后 2h 员工**另一微信号 wx2** 有跟进消息(任一账号语义:owner-IN) s.add(Message(msg_id="f1", channel="wechat", owner_account_id="wx2", talker_id="p1", timestamp=_ts(2026, 9, 1, 12, 0), is_send=True, type="text", text="跟进")) # c2(未接通)后 1h 有消息:即使任一方向也不算 followup(仅已接通判定) s.add(Message(msg_id="f2", channel="wechat", owner_account_id="wx1", talker_id="p2", timestamp=_ts(2026, 9, 1, 16, 0), is_send=False, type="text", text="客户回拨?")) # c3 后 2h WA 账号有消息 → 正例 s.add(Message(msg_id="f3", channel="whatsapp", owner_account_id="wa1", talker_id="p3", timestamp=_ts(2026, 9, 1, 12, 0), is_send=True, type="text", text="wa 跟进")) # c4(voice_call→wechat)窗口内只有 WA 消息 → 不匹配渠道,反例 s.add(Message(msg_id="f4", channel="whatsapp", owner_account_id="wa1", talker_id="p4", timestamp=_ts(2026, 9, 1, 12, 0), is_send=True, type="text", text="错渠道")) await s.commit() n = await recompute_call_days(s, [20260901]) await s.commit() assert n == 1 row = (await s.execute(sa.select(EmployeeCallDaily))).scalar_one() assert row.call_count == 4 assert row.call_seconds == 270 # 120+0+60+90;duration 单位=秒(Task0 已核实) assert row.answered_call_count == 3 # c1/c3/c4 # c1(wx2 任一账号)+ c3(wa 渠道正例);c2 未接通守卫、c4 渠道收窄反例 assert row.call_followup_count == 2 async def test_call_daily_ignores_unknown_staff(make_session): s = make_session await _seed_org(s) s.add(PhoneCall(call_id="c9", channel="phone_call", user_id="ghost", call_number="p9", start_call_time=_ts(2026, 9, 1, 10, 0), call_duration=60)) await s.commit() await recompute_call_days(s, [20260901]) await s.commit() assert (await s.execute( sa.select(sa.func.count()).select_from(EmployeeCallDaily))).scalar_one() == 0 async def _seed_msg_rows(s, *, day=20260901, channel="wechat", owner="wx1", kind="friend", peer="p1", send=3, recv=2): s.add(MsgDailyStats(channel=channel, owner_account_id=owner, peer_kind=kind, peer_id=peer, day=day, send_cnt=send, recv_cnt=recv)) await s.commit() async def test_metric_daily_friend_only_caliber(make_session): """口径判别(v2 §1.1 #2 防线):room 行不进消息计数/串列,只进 active_rooms。""" s = make_session await _seed_org(s) await _seed_msg_rows(s, send=3, recv=2, kind="friend", peer="p1") await _seed_msg_rows(s, send=10, recv=0, kind="room", peer="r1") # friend 会话给一条消息供串扫描(无消息则无串) s.add(Message(msg_id="p1-0", channel="wechat", owner_account_id="wx1", talker_id="p1", timestamp=_ts(2026, 9, 1, 10, 0), is_send=False, type="text", text="q")) await s.commit() await recompute_metric_days(s, [20260901]) await s.commit() row = (await s.execute(sa.select(EmployeeMetricDaily))).scalar_one() assert row.msg_sent == 3 and row.msg_recv == 2 # room 的 10 不进 assert row.active_rooms == 1 assert row.cust_threads == 1 and row.threads_answered == 0 # 未回复串 async def test_metric_daily_ghost_filtered(make_session): s = make_session await _seed_org(s) await _seed_msg_rows(s, owner="wx-ghost", peer="pg") await recompute_metric_days(s, [20260901]) await s.commit() assert (await s.execute( sa.select(sa.func.count()).select_from(EmployeeMetricDaily))).scalar_one() == 0 async def test_metric_daily_same_source_as_fact(make_session): """同源判别(v2 §1.1 #5 防线): 1) threads_answered ≡ fact 行数(同归属日); 2) 多日未回串宽表 threads < 日切 cust_threads 之和(分母不再膨胀); 3) 幂等。""" from app.storage.first_reply_facts import recompute_pairs s = make_session await _seed_org(s) # 客户 D1 10:00、D1 20:00、D2 09:00 三条消息,始终未回 → 1 个跨日串 rows = [(_ts(2026, 9, 1, 10, 0), False), (_ts(2026, 9, 1, 20, 0), False), (_ts(2026, 9, 2, 9, 0), False)] for i, (ts, is_send) in enumerate(rows): s.add(Message(msg_id=f"g{i}", channel="wechat", owner_account_id="wx1", talker_id="pg", timestamp=ts, is_send=is_send, type="text", text="x")) # 日切口径的 msg_daily_stats:D1、D2 各计一个未回串(供对照) await _seed_msg_rows(s, day=20260901, peer="pg", send=0, recv=2) await _seed_msg_rows(s, day=20260902, peer="pg", send=0, recv=1) await s.commit() await recompute_pairs(s, [("wechat", "wx1", "pg")]) await s.commit() await recompute_metric_days(s, [20260901, 20260902]) await s.commit() got = {r.day: r for r in ( await s.execute(sa.select(EmployeeMetricDaily))).scalars().all()} # 1) fact 行数=0(未回),宽表 threads_answered 也=0 且同源 assert (await s.execute( sa.select(sa.func.count()).select_from(FirstReplyFact))).scalar_one() == 0 # 2) 跨日串只归属 D2;日切 D1+D2=2,宽表合计=1 assert got[20260901].cust_threads == 0 assert got[20260902].cust_threads == 1 # 3) 幂等 await recompute_metric_days(s, [20260901, 20260902]) await s.commit() assert len((await s.execute( sa.select(EmployeeMetricDaily))).scalars().all()) == 2 async def test_metric_daily_lifecycle_counts(make_session): """生命周期列:cooling/dormant/reactivated 判定进宽表。""" s = make_session await _seed_org(s) # 客户 pc:D1 有互动后静默(历史≥5 条) → as_of=10-10 为 dormant; # 10-11 回流 → 该日 reactivated s.add(Customer(friend_id="pc", channel="wechat", owner_account_id="wx1", delete_flag=0)) hist = [] for h in range(9, 17): # D1 8 条消息(≥5) hist.append((_ts(2026, 9, 1, h, 0), h % 2 == 0)) for i, (ts, is_send) in enumerate(hist): s.add(Message(msg_id=f"lc{i}", channel="wechat", owner_account_id="wx1", talker_id="pc", timestamp=ts, is_send=is_send, type="text", text="x")) await _seed_msg_rows(s, day=20260901, peer="pc", send=4, recv=4) await s.commit() # D41(10-11)客户回流一条 s.add(Message(msg_id="lc-b", channel="wechat", owner_account_id="wx1", talker_id="pc", timestamp=_ts(2026, 10, 11, 9, 0), is_send=False, type="text", text="back")) await _seed_msg_rows(s, day=20261011, peer="pc", send=0, recv=1) await s.commit() from app.storage.first_reply_facts import recompute_pairs await recompute_pairs(s, [("wechat", "wx1", "pc")]) await recompute_metric_days(s, [20261010, 20261011]) await s.commit() got = {r.day: r for r in ( await s.execute(sa.select(EmployeeMetricDaily))).scalars().all()} assert got[20261010].dormant_customer_count == 1 # 39 天静默+历史 8 条 assert got[20261011].reactivated_customer_count == 1 # 当日回流且 gap>30