fix(management): 期聚合active_friends按(uid,peer)去重——多账号共享客户不再双计;reply计数并入Python/单一METRIC_VERSION

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-09 17:32:34 +08:00
parent 729710da63
commit b51b32a14b
2 changed files with 29 additions and 27 deletions
+10 -26
View File
@@ -26,7 +26,6 @@ import datetime as dt
import statistics import statistics
from collections import namedtuple from collections import namedtuple
from collections.abc import Iterable from collections.abc import Iterable
from zoneinfo import ZoneInfo
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -38,11 +37,9 @@ from app.db.models import (
MsgDailyStats, MsgDailyStats,
Staff, Staff,
) )
from app.storage.employee_metrics import METRIC_VERSION
from app.storage.staff_summary import account_map from app.storage.staff_summary import account_map
_SH = ZoneInfo("Asia/Shanghai")
METRIC_VERSION = "v1"
PeriodMetrics = namedtuple("PeriodMetrics", [ PeriodMetrics = namedtuple("PeriodMetrics", [
"user_id", "days_with_data", "msg_sent", "msg_recv", "cust_threads", "user_id", "days_with_data", "msg_sent", "msg_recv", "cust_threads",
"threads_answered", "fr_sum_ms", "fr_p50_ms", "fr_p90_ms", "threads_answered", "fr_sum_ms", "fr_p50_ms", "fr_p90_ms",
@@ -226,7 +223,7 @@ async def period_metrics(
a["cooling_customer"] = int(cool or 0) a["cooling_customer"] = int(cool or 0)
a["dormant_customer"] = int(dorm or 0) a["dormant_customer"] = int(dorm or 0)
# ---- fact 分位 + 2h/24h 回复计数(同一过滤) ---- # ---- fact 分位 + 2h/24h 回复计数(同一过滤,Python 侧一次聚合) ----
fr_conds = [FirstReplyFact.day.between(start_day, end_day)] fr_conds = [FirstReplyFact.day.between(start_day, end_day)]
if channel is not None: if channel is not None:
fr_conds.append(FirstReplyFact.channel == channel) fr_conds.append(FirstReplyFact.channel == channel)
@@ -244,26 +241,11 @@ async def period_metrics(
for uid, frs in fr_by_user.items(): for uid, frs in fr_by_user.items():
acc[uid]["fr_p50_ms"] = _percentile(frs, 1, 2) acc[uid]["fr_p50_ms"] = _percentile(frs, 1, 2)
acc[uid]["fr_p90_ms"] = _percentile(frs, 9, 10) acc[uid]["fr_p90_ms"] = _percentile(frs, 9, 10)
# 2h/24h 计数与分位同源同窗——回复率分子分母口径一致
acc[uid]["reply_2h_count"] = sum(1 for fr in frs if fr <= 7_200_000)
acc[uid]["reply_24h_count"] = sum(1 for fr in frs if fr <= 86_400_000)
reply = (await session.execute( # ---- distinct 活跃客户:账号归并到员工后按 peer 去重 ----
sa.select(
FirstReplyFact.owner_account_id.label("owner"),
sa.func.sum(sa.case(
(FirstReplyFact.fr_ms <= 7_200_000, 1), else_=0
)).label("c2h"),
sa.func.sum(sa.case(
(FirstReplyFact.fr_ms <= 86_400_000, 1), else_=0
)).label("c24h"),
).where(*fr_conds).group_by(FirstReplyFact.owner_account_id)
)).all()
for owner, c2h, c24h in reply:
uid = amap.get(owner or "")
if uid is None or uid not in target:
continue
acc[uid]["reply_2h_count"] += int(c2h or 0)
acc[uid]["reply_24h_count"] += int(c24h or 0)
# ---- distinct 活跃客户:窗内 (owner, peer) 去重(friend 行) ----
af_conds = [ af_conds = [
MsgDailyStats.day.between(start_day, end_day), MsgDailyStats.day.between(start_day, end_day),
MsgDailyStats.peer_kind == "friend", MsgDailyStats.peer_kind == "friend",
@@ -274,12 +256,14 @@ async def period_metrics(
sa.select(MsgDailyStats.owner_account_id, MsgDailyStats.peer_id) sa.select(MsgDailyStats.owner_account_id, MsgDailyStats.peer_id)
.distinct().where(*af_conds) .distinct().where(*af_conds)
)).all() )).all()
active: dict[str, set[tuple[str, str]]] = {} active: dict[str, set[str]] = {}
for owner, peer in pairs: for owner, peer in pairs:
uid = amap.get(owner or "") uid = amap.get(owner or "")
if uid is None or uid not in target: if uid is None or uid not in target:
continue continue
active.setdefault(uid, set()).add((owner or "", peer or "")) # (uid, peer) 粒度:同一员工多账号(wx1+wx2)的共享客户只计 1
# ——与宽表日值口径一致(storage 侧账号归并后 add(peer))
active.setdefault(uid, set()).add(peer or "")
for uid, pset in active.items(): for uid, pset in active.items():
acc[uid]["active_friends"] = len(pset) acc[uid]["active_friends"] = len(pset)
@@ -6,7 +6,8 @@
- 宽表 SUM 按 (day∈窗, metric_version='v1', channel) 过滤; - 宽表 SUM 按 (day∈窗, metric_version='v1', channel) 过滤;
active_rooms_avg 摊到全窗天数(含无数据日); active_rooms_avg 摊到全窗天数(含无数据日);
- 期末生命周期存量=每 (user, channel) 最新 ≤end 行,跨渠道相加; - 期末生命周期存量=每 (user, channel) 最新 ≤end 行,跨渠道相加;
- distinct 活跃客户=窗内 (owner, peer) 去重(peer_kind='friend'); - distinct 活跃客户=窗内 (uid, peer) 去重(peer_kind='friend')——
同一员工多账号的共享客户只计 1(与宽表日值口径一致);
- 窗口工具:闭区间 day_range/等长前窗/28 天个人基线窗; - 窗口工具:闭区间 day_range/等长前窗/28 天个人基线窗;
- 幽灵员工(不在 staff)过滤;user_ids 交集;通话恒全渠道。 - 幽灵员工(不在 staff)过滤;user_ids 交集;通话恒全渠道。
""" """
@@ -159,6 +160,22 @@ async def test_distinct_active_cross_day(make_session):
assert m["u1"].active_friends == 2 assert m["u1"].active_friends == 2
async def test_active_friends_shared_peer_across_accounts(make_session):
"""多账号共享客户去重:u1 的 wx1/wx3 都会话 p1 → active_friends=1
(账号归并到员工后按 peer 去重,非 (owner, peer) 双计)。"""
s = make_session
await _seed_org(s)
s.add(WechatAccount(wx_id="wx3", user_id="u1"))
await s.commit()
for owner in ("wx1", "wx3"):
s.add(MsgDailyStats(channel="wechat", owner_account_id=owner,
peer_kind="friend", peer_id="p1", day=20260901))
await s.commit()
m = await period_metrics(s, 20260901, 20260905, "wechat")
assert m["u1"].active_friends == 1
assert m["u2"].active_friends == 0
def test_window_helpers(): def test_window_helpers():
# day_range 闭区间 + 月末边界 # day_range 闭区间 + 月末边界
assert day_range(20260901, 20260903) == [20260901, 20260902, 20260903] assert day_range(20260901, 20260903) == [20260901, 20260902, 20260903]
@@ -193,6 +210,7 @@ async def test_ghost_and_channel_none(make_session):
assert set(m) == {"u1", "u2"} # ghost 不在;零数据 u2 在 assert set(m) == {"u1", "u2"} # ghost 不在;零数据 u2 在
u1 = m["u1"] u1 = m["u1"]
assert u1.msg_sent == 7 # 两渠道行都归 u1 assert u1.msg_sent == 7 # 两渠道行都归 u1
assert u1.days_with_data == 1 # 双渠道同日只计 1 天(DISTINCT day)
assert u1.fr_p50_ms == 100_000 # [100s,300s] 合并后 ceil(0.5*2)=1 assert u1.fr_p50_ms == 100_000 # [100s,300s] 合并后 ceil(0.5*2)=1
assert u1.fr_p90_ms == 300_000 # ceil(0.9*2)=2 assert u1.fr_p90_ms == 300_000 # ceil(0.9*2)=2
assert u1.reply_2h_count == 2 assert u1.reply_2h_count == 2