"""stat_daily_staff(员工×日)维护层——rollup-of-rollup,不挂写钩子。 刷新策略(spec §3.1):4h 调度刷当日+滚动 3 天(staff_summary.rolling_days), 夜间 full_sync 全量自愈(recompute_all),部署回填 CLI 同款。空表时读端点 回退直接聚合 msg_daily_stats(毫秒级)——部署即用,沿用 sessions 的 has_rows 回退模式。 """ from __future__ import annotations import datetime as dt import logging from collections.abc import Iterable from zoneinfo import ZoneInfo import sqlalchemy as sa from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import MsgDailyStats, StatDailyStaff, WechatAccount, WhatsappAccount logger = logging.getLogger(__name__) _SH = ZoneInfo("Asia/Shanghai") async def has_rows(session: AsyncSession) -> bool: """汇总表是否已有数据(读端点空表守卫;表不存在与空表同视)。""" try: return bool( ( await session.execute( sa.select(sa.func.count()).select_from(StatDailyStaff) ) ).scalar_one() ) except (sa.exc.OperationalError, sa.exc.ProgrammingError): return False async def account_map(session: AsyncSession, channel: str) -> dict[str, str]: """owner_account_id → user_id(按渠道选账号表;映射不上的不在 dict)。""" if channel == "whatsapp": stmt = sa.select(WhatsappAccount.uid, WhatsappAccount.user_id).where( WhatsappAccount.user_id.is_not(None) ) else: stmt = sa.select(WechatAccount.wx_id, WechatAccount.user_id).where( WechatAccount.user_id.is_not(None) ) return {owner: uid for owner, uid in (await session.execute(stmt)).all() if uid} def rolling_days(today: int, window: int = 3) -> list[int]: """当日 + 前 window-1 天的 day 键(4h job 的滚动刷新窗)。""" d = _day_from_key(today) return [_key_from_day(d - dt.timedelta(days=i)) for i in range(window - 1, -1, -1)] def _day_from_key(day: int) -> dt.date: return dt.date(day // 10000, (day // 100) % 100, day % 100) def _key_from_day(d: dt.date) -> int: return d.year * 10000 + d.month * 100 + d.day async def recompute_days( session: AsyncSession, days: Iterable[int], channels: tuple[str, ...] = ("wechat", "whatsapp") ) -> int: """重算给定 day×channel 的员工行:删旧插新,幂等。 返回**有数据行的 day×channel 组合数**(空渠道不计——与测试断言的 「天数」语义一致,seed 只有 wechat 日行时返回 1)。 单日日行仅数百~两千,拉回 Python 侧按 user_id 聚合——COUNT(DISTINCT peer) 必须在「账号合并到员工」之后算(同人多账号 distinct peer 跨账号 去重),写 SQL 反而绕且踩 only_full_group_by。 """ n = 0 # 映射按渠道取一次缓存(而非 day×channel 每轮重查)——全量重建时 # ~600 次重复小表全查收敛为每渠道 1 次,且整个重建用同一份映射快照。 maps = {ch: await account_map(session, ch) for ch in channels} for day in sorted(set(days)): for channel in channels: amap = maps[channel] rows = ( await session.execute( sa.select( MsgDailyStats.owner_account_id, MsgDailyStats.peer_kind, MsgDailyStats.peer_id, MsgDailyStats.send_cnt, MsgDailyStats.recv_cnt, # 首响/回复率三列(2026-09-07 批1):同义聚合累加 MsgDailyStats.cust_threads, MsgDailyStats.threads_answered, MsgDailyStats.fr_sum_ms, ).where( MsgDailyStats.day == day, MsgDailyStats.channel == channel ) ) ).all() await session.execute( sa.delete(StatDailyStaff).where( StatDailyStaff.day == day, StatDailyStaff.channel == channel ) ) if not rows: continue agg: dict[str, dict] = {} for owner, kind, peer, s, r, t, a_ns, fr in rows: uid = amap.get(owner, owner) # 映射不上按 owner 原值(读侧标注) a = agg.setdefault( uid, { "sent": 0, "recv": 0, "friends": set(), "rooms": set(), "threads": 0, "answered": 0, "fr": 0, }, ) a["sent"] += int(s or 0) a["recv"] += int(r or 0) a["threads"] += int(t or 0) a["answered"] += int(a_ns or 0) a["fr"] += int(fr or 0) if kind == "friend": # 显式分支:未来新 kind 跳过,不误入 rooms a["friends"].add(peer) elif kind == "room": a["rooms"].add(peer) for uid, a in sorted(agg.items()): session.add( StatDailyStaff( day=day, channel=channel, user_id=uid, msg_sent=a["sent"], msg_recv=a["recv"], active_friends=len(a["friends"]), active_rooms=len(a["rooms"]), cust_threads=a["threads"], threads_answered=a["answered"], fr_sum_ms=a["fr"], ) ) n += 1 return n async def recompute_all(session: AsyncSession) -> int: """全量重建(夜间自愈 / 部署回填,幂等)。 一次清空保证 msg_daily_stats 中已消失的日期不留陈旧行(夜间 summary.recompute_all 清空重建 msg_daily_stats 后,本层同步自愈)。 """ await session.execute(sa.delete(StatDailyStaff)) days = ( await session.execute(sa.select(MsgDailyStats.day).distinct()) ).scalars().all() return await recompute_days(session, list(days))