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>
249 lines
10 KiB
Python
249 lines
10 KiB
Python
"""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)
|