2026-09-04 16:06:18 +08:00
|
|
|
|
"""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
|
2026-09-04 16:28:39 +08:00
|
|
|
|
# 映射按渠道取一次缓存(而非 day×channel 每轮重查)——全量重建时
|
|
|
|
|
|
# ~600 次重复小表全查收敛为每渠道 1 次,且整个重建用同一份映射快照。
|
|
|
|
|
|
maps = {ch: await account_map(session, ch) for ch in channels}
|
2026-09-04 16:06:18 +08:00
|
|
|
|
for day in sorted(set(days)):
|
|
|
|
|
|
for channel in channels:
|
2026-09-04 16:28:39 +08:00
|
|
|
|
amap = maps[channel]
|
2026-09-04 16:06:18 +08:00
|
|
|
|
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 14:13:09 +08:00
|
|
|
|
# 首响/回复率三列(2026-09-07 批1):同义聚合累加
|
|
|
|
|
|
MsgDailyStats.cust_threads,
|
|
|
|
|
|
MsgDailyStats.threads_answered,
|
|
|
|
|
|
MsgDailyStats.fr_sum_ms,
|
2026-09-04 16:06:18 +08:00
|
|
|
|
).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] = {}
|
2026-09-07 14:13:09 +08:00
|
|
|
|
for owner, kind, peer, s, r, t, a_ns, fr in rows:
|
2026-09-04 16:06:18 +08:00
|
|
|
|
uid = amap.get(owner, owner) # 映射不上按 owner 原值(读侧标注)
|
|
|
|
|
|
a = agg.setdefault(
|
2026-09-07 14:13:09 +08:00
|
|
|
|
uid,
|
|
|
|
|
|
{
|
|
|
|
|
|
"sent": 0,
|
|
|
|
|
|
"recv": 0,
|
|
|
|
|
|
"friends": set(),
|
|
|
|
|
|
"rooms": set(),
|
|
|
|
|
|
"threads": 0,
|
|
|
|
|
|
"answered": 0,
|
|
|
|
|
|
"fr": 0,
|
|
|
|
|
|
},
|
2026-09-04 16:06:18 +08:00
|
|
|
|
)
|
|
|
|
|
|
a["sent"] += int(s or 0)
|
|
|
|
|
|
a["recv"] += int(r or 0)
|
2026-09-07 14:13:09 +08:00
|
|
|
|
a["threads"] += int(t or 0)
|
|
|
|
|
|
a["answered"] += int(a_ns or 0)
|
|
|
|
|
|
a["fr"] += int(fr or 0)
|
2026-09-04 16:28:39 +08:00
|
|
|
|
if kind == "friend": # 显式分支:未来新 kind 跳过,不误入 rooms
|
|
|
|
|
|
a["friends"].add(peer)
|
|
|
|
|
|
elif kind == "room":
|
|
|
|
|
|
a["rooms"].add(peer)
|
2026-09-04 16:06:18 +08:00
|
|
|
|
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"]),
|
2026-09-07 14:13:09 +08:00
|
|
|
|
cust_threads=a["threads"],
|
|
|
|
|
|
threads_answered=a["answered"],
|
|
|
|
|
|
fr_sum_ms=a["fr"],
|
2026-09-04 16:06:18 +08:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
n += 1
|
|
|
|
|
|
return n
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def recompute_all(session: AsyncSession) -> int:
|
2026-09-04 16:28:39 +08:00
|
|
|
|
"""全量重建(夜间自愈 / 部署回填,幂等)。
|
|
|
|
|
|
|
|
|
|
|
|
一次清空保证 msg_daily_stats 中已消失的日期不留陈旧行(夜间
|
|
|
|
|
|
summary.recompute_all 清空重建 msg_daily_stats 后,本层同步自愈)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
await session.execute(sa.delete(StatDailyStaff))
|
2026-09-04 16:06:18 +08:00
|
|
|
|
days = (
|
|
|
|
|
|
await session.execute(sa.select(MsgDailyStats.day).distinct())
|
|
|
|
|
|
).scalars().all()
|
|
|
|
|
|
return await recompute_days(session, list(days))
|