"""stat_daily_staff 维护层:派生/幂等/判别法/未映射兜底。 判别法(9-03 先例):rollup 只读 msg_daily_stats——回填后删光 message 明细, 员工汇总重算仍出完整数据。 """ from __future__ import annotations import datetime as dt from app.db.models import ( Message, MsgDailyStats, StatDailyStaff, WechatAccount, WhatsappAccount, ) from app.storage import staff_summary def _mk_msg(msg_id: str, ts: int, is_send: bool | None) -> Message: return Message( msg_id=msg_id, channel="wechat", owner_account_id="wx1", talker_id="t1", timestamp=ts, is_send=is_send, text="x", fetched_at=dt.datetime(2026, 9, 4, 12, 0), ) async def _seed_pair(session) -> None: """两账号同一员工(wx1/wx2→u1) + 未映射账号 wx3,20260904 各有消息。""" session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三")) session.add(WechatAccount(wx_id="wx2", user_id="u1", user_name="张三")) rows = [ MsgDailyStats(channel="wechat", owner_account_id="wx1", peer_kind="friend", peer_id="t1", day=20260904, send_cnt=10, recv_cnt=5, last_ts=1756900000000, last_is_send=True), MsgDailyStats(channel="wechat", owner_account_id="wx1", peer_kind="room", peer_id="r1", day=20260904, send_cnt=3, recv_cnt=0, last_ts=1756900000000, last_is_send=True), MsgDailyStats(channel="wechat", owner_account_id="wx2", peer_kind="friend", peer_id="t1", day=20260904, send_cnt=2, recv_cnt=1, last_ts=1756900001000, last_is_send=False), MsgDailyStats(channel="wechat", owner_account_id="wx3", peer_kind="friend", peer_id="t9", day=20260904, send_cnt=1, recv_cnt=0, last_ts=1756900002000, last_is_send=True), ] session.add_all(rows) await session.commit() async def _seed_wa_and_days(session) -> None: """wechat 20260903/20260904 两天 + whatsapp 20260904 一天(uid→wa_u1)。""" session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三")) session.add(WhatsappAccount(uid="wa1@x", user_id="wa_u1", user_name="李四")) rows = [ MsgDailyStats(channel="wechat", owner_account_id="wx1", peer_kind="friend", peer_id="t1", day=20260903, send_cnt=4, recv_cnt=2, last_ts=1756800000000, last_is_send=True), MsgDailyStats(channel="wechat", owner_account_id="wx1", peer_kind="friend", peer_id="t1", day=20260904, send_cnt=10, recv_cnt=5, last_ts=1756900000000, last_is_send=True), MsgDailyStats(channel="whatsapp", owner_account_id="wa1@x", peer_kind="friend", peer_id="p1", day=20260904, send_cnt=7, recv_cnt=3, last_ts=1756900005000, last_is_send=True), ] session.add_all(rows) await session.commit() async def test_recompute_multi_day_and_whatsapp(make_client): """行按 (day, channel, user_id) 正确分键:wechat 两天各一行 + whatsapp 一行; WA 走 WhatsappAccount.uid→user_id 映射(而非 wechat 账号表)。""" async def seed(session): await _seed_wa_and_days(session) n = await staff_summary.recompute_days(session, [20260903, 20260904]) await session.commit() assert n == 3 # wechat×2天 + whatsapp×1天(空 day×channel 不计) from sqlalchemy import select rows = (await session.execute(select(StatDailyStaff))).scalars().all() keys = {(r.day, r.channel, r.user_id) for r in rows} assert keys == { (20260903, "wechat", "u1"), (20260904, "wechat", "u1"), (20260904, "whatsapp", "wa_u1"), } wa = next(r for r in rows if r.channel == "whatsapp") assert wa.msg_sent == 7 and wa.msg_recv == 3 and wa.active_friends == 1 await make_client(seed) async def test_stale_day_cleanup(make_client): """陈旧行清理:msg_daily_stats 里已消失的日期,重算后 stat 行必须消失。 - recompute_all 全表清空自愈:day 从 msg_daily_stats 整体消失(夜间 summary.recompute_all 清空重建的典型场景)→ distinct days 不含它, 只有全表清空能救; - recompute_days 空日 delete-before-continue:day 仍在但某渠道行删光 → 该 day×channel 的 stat 行删除,同日 wechat 行保留。 """ async def seed(session): from sqlalchemy import delete, select await _seed_wa_and_days(session) await staff_summary.recompute_all(session) # stat 先有 3 行 await session.commit() # 场景 1:20260903 整天从 msg_daily_stats 消失 → recompute_all 自愈 await session.execute( delete(MsgDailyStats).where(MsgDailyStats.day == 20260903) ) n = await staff_summary.recompute_all(session) await session.commit() assert n == 2 # 只剩 20260904 的 wechat + whatsapp 两个 day×channel days = set( (await session.execute(select(StatDailyStaff.day))).scalars().all() ) assert days == {20260904} # 20260903 陈旧行被清 # 场景 2:20260904 的 whatsapp 行删光 → 空日重算删行,wechat 保留 await session.execute( delete(MsgDailyStats).where(MsgDailyStats.channel == "whatsapp") ) await staff_summary.recompute_days(session, [20260904]) await session.commit() keys = { (r.day, r.channel) for r in (await session.execute(select(StatDailyStaff))).scalars().all() } assert keys == {(20260904, "wechat")} # whatsapp 行消失,wechat 不动 await make_client(seed) async def test_recompute_aggregates_by_user(make_client): """两账号同人合并(sent=10+3+2=15,recv=5+0+1=6;t1 在 wx1/wx2 都聊 → 合并后 distinct friend={t1} + room={r1});wx3 未映射按原值。""" async def seed(session): await _seed_pair(session) await staff_summary.recompute_days(session, [20260904]) await session.commit() from sqlalchemy import select rows = (await session.execute(select(StatDailyStaff))).scalars().all() by_uid = {r.user_id: r for r in rows} assert set(by_uid) == {"u1", "wx3"} # wx3 映射不上按 owner 原值 u1 = by_uid["u1"] assert u1.msg_sent == 15 and u1.msg_recv == 6 assert u1.active_friends == 1 # t1 跨账号去重 assert u1.active_rooms == 1 assert by_uid["wx3"].active_friends == 1 await make_client(seed) async def test_recompute_idempotent(make_client): async def seed(session): await _seed_pair(session) await staff_summary.recompute_days(session, [20260904]) await staff_summary.recompute_days(session, [20260904]) # 再跑不翻倍 await session.commit() from sqlalchemy import func, select n = ( await session.execute( select(func.count()).select_from(StatDailyStaff) ) ).scalar_one() assert n == 2 # u1 + wx3 各一行 await make_client(seed) async def test_discriminant_survives_message_delete(make_client): """判别法:rollup 回填后删光 message,重算结果不变(只依赖 msg_daily_stats)。""" async def seed(session): await _seed_pair(session) session.add(_mk_msg("m1", 1756900000000, True)) await session.commit() await staff_summary.recompute_days(session, [20260904]) await session.commit() from sqlalchemy import delete, select await session.execute(delete(Message)) await staff_summary.recompute_days(session, [20260904]) # 自愈重算 await session.commit() rows = (await session.execute(select(StatDailyStaff))).scalars().all() by_uid = {r.user_id: r for r in rows} assert by_uid["u1"].msg_sent == 15 # 数据不丢 await make_client(seed) async def test_recompute_all_and_rolling(make_client): async def seed(session): await _seed_pair(session) n = await staff_summary.recompute_all(session) await session.commit() assert n == 1 # 全库只有一个「有数据的 day×channel」 await make_client(seed) days = staff_summary.rolling_days(20260904, window=3) assert days == [20260902, 20260903, 20260904] async def test_recompute_carries_thread_stats(make_client): """首响/回复率三列(2026-09-07 批1)按账号→员工累加传递;未映射账号同样保留。""" async def seed(session): session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三")) session.add(WechatAccount(wx_id="wx2", user_id="u1", user_name="张三")) rows = [ MsgDailyStats(channel="wechat", owner_account_id="wx1", peer_kind="friend", peer_id="t1", day=20260904, send_cnt=10, recv_cnt=5, last_ts=1756900000000, last_is_send=True, cust_threads=4, threads_answered=3, fr_sum_ms=600_000), MsgDailyStats(channel="wechat", owner_account_id="wx2", peer_kind="room", peer_id="r1", day=20260904, send_cnt=2, recv_cnt=1, last_ts=1756900001000, last_is_send=False, cust_threads=2, threads_answered=1, fr_sum_ms=240_000), # 旧数据行(迁移前):三列 server_default 兜 0,聚合不炸 MsgDailyStats(channel="wechat", owner_account_id="wx3", peer_kind="friend", peer_id="t9", day=20260904, send_cnt=1, recv_cnt=0, last_ts=1756900002000, last_is_send=True), ] session.add_all(rows) await session.commit() await staff_summary.recompute_days(session, [20260904]) await session.commit() from sqlalchemy import select by_uid = { r.user_id: r for r in (await session.execute(select(StatDailyStaff))).scalars().all() } u1 = by_uid["u1"] # wx1+wx2 合并:threads=6, answered=4, fr_sum=840000 assert u1.cust_threads == 6 assert u1.threads_answered == 4 assert u1.fr_sum_ms == 840_000 assert by_uid["wx3"].cust_threads == 0 # 未传三列 → server_default 0 assert by_uid["wx3"].threads_answered == 0 assert by_uid["wx3"].fr_sum_ms == 0 await make_client(seed)