"""管理 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