Files
yunke/yunke-server/app/storage/staff_summary.py
T
浅念 feb3b98665 feat(analytics): 首响时长+回复率数据管道——消息串算法/日汇总扩展列/员工聚合
批1(A1+A2):管理层要看「每个人的响应质量」。

- 迁移 d9e0f1a2b3c4:msg_daily_stats/stat_daily_staff 各加
  cust_threads/threads_answered/fr_sum_ms(NOT NULL server_default 0,
  幂等守卫照 9b0d0c133035 惯例;models.py 双声明同步)
- summary.py _thread_stats:串=客户连续消息不被员工消息打断的段,
  is_send=None 不参与串判定;fr=员工首条回复−串末条客户消息;
  日切近似 v1(日末未闭合串计 threads 不计 answered,口径写进 docstring)
- recompute_days/recompute_all 都先按 ts 排序再算串(乱序防护)
- staff_summary 聚合三列按账号→员工累加(fr_cnt≡threads_answered 不单列)
- 测试 +10:串算法矩阵 9 例+staff 聚合传递;全量 589 绿
- 本地 86 万行回填验证:daily-stats 60s/staff 10s,rollup 总和与
  明细完全一致,随机 5 行 SQL 独立复算全对

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-07 14:13:09 +08:00

162 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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))