Files
yunke/yunke-server/app/management/employee_metrics.py
T
浅念 3d5644c42a fix(management): drilldown生命周期历史改peer键控对齐批次A口径+comparisons键域统一+补测试
评审修正(I1/M1-M4/M6):
- I1 lifecycle_customers 历史改 (channel,peer) 键控跨owner合并,归属仍由
  Customer.owner_account_id 决定——与批次A _lifecycle_daily 同口径;新增
  共享客户测试(历史在他人账号下仍全量呈现)
- M3 employees 行 comparisons 键域统一为指标源名(config code→spec.metric
  映射,active_friends_band/call_band 不再出现),与 sections 一致
- M4 未回复分支空 owners 先短路,免去无效 finalize 调用
- M1 employee_metrics._percentile 改公有 percentile 供同包复用,删除
  api_assembly.percentile_nearest(存储层 _percentile 不同层保留)
- M2 drilldown 内联 SQL(FirstReplyFact/PhoneCall)下沉 api_assembly
  (fr_fact_rows/user_calls_page),路由层无裸查询
- M6 补 start>end→422 断言;新增 config_source=db 测试(激活配置
  min_days=9→五天数据员工翻 insufficient_sample,守护生效可见)

tests/test_management_api.py: 21 passed;全量 721 passed

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 18:59:45 +08:00

343 lines
14 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.
"""管理域期聚合查询(批次B)——评分/异常/employees API 的单一数据源。
窗口:days=[start_day..end_day] 闭区间 yyyymmdd;上期=等长紧邻前窗;
个人基线=紧邻当前窗之前的 28 天。所有查询走 (day, ...) 索引,不扫消息表
(除 distinct 活跃客户走 msg_daily_stats friend 行)。
口径要点:
- 分位/回复计数:first_reply_fact 窗内行拉回 Python 按员工合并排序取
最近邻秩 ceil(q·n)(整除算术 (n+1)//2 / (9n+9)//10——SQLite 无原生
ceil,MySQL `/` 返回浮点,Python 侧取秩零方言风险)。合并按员工而非
owner:一员工多账号(wx1+wx2→u1,channel=None 时人人如此)的样本必须
跨账号归并,逐 owner 取分位再映射会互相覆盖。fact 全库全期 ≤6.8万行,
Python 侧聚合可行。
- 期末存量(new/cooling/dormant/end_active_friends):状态快照取
「每 (user, channel) 最新 ≤end 行」——channel=None 时两渠道各取各的
最新行再相加(直接取全渠道最大 day 的那一行会丢另一渠道的存量)。
end_active_friends 与 new/cooling/dormant 同源同行(该渠道最新行的
active_friends 列),供评分 active_share 用——区别于 active_friends
字段的周期 distinct 口径。
- active_rooms_avg = SUM(active_rooms)/全窗天数(含无数据日摊薄),
非有数据天数——「日均负载」语义。
- days_with_data = COUNT(DISTINCT day):单渠道时与 COUNT(*) 等价
(PK 含 day+channel,每日至多一行),channel=None 双渠道同日也只计 1 天。
- 通话四列恒全渠道(employee_call_daily 无渠道维),channel 参数不过滤。
"""
from __future__ import annotations
import datetime as dt
import statistics
from collections import namedtuple
from collections.abc import Iterable
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import (
EmployeeCallDaily,
EmployeeMetricDaily,
FirstReplyFact,
MsgDailyStats,
Staff,
)
from app.storage.employee_metrics import METRIC_VERSION
from app.storage.staff_summary import account_map
PeriodMetrics = namedtuple("PeriodMetrics", [
"user_id", "days_with_data", "msg_sent", "msg_recv", "cust_threads",
"threads_answered", "fr_sum_ms", "fr_p50_ms", "fr_p90_ms",
"reply_2h_count", "reply_24h_count",
"active_friends", # 期内 distinct(直查,非日值求和)
"active_rooms_avg", # 日均(全窗天数摊薄)
"new_customer", "cooling_customer", "dormant_customer", # 期末单日存量
"end_active_friends", # 期末行 active_friends 跨渠道 SUM(active_share 分子)
"reactivated_customer", # 期内求和
"call_count", "call_seconds", "answered_call", "call_followup",
])
def _to_date(k: int) -> dt.date:
return dt.date(k // 10000, k // 100 % 100, k % 100)
def _to_key(d: dt.date) -> int:
return d.year * 10000 + d.month * 100 + d.day
def day_range(start: int, end: int) -> list[int]:
"""闭区间日键列表。"""
out, cur = [], _to_date(start)
while cur <= _to_date(end):
out.append(_to_key(cur))
cur += dt.timedelta(days=1)
return out
def prev_window(start: int, end: int) -> tuple[int, int]:
"""等长紧邻前窗。"""
n = len(day_range(start, end))
pe = _to_date(start) - dt.timedelta(days=1)
ps = pe - dt.timedelta(days=n - 1)
return _to_key(ps), _to_key(pe)
def baseline_window(start: int) -> tuple[int, int]:
"""个人基线:紧邻当前窗之前的 28 天。"""
be = _to_date(start) - dt.timedelta(days=1)
bs = be - dt.timedelta(days=27)
return _to_key(bs), _to_key(be)
def percentile(vals: list[int], q_num: int, q_den: int) -> int | None:
"""最近邻秩分位 ceil(q·n) 的样本值,q=q_num/q_den(1/2、9/10)。
公有供同包 api_assembly(B4 服务趋势)复用;整数算术
rank=(q_num*n + q_den - 1)//q_den 对正整数 n 恒落在 [1,n]
(与 math.ceil(q·n) 等价,与批次A storage 层 _percentile 同口径——
后者为不同层单独保留);空列表→None(与 0 严格区分)。
"""
if not vals:
return None
s = sorted(vals)
n = len(s)
rank = max(1, min(n, (q_num * n + q_den - 1) // q_den))
return s[rank - 1]
# 累加器初值:全员(staff)都出结果行,无数据员工为零值而非缺行——
# employees API 全员列表语义。
_ZERO: dict[str, object] = {
"days_with_data": 0, "msg_sent": 0, "msg_recv": 0, "cust_threads": 0,
"threads_answered": 0, "fr_sum_ms": 0,
"fr_p50_ms": None, "fr_p90_ms": None,
"reply_2h_count": 0, "reply_24h_count": 0,
"active_friends": 0, "active_rooms_sum": 0,
"new_customer": 0, "cooling_customer": 0, "dormant_customer": 0,
"end_active_friends": 0,
"reactivated_customer": 0,
"call_count": 0, "call_seconds": 0, "answered_call": 0, "call_followup": 0,
}
async def period_metrics(
session: AsyncSession,
start_day: int,
end_day: int,
channel: str | None,
user_ids: Iterable[str] | None = None,
) -> dict[str, PeriodMetrics]:
"""窗口 [start_day, end_day] × channel(None=全渠道)的员工期指标。
user_ids 传入时与 staff 表求交(幽灵过滤);返回含全部目标员工的
PeriodMetrics(无数据员工为零值行)。channel=None 时两渠道
account_map 合并(wx_id 与 WA uid 命名空间不撞,无冲突)。
"""
staff_ids = {
u for u in (await session.execute(sa.select(Staff.user_id))).scalars() if u
}
target = staff_ids if user_ids is None else staff_ids & set(user_ids)
if not target:
return {}
if channel is None:
amap: dict[str, str] = {}
for ch in ("wechat", "whatsapp"):
amap.update(await account_map(session, ch))
else:
amap = await account_map(session, channel)
n_days = len(day_range(start_day, end_day))
acc: dict[str, dict] = {uid: dict(_ZERO) for uid in target}
# ---- 宽表 SUM:消息计数/串类/日均负载/复活 ----
wide_conds = [
EmployeeMetricDaily.day.between(start_day, end_day),
EmployeeMetricDaily.metric_version == METRIC_VERSION,
EmployeeMetricDaily.user_id.in_(target),
]
if channel is not None:
wide_conds.append(EmployeeMetricDaily.channel == channel)
wide = (await session.execute(
sa.select(
EmployeeMetricDaily.user_id.label("uid"),
sa.func.count(sa.distinct(EmployeeMetricDaily.day)).label("nd"),
sa.func.sum(EmployeeMetricDaily.msg_sent).label("sent"),
sa.func.sum(EmployeeMetricDaily.msg_recv).label("recv"),
sa.func.sum(EmployeeMetricDaily.cust_threads).label("thr"),
sa.func.sum(EmployeeMetricDaily.threads_answered).label("ans"),
sa.func.sum(EmployeeMetricDaily.fr_sum_ms).label("frs"),
sa.func.sum(EmployeeMetricDaily.active_rooms).label("rooms"),
sa.func.sum(
EmployeeMetricDaily.reactivated_customer_count
).label("react"),
).where(*wide_conds).group_by(EmployeeMetricDaily.user_id)
)).all()
for uid, nd, sent, recv, thr, ans, frs, rooms, react in wide:
a = acc[uid]
a["days_with_data"] = int(nd or 0)
a["msg_sent"] = int(sent or 0)
a["msg_recv"] = int(recv or 0)
a["cust_threads"] = int(thr or 0)
a["threads_answered"] = int(ans or 0)
a["fr_sum_ms"] = int(frs or 0)
a["active_rooms_sum"] = int(rooms or 0)
a["reactivated_customer"] = int(react or 0)
# ---- 期末存量:每 (user, channel) 最新 ≤end 行,跨渠道相加 ----
stock_conds = [
EmployeeMetricDaily.day <= end_day,
EmployeeMetricDaily.metric_version == METRIC_VERSION,
EmployeeMetricDaily.user_id.in_(target),
]
if channel is not None:
stock_conds.append(EmployeeMetricDaily.channel == channel)
latest = (await session.execute(
sa.select(
EmployeeMetricDaily.user_id.label("uid"),
EmployeeMetricDaily.channel.label("ch"),
sa.func.max(EmployeeMetricDaily.day).label("d"),
).where(*stock_conds).group_by(
EmployeeMetricDaily.user_id, EmployeeMetricDaily.channel
)
)).all()
if latest:
# (user, channel, day) 含 metric_version 唯一(主键)——OR 点查
# 精确取回各渠道最新行,再按 user 跨渠道 SUM
stocks = (await session.execute(
sa.select(
EmployeeMetricDaily.user_id.label("uid"),
sa.func.sum(EmployeeMetricDaily.new_customer_count).label("nw"),
sa.func.sum(
EmployeeMetricDaily.cooling_customer_count
).label("cool"),
sa.func.sum(
EmployeeMetricDaily.dormant_customer_count
).label("dorm"),
sa.func.sum(EmployeeMetricDaily.active_friends).label("af"),
).where(sa.or_(*(
sa.and_(
EmployeeMetricDaily.user_id == uid,
EmployeeMetricDaily.channel == ch,
EmployeeMetricDaily.day == d,
EmployeeMetricDaily.metric_version == METRIC_VERSION,
)
for uid, ch, d in latest
))).group_by(EmployeeMetricDaily.user_id)
)).all()
for uid, nw, cool, dorm, af in stocks:
a = acc[uid]
a["new_customer"] = int(nw or 0)
a["cooling_customer"] = int(cool or 0)
a["dormant_customer"] = int(dorm or 0)
a["end_active_friends"] = int(af or 0)
# ---- fact 分位 + 2h/24h 回复计数(同一过滤,Python 侧一次聚合) ----
fr_conds = [FirstReplyFact.day.between(start_day, end_day)]
if channel is not None:
fr_conds.append(FirstReplyFact.channel == channel)
fr_rows = (await session.execute(
sa.select(
FirstReplyFact.owner_account_id.label("owner"),
FirstReplyFact.fr_ms.label("fr"),
).where(*fr_conds)
)).all()
fr_by_user: dict[str, list[int]] = {}
for owner, fr in fr_rows:
uid = amap.get(owner or "")
if uid is not None and uid in target:
fr_by_user.setdefault(uid, []).append(int(fr))
for uid, frs in fr_by_user.items():
acc[uid]["fr_p50_ms"] = percentile(frs, 1, 2)
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)
# ---- distinct 活跃客户:账号归并到员工后按 peer 去重 ----
af_conds = [
MsgDailyStats.day.between(start_day, end_day),
MsgDailyStats.peer_kind == "friend",
]
if channel is not None:
af_conds.append(MsgDailyStats.channel == channel)
pairs = (await session.execute(
sa.select(MsgDailyStats.owner_account_id, MsgDailyStats.peer_id)
.distinct().where(*af_conds)
)).all()
active: dict[str, set[str]] = {}
for owner, peer in pairs:
uid = amap.get(owner or "")
if uid is None or uid not in target:
continue
# (uid, peer) 粒度:同一员工多账号(wx1+wx2)的共享客户只计 1
# ——与宽表日值口径一致(storage 侧账号归并后 add(peer))
active.setdefault(uid, set()).add(peer or "")
for uid, pset in active.items():
acc[uid]["active_friends"] = len(pset)
# ---- 通话:恒全渠道(表无渠道维,channel 参数不过滤) ----
calls = (await session.execute(
sa.select(
EmployeeCallDaily.user_id.label("uid"),
sa.func.sum(EmployeeCallDaily.call_count).label("cc"),
sa.func.sum(EmployeeCallDaily.call_seconds).label("cs"),
sa.func.sum(EmployeeCallDaily.answered_call_count).label("ca"),
sa.func.sum(EmployeeCallDaily.call_followup_count).label("cf"),
).where(
EmployeeCallDaily.day.between(start_day, end_day),
EmployeeCallDaily.user_id.in_(target),
).group_by(EmployeeCallDaily.user_id)
)).all()
for uid, cc, cs, ca, cf in calls:
a = acc[uid]
a["call_count"] = int(cc or 0)
a["call_seconds"] = int(cs or 0)
a["answered_call"] = int(ca or 0)
a["call_followup"] = int(cf or 0)
return {
uid: PeriodMetrics(
user_id=uid,
days_with_data=a["days_with_data"],
msg_sent=a["msg_sent"],
msg_recv=a["msg_recv"],
cust_threads=a["cust_threads"],
threads_answered=a["threads_answered"],
fr_sum_ms=a["fr_sum_ms"],
fr_p50_ms=a["fr_p50_ms"],
fr_p90_ms=a["fr_p90_ms"],
reply_2h_count=a["reply_2h_count"],
reply_24h_count=a["reply_24h_count"],
active_friends=a["active_friends"],
active_rooms_avg=(a["active_rooms_sum"] / n_days) if n_days else 0.0,
new_customer=a["new_customer"],
cooling_customer=a["cooling_customer"],
dormant_customer=a["dormant_customer"],
end_active_friends=a["end_active_friends"],
reactivated_customer=a["reactivated_customer"],
call_count=a["call_count"],
call_seconds=a["call_seconds"],
answered_call=a["answered_call"],
call_followup=a["call_followup"],
)
for uid, a in acc.items()
}
def team_medians(metrics: dict[str, PeriodMetrics]) -> dict[str, float | None]:
"""每数值字段的全员中位数(纯函数,评分归一用)。
None(无样本的分位等)剔除后取中位——statistics.median:奇数取中位
样本、偶数取中间两数均值;全员 None → None(调用方按无基线处理)。
"""
out: dict[str, float | None] = {}
for field in PeriodMetrics._fields:
if field == "user_id":
continue
vals = [
v for v in (getattr(m, field) for m in metrics.values())
if v is not None
]
out[field] = statistics.median(vals) if vals else None
return out