feb3b98665
批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>
162 lines
6.2 KiB
Python
162 lines
6.2 KiB
Python
"""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))
|