31cd2d92aa
Co-Authored-By: Claude Code <noreply@anthropic.com>
448 lines
17 KiB
Python
448 lines
17 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,
|
|
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
|