Files
yunke/yunke-server/app/management/api_assembly.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

532 lines
20 KiB
Python

"""管理 API 组装层(批次B Task B4)——router 六端点共用的重查询与行构造。
拆出 router 的原因:employees/dashboard 的组装(多窗聚合 + 评分 + 异常 +
对比派生)上百行,放 router 会淹没端点定义;本模块只做数据组装,参数
校验/HTTP 语义留在 router。
查询纪律(与 analytics.metrics 同):**禁止 asyncio.gather**——单
AsyncSession 不能并发执行查询,全部顺序 await。重端点不做 N+1:
period_metrics 每窗一次调用全员;员工循环(评分/异常)是纯函数。
"""
from __future__ import annotations
import copy
import statistics
from bisect import bisect_right
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.analytics.lifecycle import classify
from app.db.models import (
Customer,
EmployeeCallDaily,
EmployeeMetricDaily,
EmployeeScoreConfig,
FirstReplyFact,
MsgDailyStats,
PhoneCall,
Staff,
)
from app.management.anomalies import detect_anomalies
from app.management.employee_metrics import (
PeriodMetrics,
baseline_window,
day_range,
percentile,
period_metrics,
prev_window,
team_medians,
)
from app.management.metric_definitions import DEFAULT_SCORE_CONFIG
from app.management.scoring import derive_metrics, score_employee
from app.storage.employee_metrics import METRIC_VERSION
from app.storage.staff_summary import account_map
from app.storage.summary import day_bounds_ms
# 异常权重(anomaly_severity 排序/汇总):critical=2 / warning=1
SEVERITY_WEIGHT = {"critical": 2, "warning": 1}
# 派生指标中需对「员工派生值」另取中位的键(scoring.team 键=派生指标名;
# 其余如 fr_p90_ms/active_friends/call_count 直接用 team_medians 输出)
_DERIVED_MEDIAN_KEYS = ("reply_2h_rate", "reply_24h_rate", "active_share")
async def load_score_config(session: AsyncSession) -> tuple[dict, str]:
"""激活评分配置(DB)→ (config_json, "db");无 active 行 →
(DEFAULT_SCORE_CONFIG 深拷贝, "default")——未跑种子的环境也能出分。"""
row = (await session.execute(
sa.select(EmployeeScoreConfig)
.where(EmployeeScoreConfig.status == "active")
.order_by(EmployeeScoreConfig.id.desc())
.limit(1)
)).scalar_one_or_none()
if row is not None:
return row.config_json, "db"
return copy.deepcopy(DEFAULT_SCORE_CONFIG), "default"
def team_derived_medians(metrics: dict[str, PeriodMetrics]) -> dict[str, float | None]:
"""评分用团队中位:PeriodMetrics 字段中位 + 派生率/占比的员工级中位
(scoring 要求 team 键=派生指标名,见其模块 docstring)。"""
team = dict(team_medians(metrics))
for key in _DERIVED_MEDIAN_KEYS:
vals = [
v for v in (derive_metrics(pm).get(key) for pm in metrics.values())
if v is not None
]
team[key] = statistics.median(vals) if vals else None
return team
def score_with_guards(
config: dict,
cur: PeriodMetrics,
baseline: PeriodMetrics | None = None,
team: dict[str, float] | None = None,
prev: PeriodMetrics | None = None,
) -> dict:
"""score_employee + min_days 接线(B2 评审遗留):当前窗天数 <
config.sample_guards.min_days → score_status 覆盖 insufficient_sample,
total/明细保留展示(按天稀疏的数据不参与考核但可查看)。"""
score = score_employee(config, cur, baseline=baseline, team=team, prev=prev)
min_days = config.get("sample_guards", {}).get("min_days", 5)
if cur.days_with_data < min_days:
score["score_status"] = "insufficient_sample"
return score
async def assemble_employees(
session: AsyncSession,
config: dict,
start_day: int,
end_day: int,
channel: str | None,
*,
with_prev: bool = True,
) -> dict[str, dict]:
"""全员组装:staff → 当前/基线(/前)窗 period_metrics → 逐员工
score_with_guards + detect_anomalies(纯函数循环)。
dashboard 传 with_prev=False(只需评分+异常,省一次前窗聚合);
employees/detail 需要 prev/团队中位时传 True。返回
{uid: {name, is_on_job, cur, prev, baseline, score, anomalies,
anomaly_count, anomaly_weight}}——过滤/排序/分页由调用方做。
"""
staff_rows = (await session.execute(
sa.select(Staff.user_id, Staff.name, Staff.is_on_job)
)).all()
staff = {uid: (name, onjob) for uid, name, onjob in staff_rows if uid}
if not staff:
return {}
n_days = len(day_range(start_day, end_day))
cur = await period_metrics(session, start_day, end_day, channel)
bs, be = baseline_window(start_day)
base = await period_metrics(session, bs, be, channel)
prev: dict[str, PeriodMetrics] | None = None
team: dict[str, float | None] | None = None
if with_prev:
ps, pe = prev_window(start_day, end_day)
prev = await period_metrics(session, ps, pe, channel)
team = team_derived_medians(cur)
out: dict[str, dict] = {}
for uid, (name, onjob) in staff.items():
c = cur.get(uid)
if c is None: # 防御:period_metrics 恒含全员,此处不该走到
continue
b = base.get(uid)
p = prev.get(uid) if prev is not None else None
score = score_with_guards(config, c, baseline=b, team=team, prev=p)
anomalies = detect_anomalies(uid, c, b, n_days=n_days, config=config)
out[uid] = {
"name": name,
"is_on_job": onjob,
"cur": c,
"prev": p,
"baseline": b,
"score": score,
"anomalies": anomalies,
"anomaly_count": len(anomalies),
"anomaly_weight": sum(
SEVERITY_WEIGHT.get(a["severity"], 0) for a in anomalies
),
}
return out
def _code_to_source(config: dict) -> dict[str, str]:
"""配置指标 code → 源指标名(spec.metric,无则 code 本身)。
comparisons 键域统一为源指标名(与 derive_metrics / sections 端点一致,
冻结批次C契约):active_friends_band→active_friends、call_band→call_count,
其余 code 本身即源名。"""
out: dict[str, str] = {}
for dim in config.get("dimensions", {}).values():
for code, spec in dim.get("metrics", {}).items():
out[code] = spec.get("metric", code)
return out
def build_employee_row(uid: str, data: dict, config: dict) -> dict:
"""employees 列表行:metrics 全字段 + comparisons 四值(自评分
metrics_detail 透传,键映射为源指标名)+ score 摘要 + 异常计数/权重。"""
score = data["score"]
code_src = _code_to_source(config)
return {
"user_id": uid,
"name": data["name"],
"is_on_job": data["is_on_job"],
"metrics": data["cur"]._asdict(),
"comparisons": {
code_src.get(code, code): {
"current": d["value"],
"prev": d["prev"],
"baseline": d["baseline"],
"median": d["median"],
}
for code, d in score["metrics_detail"].items()
},
"score": {
"total_score": score["total_score"],
"grade": score["grade"],
"score_status": score["score_status"],
"trial": score["trial"],
"dimensions": {
key: {"score": dim["score"], "status": dim["status"]}
for key, dim in score["dimensions"].items()
},
},
"anomaly_count": data["anomaly_count"],
"anomaly_weight": data["anomaly_weight"],
}
def derived_comparisons(
cur: PeriodMetrics,
prev: PeriodMetrics | None,
baseline: PeriodMetrics | None,
team: dict[str, float | None] | None,
) -> dict[str, dict]:
"""四对比(current/prev/baseline/median),派生口径=scoring.derive_metrics
——评分、异常、展示三方同一派生式。"""
cv = derive_metrics(cur)
pv = derive_metrics(prev) if prev is not None else {}
bv = derive_metrics(baseline) if baseline is not None else {}
return {
key: {
"current": cv.get(key),
"prev": pv.get(key),
"baseline": bv.get(key),
"median": team.get(key) if team else None,
}
for key in cv
}
async def wide_daily_rows(
session: AsyncSession,
start_day: int,
end_day: int,
channel: str | None,
user_id: str | None = None,
) -> list[dict]:
"""宽表逐日聚合(加性列跨渠道求和;user_id=None 为全员合计)。
first_reply_p50/p90_ms 是 (day,channel,user) 行内单值不可求和:
单员工视图(channel=None 双渠道同日)取当日 cust_threads 较大渠道的
值(当日主力渠道,并列取 wechat);全员视图不取用这两列(分位由
service_daily_trend 从 fact 精确算)。
"""
conds = [
EmployeeMetricDaily.day.between(start_day, end_day),
EmployeeMetricDaily.metric_version == METRIC_VERSION,
]
if channel is not None:
conds.append(EmployeeMetricDaily.channel == channel)
if user_id is not None:
conds.append(EmployeeMetricDaily.user_id == user_id)
rows = (await session.execute(
sa.select(
EmployeeMetricDaily.day.label("day"),
EmployeeMetricDaily.channel.label("ch"),
sa.func.sum(EmployeeMetricDaily.msg_sent).label("sent"),
sa.func.sum(EmployeeMetricDaily.msg_recv).label("recv"),
sa.func.sum(EmployeeMetricDaily.active_friends).label("af"),
sa.func.sum(EmployeeMetricDaily.active_rooms).label("ar"),
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.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.reactivated_customer_count).label("react"),
sa.func.max(EmployeeMetricDaily.first_reply_p50_ms).label("p50"),
sa.func.max(EmployeeMetricDaily.first_reply_p90_ms).label("p90"),
).where(*conds).group_by(EmployeeMetricDaily.day, EmployeeMetricDaily.channel)
)).all()
per_day: dict[int, dict] = {}
def _blank(day: int) -> dict:
return {
"day": day, "msg_sent": 0, "msg_recv": 0, "active_friends": 0,
"active_rooms": 0, "cust_threads": 0, "threads_answered": 0,
"fr_sum_ms": 0, "new_customer_count": 0,
"cooling_customer_count": 0, "dormant_customer_count": 0,
"reactivated_customer_count": 0,
"first_reply_p50_ms": None, "first_reply_p90_ms": None,
"_pick": (-1, -1), # 主力渠道追踪 (cust_threads, wechat 加分)
}
for (day, ch, sent, recv, af, ar, thr, ans, frs, nw, cool, dorm,
react, p50, p90) in rows:
d = per_day.setdefault(day, _blank(day))
d["msg_sent"] += int(sent or 0)
d["msg_recv"] += int(recv or 0)
d["active_friends"] += int(af or 0)
d["active_rooms"] += int(ar or 0)
d["cust_threads"] += int(thr or 0)
d["threads_answered"] += int(ans or 0)
d["fr_sum_ms"] += int(frs or 0)
d["new_customer_count"] += int(nw or 0)
d["cooling_customer_count"] += int(cool or 0)
d["dormant_customer_count"] += int(dorm or 0)
d["reactivated_customer_count"] += int(react or 0)
if user_id is not None:
pick = (int(thr or 0), 1 if ch == "wechat" else 0)
if pick > d["_pick"]:
d["_pick"] = pick
d["first_reply_p50_ms"] = p50
d["first_reply_p90_ms"] = p90
out = []
for day in sorted(per_day):
d = per_day[day]
d.pop("_pick", None)
out.append(d)
return out
async def call_daily_rows(
session: AsyncSession,
start_day: int,
end_day: int,
user_id: str | None = None,
) -> list[dict]:
"""通话小表逐日聚合(恒全渠道——表无渠道维);user_id=None 为全员。"""
conds = [EmployeeCallDaily.day.between(start_day, end_day)]
if user_id is not None:
conds.append(EmployeeCallDaily.user_id == user_id)
rows = (await session.execute(
sa.select(
EmployeeCallDaily.day,
sa.func.sum(EmployeeCallDaily.call_count),
sa.func.sum(EmployeeCallDaily.call_seconds),
sa.func.sum(EmployeeCallDaily.answered_call_count),
sa.func.sum(EmployeeCallDaily.call_followup_count),
).where(*conds).group_by(EmployeeCallDaily.day)
)).all()
return [
{
"day": day, "call_count": int(cc or 0),
"call_seconds": int(cs or 0),
"answered_call_count": int(ca or 0),
"call_followup_count": int(cf or 0),
}
for day, cc, cs, ca, cf in rows
]
async def service_daily_trend(
session: AsyncSession,
start_day: int,
end_day: int,
channel: str | None,
threads_by_day: dict[int, int],
) -> list[dict]:
"""服务趋势(dashboard 全团队):fact 逐日 (day, fr_ms) 行拉回 Python
最近邻秩分位(跨员工合并样本——团队级分位);reply_2h_rate = fact
当日 ≤2h 计数 / 宽表当日 cust_threads 合计(分母 0 → None)。
输出全窗口逐日(无数据日 p50/p90 为 None),threads_by_day 来自
wide_daily_rows 的全员 cust_threads 列。
"""
conds = [FirstReplyFact.day.between(start_day, end_day)]
if channel is not None:
conds.append(FirstReplyFact.channel == channel)
rows = (await session.execute(
sa.select(FirstReplyFact.day, FirstReplyFact.fr_ms).where(*conds)
)).all()
by_day: dict[int, list[int]] = {}
for day, fr in rows:
by_day.setdefault(day, []).append(int(fr))
out = []
for day in day_range(start_day, end_day):
frs = by_day.get(day, [])
cnt_2h = sum(1 for f in frs if f <= 7_200_000)
denom = threads_by_day.get(day, 0)
out.append({
"day": day,
"fr_p50_ms": percentile(frs, 1, 2),
"fr_p90_ms": percentile(frs, 9, 10),
"reply_2h_rate": (cnt_2h / denom) if denom else None,
})
return out
async def user_owner_accounts(
session: AsyncSession, user_id: str, channel: str | None
) -> list[str]:
"""员工在所选渠道下的 owner 账号(channel=None → 两渠道合并)。"""
chs = ("wechat", "whatsapp") if channel is None else (channel,)
out: list[str] = []
for ch in chs:
amap = await account_map(session, ch)
out.extend(owner for owner, uid in amap.items() if uid == user_id)
return out
async def lifecycle_customers(
session: AsyncSession,
owner_accounts: list[str],
as_of_day: int,
want_state: str,
) -> list[dict]:
"""drilldown cooling/dormant:该员工账号归属客户(delete_flag 过滤)+
msg_daily_stats friend 行全历史 → lifecycle.classify(as_of=窗口末日),
返回状态匹配行 {friend_id, nickname, last_day, state},按 last_day asc
(最陈旧在前)、friend_id asc 排序。
历史按 (channel, peer) 键控、跨 owner 合并——与批次A _lifecycle_daily
的 peer 键控口径一致(归属由 Customer.owner_account_id 决定,互动史
不随归属走:共享/换归属客户的全部历史都算,否则 KPI 计了而名单丢人)。
hist 为 ≤as_of 的历史消息数(时点语义,as_of 之后的消息不虚增沉睡门槛)。"""
custs = (await session.execute(
sa.select(Customer).where(
sa.or_(Customer.delete_flag.is_(None), Customer.delete_flag == 0),
Customer.owner_account_id.in_(owner_accounts),
)
)).scalars().all()
if not custs:
return []
ids = [c.friend_id for c in custs]
rows = (await session.execute(
sa.select(
MsgDailyStats.channel,
MsgDailyStats.peer_id,
MsgDailyStats.day,
MsgDailyStats.send_cnt,
MsgDailyStats.recv_cnt,
).where(
MsgDailyStats.peer_kind == "friend",
MsgDailyStats.peer_id.in_(ids),
)
)).all()
hist: dict[tuple[str, str], dict[int, int]] = {}
for ch, peer, day, snd, rcv in rows:
dm = hist.setdefault((ch, peer), {})
dm[day] = dm.get(day, 0) + int(snd or 0) + int(rcv or 0)
matches: list[dict] = []
for c in custs:
dm = hist.get((c.channel, c.friend_id))
if not dm:
continue
days_list = sorted(dm)
idx = bisect_right(days_list, as_of_day) - 1
if idx < 0:
continue # 窗口末日之前从未互动
last = days_list[idx]
prev_d = days_list[idx - 1] if idx > 0 else None
hist_msgs = sum(dm[d] for d in days_list[: idx + 1])
state = classify(days_list[0], last, prev_d, as_of_day, hist_msgs)
if state == want_state:
matches.append({
"friend_id": c.friend_id,
"nickname": c.friend_nickname or c.friend_remark or c.friend_id,
"last_day": last,
"state": state,
})
matches.sort(key=lambda m: (m["last_day"], m["friend_id"]))
return matches
async def fr_fact_rows(
session: AsyncSession,
owner_accounts: list[str],
channel: str | None,
start_day: int,
end_day: int,
*,
page: int,
page_size: int,
) -> tuple[int, list[dict]]:
"""drilldown 首响分位:该员工账号窗内 fact 逐串证据行,fr_ms desc
(同值按日/串末时刻 desc 决胜)分页。chat_hint 给出会话定位参数
(owner+talker 直达聊天记录)。返回 (total, items)。"""
conds = [
FirstReplyFact.day.between(start_day, end_day),
FirstReplyFact.owner_account_id.in_(owner_accounts),
]
if channel is not None:
conds.append(FirstReplyFact.channel == channel)
total = (await session.execute(
sa.select(sa.func.count()).select_from(FirstReplyFact).where(*conds)
)).scalar_one()
rows = (await session.execute(
sa.select(FirstReplyFact).where(*conds)
.order_by(FirstReplyFact.fr_ms.desc(), FirstReplyFact.day.desc(),
FirstReplyFact.thread_end_ts.desc())
.offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
items = [{
"day": f.day,
"owner_account_id": f.owner_account_id,
"peer_id": f.peer_id,
"thread_end_ts": f.thread_end_ts,
"reply_ts": f.reply_ts,
"fr_ms": f.fr_ms,
"chat_hint": {"owner_account_id": f.owner_account_id,
"talker_id": f.peer_id},
} for f in rows]
return int(total), items
async def user_calls_page(
session: AsyncSession,
user_id: str,
start_day: int,
end_day: int,
*,
page: int,
page_size: int,
) -> tuple[int, list[dict]]:
"""drilldown 通话跟进:该员工窗内通话行(start_call_time desc,
call_id desc 决胜)分页。返回 (total, items)。"""
lo, _ = day_bounds_ms(start_day)
_, hi = day_bounds_ms(end_day) # 末日次日 00:00(左闭右开上界)
conds = [
PhoneCall.user_id == user_id,
PhoneCall.start_call_time.is_not(None),
PhoneCall.start_call_time >= lo,
PhoneCall.start_call_time < hi,
]
total = (await session.execute(
sa.select(sa.func.count()).select_from(PhoneCall).where(*conds)
)).scalar_one()
rows = (await session.execute(
sa.select(PhoneCall).where(*conds)
.order_by(PhoneCall.start_call_time.desc(), PhoneCall.call_id.desc())
.offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
items = [{
"call_id": c.call_id,
"channel": c.channel,
"call_number": c.call_number,
"start_call_time": c.start_call_time,
"call_duration": c.call_duration,
} for c in rows]
return int(total), items