feat(management): 只读管理API六端点——dashboard/employees/detail/sections/drilldown白名单/指标定义
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,447 @@
|
||||
"""管理 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,
|
||||
Staff,
|
||||
)
|
||||
from app.management.anomalies import detect_anomalies
|
||||
from app.management.employee_metrics import (
|
||||
PeriodMetrics,
|
||||
baseline_window,
|
||||
day_range,
|
||||
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
|
||||
|
||||
# 异常权重(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 percentile_nearest(
|
||||
vals: list[int], q_num: int, q_den: int
|
||||
) -> int | None:
|
||||
"""最近邻秩分位 ceil(q·n)(与批次A/employee_metrics 同口径,自写 5 行
|
||||
避免跨层引私有 _percentile);空列表→None(与 0 严格区分)。"""
|
||||
if not vals:
|
||||
return None
|
||||
s = sorted(vals)
|
||||
rank = max(1, min(len(s), (q_num * len(s) + q_den - 1) // q_den))
|
||||
return s[rank - 1]
|
||||
|
||||
|
||||
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 build_employee_row(uid: str, data: dict) -> dict:
|
||||
"""employees 列表行:metrics 全字段 + comparisons 四值(自评分
|
||||
metrics_detail 透传)+ score 摘要 + 异常计数/权重。"""
|
||||
score = data["score"]
|
||||
return {
|
||||
"user_id": uid,
|
||||
"name": data["name"],
|
||||
"is_on_job": data["is_on_job"],
|
||||
"metrics": data["cur"]._asdict(),
|
||||
"comparisons": {
|
||||
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_nearest(frs, 1, 2),
|
||||
"fr_p90_ms": percentile_nearest(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 排序。
|
||||
|
||||
与批次A _lifecycle_daily 同式: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.owner_account_id,
|
||||
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, str], dict[int, int]] = {}
|
||||
for ch, owner, peer, day, snd, rcv in rows:
|
||||
dm = hist.setdefault((ch, owner, 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.owner_account_id or "", 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
|
||||
Reference in New Issue
Block a user