Files
yunke/yunke-server/app/api/routers/management.py
T

589 lines
24 KiB
Python

"""GET /api/management/* —— 经营管理后台只读端组(批次B Task B4)。
六个端点:dashboard(总览)/ employees(列表,一期四视图共用行集)/
employees/{user_id}(详情)/ employees/{user_id}/metrics(分段指标+趋势)/
employees/{user_id}/drilldown(白名单下钻)/ metric-definitions(口径字典)。
惯例同 analytics.py:channel=all 在 router 层翻译为 None 传服务层;
start/end yyyymmdd 缺一/日历非法/start>end/跨度>366 天皆 422(_window);
重查询与行构造在 app.management.api_assembly,本文件只做薄编排。
所有查询共用单 AsyncSession——**顺序 await,不并发**(见 assembly 模块)。
"""
from __future__ import annotations
import datetime as dt
from typing import Annotated, Any, Literal
import sqlalchemy as sa
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.analytics import health as health_svc
from app.analytics import metrics as metrics_svc
from app.api.deps import get_session
from app.db.models import (
FirstReplyFact,
PhoneCall,
Staff,
WechatAccount,
WhatsappAccount,
)
from app.management import api_assembly as asm
from app.management.anomalies import detect_anomalies
from app.management.employee_metrics import (
baseline_window,
day_range,
period_metrics,
prev_window,
)
from app.management.metric_definitions import METRIC_DEFINITIONS
from app.storage.employee_metrics import METRIC_VERSION
from app.storage.staff_summary import account_map
from app.storage.summary import day_bounds_ms
router = APIRouter(prefix="/api/management", tags=["management"])
SessionDep = Annotated[AsyncSession, Depends(get_session)]
AllChannelParam = Annotated[Literal["all", "wechat", "whatsapp"], Query()]
YmdParam = Annotated[int, Query(ge=20000101, le=29991231)]
CompareParam = Annotated[
Literal["previous", "personal_baseline", "team_median"], Query()
]
ViewParam = Annotated[
Literal["management", "workload", "service", "customer"], Query()
]
StatusParam = Annotated[Literal["all", "on_job", "offline"], Query()]
SortByParam = Annotated[
Literal["anomaly_severity", "total_score", "active_friends", "cust_threads"],
Query(),
]
SortOrderParam = Annotated[Literal["asc", "desc"], Query()]
SectionParam = Annotated[
Literal["overview", "service", "customer", "workload", "calls"], Query()
]
# drilldown 白名单(外 422 "unknown metric_code")
_DRILLDOWN_CODES = frozenset({
"first_reply_p50", "first_reply_p90", "unreplied",
"cooling_customers", "dormant_customers", "call_followup",
})
# unreplied 适配:上游无私有 owner 过滤,取全量决赛圈结果再按 owner 过滤。
# 500 = analytics.metrics._FINALIZE_ALL_MAX 同值(候选 ≤500 时单次调用即
# 拿到全部已过滤 items;超过则上游本就按候选分页,响应标 truncated)。
_UNREPLIED_FETCH = 500
# 三方口径差异说明(硬编码,与 METRIC_DEFINITIONS.caliber 互补的总述)
CALIBER_NOTES = (
"管理域指标为 1:1 私聊口径且跨日精确(消息串按会话全历史计算、"
"归属串末条所在自然日);现有看板为私聊+群聊混合、日切口径"
"(跨夜串重复计/丢失,回复率分子分母同源近似)。"
"通话指标(通话数/时长/接通/跟进)无消息渠道维度——"
"voice_call 与 wa_voice_call 合并计数,不随 channel 参数过滤。"
)
# 各 section 的期指标字段(自 PeriodMetrics 挑)与宽表/通话表趋势列
_SECTION_FIELDS: dict[str, tuple[str, ...]] = {
"overview": ("days_with_data", "msg_sent", "msg_recv", "active_friends",
"active_rooms_avg"),
"service": ("cust_threads", "threads_answered", "fr_sum_ms",
"fr_p50_ms", "fr_p90_ms", "reply_2h_count", "reply_24h_count"),
"customer": ("new_customer", "cooling_customer", "dormant_customer",
"reactivated_customer", "end_active_friends"),
"workload": ("active_friends", "active_rooms_avg", "cust_threads"),
"calls": ("call_count", "call_seconds", "answered_call", "call_followup"),
}
_SECTION_TREND_COLS: dict[str, tuple[str, ...]] = {
"overview": ("msg_sent", "msg_recv", "active_friends"),
"service": ("cust_threads", "threads_answered", "fr_sum_ms",
"first_reply_p50_ms", "first_reply_p90_ms"),
"customer": ("new_customer_count", "cooling_customer_count",
"dormant_customer_count", "reactivated_customer_count"),
"workload": ("active_friends", "active_rooms"),
"calls": ("call_count", "call_seconds", "answered_call_count",
"call_followup_count"),
}
def _window(start: int | None, end: int | None) -> tuple[int, int]:
"""管理端点公共窗口校验(照 analytics custom scope 惯例)。"""
if start is None or end is None:
raise HTTPException(422, "start and end are required")
try:
sd = dt.date(start // 10000, (start // 100) % 100, start % 100)
ed = dt.date(end // 10000, (end // 100) % 100, end % 100)
except ValueError as exc: # 如 20260230——Query 只拦范围不拦日历
raise HTTPException(422, "invalid start/end calendar date") from exc
if sd > ed:
raise HTTPException(422, "start must be <= end")
if (ed - sd).days > 365: # 含端点共 366 天
raise HTTPException(422, "span must be <= 366 days")
return start, end
def _translate(channel: str) -> str | None:
return None if channel == "all" else channel
async def _get_staff(session: AsyncSession, user_id: str) -> Staff:
staff = (await session.execute(
sa.select(Staff).where(Staff.user_id == user_id)
)).scalar_one_or_none()
if staff is None:
raise HTTPException(404, f"employee {user_id} not found")
return staff
@router.get("/dashboard")
async def management_dashboard(
session: SessionDep,
channel: AllChannelParam = "all",
start: YmdParam | None = None,
end: YmdParam | None = None,
compare: CompareParam = "previous",
) -> dict[str, Any]:
"""经营管理总览:KPI + 双趋势 + 生命周期 + 维度分布 + 异常汇总 +
行动入口计数,单往返。compare 仅回显(对比数值在 employees 行内)。"""
start, end = _window(start, end)
ch = _translate(channel)
config, config_source = await asm.load_score_config(session)
assembled = await asm.assemble_employees(
session, config, start, end, ch, with_prev=False
)
pms = [a["cur"] for a in assembled.values()]
# ---- 趋势两查:宽表全员逐日 + 通话小表逐日 + fact 逐日 ----
wide = await asm.wide_daily_rows(session, start, end, ch)
wide_by_day = {d["day"]: d for d in wide}
threads_by_day = {d["day"]: d["cust_threads"] for d in wide}
calls = await asm.call_daily_rows(session, start, end)
call_by_day = {d["day"]: d["call_count"] for d in calls}
comm_trend = []
for day in day_range(start, end):
d = wide_by_day.get(day)
comm_trend.append({
"day": day,
"msg_total": (d["msg_sent"] + d["msg_recv"]) if d else 0,
# 团队合计口径:当日各员工宽表 active_friends 求和
"active_customers": d["active_friends"] if d else 0,
"call_cnt": call_by_day.get(day, 0),
})
service_trend = await asm.service_daily_trend(
session, start, end, ch, threads_by_day
)
lifecycle = {
"new": sum(p.new_customer for p in pms),
"cooling": sum(p.cooling_customer for p in pms),
"dormant": sum(p.dormant_customer for p in pms),
"reactivated": sum(p.reactivated_customer for p in pms),
}
kpis = {
# 全员 active_friends 求和:跨员工可能重复计客户(共享客户)
"active_customers": sum(p.active_friends for p in pms),
"active_employees": sum(1 for p in pms if p.days_with_data > 0),
"msg_total": sum(p.msg_sent + p.msg_recv for p in pms),
"cust_threads": sum(p.cust_threads for p in pms),
"threads_answered": sum(p.threads_answered for p in pms),
"call_count": sum(p.call_count for p in pms),
"call_seconds": sum(p.call_seconds for p in pms),
"answered_call": sum(p.answered_call for p in pms),
"lifecycle": lifecycle,
}
# ---- 维度分布(全员 score_employee 的维度状态计数) ----
dim_summary: dict[str, dict] = {}
for a in assembled.values():
for key, dim in a["score"]["dimensions"].items():
e = dim_summary.setdefault(key, {
"name": dim.get("name"), "weight": dim.get("weight", 0),
"scored_count": 0, "insufficient_count": 0,
"pending_count": 0, "_scores": [],
})
if dim["status"] == "scored":
e["scored_count"] += 1
e["_scores"].append(dim["score"])
elif dim["status"] == "insufficient":
e["insufficient_count"] += 1
else: # pending / pending_data
e["pending_count"] += 1
for e in dim_summary.values():
scores = e.pop("_scores")
e["avg_score"] = (
sum(scores) / len(scores) if scores else None
)
# ---- 异常汇总:critical 先、warning 后,同 severity 按 affected desc ----
anomalies = [a for data in assembled.values() for a in data["anomalies"]]
sev_rank = {"critical": 0, "warning": 1}
anomalies.sort(key=lambda a: (
sev_rank.get(a["severity"], 9),
-a.get("affected_count", 0),
a["employee_user_id"],
))
return {
"scope": {"channel": channel, "start": start, "end": end,
"compare": compare},
"data_as_of": await health_svc.data_as_of(session),
"metric_version": METRIC_VERSION,
"config_source": config_source,
"kpis": kpis,
"communication_trend": comm_trend,
"service_trend": service_trend,
"lifecycle": lifecycle,
"dimension_summary": dim_summary,
"anomalies": anomalies,
"action_summary": await metrics_svc.action_counts(session, channel=ch),
"caliber_notes": {
"active_customers": "kpis.active_customers 为全员周期 distinct "
"求和,跨员工共享客户可能重复计",
"trend_active_customers": "communication_trend.active_customers "
"为宽表日值团队合计(日口径求和)",
"reply_2h_rate": "分子=fact 当日 ≤2h 计数,分母=宽表当日串数",
},
}
@router.get("/employees")
async def management_employees(
session: SessionDep,
channel: AllChannelParam = "all",
start: YmdParam | None = None,
end: YmdParam | None = None,
view: ViewParam = "management",
keyword: str | None = None,
status: StatusParam = "all",
sort_by: SortByParam = "anomaly_severity",
sort_order: SortOrderParam = "desc",
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
"""员工列表(一期 management/workload/service/customer 四视图共用
行集,view 仅回显)。默认 anomaly_severity 排序:含 critical 者 →
权重和 → 卡片数;total_score 排序 None 恒排最后。"""
start, end = _window(start, end)
ch = _translate(channel)
config, config_source = await asm.load_score_config(session)
assembled = await asm.assemble_employees(
session, config, start, end, ch, with_prev=True
)
items = list(assembled.items())
if ch is not None:
# 渠道过滤=账号归属(该渠道无账号的员工不进列表;有账号无数据仍
# 展示零值行)
owners_uids = set((await account_map(session, ch)).values())
items = [(u, d) for u, d in items if u in owners_uids]
if keyword:
kw = keyword.strip().lower()
items = [
(u, d) for u, d in items
if kw in (d["name"] or "").lower() or kw in u.lower()
]
if status == "on_job":
items = [(u, d) for u, d in items if d["is_on_job"] is True]
elif status == "offline":
items = [(u, d) for u, d in items if d["is_on_job"] is not True]
items.sort(key=lambda kv: kv[0]) # 决胜底座:user_id asc(稳定排序)
if sort_by == "anomaly_severity":
items.sort(key=lambda kv: (
any(a["severity"] == "critical" for a in kv[1]["anomalies"]),
kv[1]["anomaly_weight"], kv[1]["anomaly_count"],
), reverse=(sort_order == "desc"))
elif sort_by == "total_score":
# None(样本不足等)恒排最后,与升降序无关
if sort_order == "desc":
items.sort(key=lambda kv: (
kv[1]["score"]["total_score"] is None,
-(kv[1]["score"]["total_score"] or 0),
))
else:
items.sort(key=lambda kv: (
kv[1]["score"]["total_score"] is None,
kv[1]["score"]["total_score"] or 0,
))
else: # active_friends / cust_threads(PeriodMetrics 数值列)
if sort_order == "desc":
items.sort(key=lambda kv: -getattr(kv[1]["cur"], sort_by))
else:
items.sort(key=lambda kv: getattr(kv[1]["cur"], sort_by))
total = len(items)
page_rows = items[(page - 1) * page_size: page * page_size]
return {
"total": total,
"page": page,
"page_size": page_size,
"rows": [asm.build_employee_row(u, d) for u, d in page_rows],
"scope": {"channel": channel, "start": start, "end": end,
"view": view},
"data_as_of": await health_svc.data_as_of(session),
"metric_version": METRIC_VERSION,
"config_source": config_source,
}
@router.get("/employees/{user_id}")
async def management_employee_detail(
session: SessionDep,
user_id: str,
channel: AllChannelParam = "all",
start: YmdParam | None = None,
end: YmdParam | None = None,
) -> dict[str, Any]:
"""员工详情:身份+账号清单+期指标全字段+评分完整明细(含逐指标
metrics_detail)+异常卡片+prev/baseline 原始 PeriodMetrics。"""
start, end = _window(start, end)
ch = _translate(channel)
staff = await _get_staff(session, user_id)
config, config_source = await asm.load_score_config(session)
cur_map = await period_metrics(session, start, end, ch, user_ids={user_id})
bs, be = baseline_window(start)
base_map = await period_metrics(session, bs, be, ch, user_ids={user_id})
ps, pe = prev_window(start, end)
prev_map = await period_metrics(session, ps, pe, ch, user_ids={user_id})
cur, base, prev = (cur_map.get(user_id), base_map.get(user_id),
prev_map.get(user_id))
if cur is None: # 防御:_get_staff 已保证在 staff,period_metrics 必含
raise HTTPException(404, f"employee {user_id} not found")
team = asm.team_derived_medians(
await period_metrics(session, start, end, ch)
)
score = asm.score_with_guards(config, cur, baseline=base, team=team,
prev=prev)
anomalies = detect_anomalies(
user_id, cur, base, n_days=len(day_range(start, end)), config=config
)
accounts: list[dict] = []
for wa in (await session.execute(
sa.select(WechatAccount).where(WechatAccount.user_id == user_id)
)).scalars():
accounts.append({
"channel": "wechat", "account_id": wa.wx_id,
"nick_name": wa.nick_name, "user_name": wa.user_name,
})
for wa in (await session.execute(
sa.select(WhatsappAccount).where(WhatsappAccount.user_id == user_id)
)).scalars():
accounts.append({
"channel": "whatsapp", "account_id": wa.uid,
"nick_name": wa.nick_name, "user_name": wa.user_name,
})
return {
"user_id": user_id,
"name": staff.name,
"is_on_job": staff.is_on_job,
"accounts": accounts,
"metrics": cur._asdict(),
"score": score,
"anomalies": anomalies,
"prev": prev._asdict() if prev is not None else None,
"baseline": base._asdict() if base is not None else None,
"scope": {"channel": channel, "start": start, "end": end},
"data_as_of": await health_svc.data_as_of(session),
"metric_version": METRIC_VERSION,
"config_source": config_source,
}
@router.get("/employees/{user_id}/metrics")
async def management_employee_metrics(
session: SessionDep,
user_id: str,
section: SectionParam = "overview",
channel: AllChannelParam = "all",
start: YmdParam | None = None,
end: YmdParam | None = None,
) -> dict[str, Any]:
"""员工分段指标:该域期指标 + 窗口逐日趋势 + 四对比
(current/prev/baseline/median,派生率走 scoring.derive_metrics 口径)。"""
start, end = _window(start, end)
ch = _translate(channel)
await _get_staff(session, user_id)
cur_map = await period_metrics(session, start, end, ch, user_ids={user_id})
bs, be = baseline_window(start)
base_map = await period_metrics(session, bs, be, ch, user_ids={user_id})
ps, pe = prev_window(start, end)
prev_map = await period_metrics(session, ps, pe, ch, user_ids={user_id})
cur, base, prev = (cur_map.get(user_id), base_map.get(user_id),
prev_map.get(user_id))
if cur is None:
raise HTTPException(404, f"employee {user_id} not found")
team = asm.team_derived_medians(
await period_metrics(session, start, end, ch)
)
if section == "calls":
src_rows = await asm.call_daily_rows(session, start, end,
user_id=user_id)
else:
src_rows = await asm.wide_daily_rows(session, start, end, ch,
user_id=user_id)
by_day = {r["day"]: r for r in src_rows}
cols = _SECTION_TREND_COLS[section]
trend = []
for day in day_range(start, end):
row = by_day.get(day)
point: dict[str, Any] = {"day": day}
for col in cols:
if row is not None:
point[col] = row.get(col)
else: # 无数据日:计数列补 0,分位列补 None
point[col] = None if col.startswith("first_reply") else 0
trend.append(point)
return {
"section": section,
"metrics": {f: getattr(cur, f) for f in _SECTION_FIELDS[section]},
"trend": trend,
"comparisons": asm.derived_comparisons(cur, prev, base, team),
"scope": {"channel": channel, "start": start, "end": end,
"user_id": user_id},
"data_as_of": await health_svc.data_as_of(session),
"metric_version": METRIC_VERSION,
}
@router.get("/employees/{user_id}/drilldown")
async def management_employee_drilldown(
session: SessionDep,
user_id: str,
metric_code: str,
channel: AllChannelParam = "all",
start: YmdParam | None = None,
end: YmdParam | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
"""指标下钻(白名单六项,外 422):首响分位逐串证据 / 未回串 /
降温·沉睡客户名单 / 通话跟进列表。所有分支按员工 owner 账号过滤。"""
if metric_code not in _DRILLDOWN_CODES:
raise HTTPException(422, f"unknown metric_code: {metric_code}")
start, end = _window(start, end)
ch = _translate(channel)
await _get_staff(session, user_id)
scope = {"user_id": user_id, "metric_code": metric_code,
"channel": channel, "start": start, "end": end}
owners = await asm.user_owner_accounts(session, user_id, ch)
def _paged(items: list[dict]) -> dict[str, Any]:
ix = (page - 1) * page_size
return {"items": items[ix: ix + page_size], "total": len(items),
"page": page, "page_size": page_size, "scope": scope}
if metric_code in ("first_reply_p50", "first_reply_p90"):
if not owners:
return _paged([])
conds = [FirstReplyFact.day.between(start, end),
FirstReplyFact.owner_account_id.in_(owners)]
if ch is not None:
conds.append(FirstReplyFact.channel == ch)
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,
# 会话定位参数提示(owner+talker 直达聊天记录)
"chat_hint": {"owner_account_id": f.owner_account_id,
"talker_id": f.peer_id},
} for f in rows]
return {"items": items, "total": int(total), "page": page,
"page_size": page_size, "scope": scope}
if metric_code == "unreplied":
# 上游无私有 owner 过滤:一次拉全量决赛圈结果(候选 ≤500 时完整),
# 按 owner 账号过滤后内存分页;候选超限标 truncated(过滤可能不全)
res = await metrics_svc.unreplied(
session, channel=ch, hours=24, page=1, page_size=_UNREPLIED_FETCH
)
owner_set = set(owners)
owned = [i for i in res["items"]
if i["owner_account_id"] in owner_set]
out = _paged(owned)
out["note"] = ("按该员工 owner 账号自全量未回清单过滤"
"(hours=24,与看板清单同源同判定)")
if res["total"] > len(res["items"]):
out["truncated"] = True
return out
if metric_code in ("cooling_customers", "dormant_customers"):
want = ("cooling" if metric_code == "cooling_customers"
else "dormant")
if not owners:
return _paged([])
matches = await asm.lifecycle_customers(session, owners, end, want)
return _paged(matches)
# call_followup:窗口通话列表(start desc)+ 期聚合摘要
# (逐通话跟进标记复用批次A _followup_exists 太重,留批次C按需再加)
lo, _ = day_bounds_ms(start)
_, hi = day_bounds_ms(end)
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]
summary_rows = await asm.call_daily_rows(session, start, end,
user_id=user_id)
summary = {col: sum(r[col] for r in summary_rows)
for col in ("call_count", "call_seconds",
"answered_call_count", "call_followup_count")}
return {"items": items, "total": int(total), "page": page,
"page_size": page_size, "summary": summary, "scope": scope,
"note": "逐通话跟进标记留批次C;此处为窗口通话列表+期聚合"
"(followup 为接通后24h内有跟进的通话数)"}
@router.get("/metric-definitions")
async def management_metric_definitions(
session: SessionDep,
) -> dict[str, Any]:
"""指标口径字典 + 当前评分配置(含 pending 维度)+ 三方口径差异说明。
config_source: "db"=激活配置 / "default"=未种子回退默认。"""
config, source = await asm.load_score_config(session)
return {
"definitions": METRIC_DEFINITIONS,
"score_config": {
"version": config.get("version"),
"name": config.get("name"),
"trial": config.get("trial"),
"normalization": config.get("normalization"),
"sample_guards": config.get("sample_guards"),
"grade_bounds": config.get("grade_bounds"),
"dimensions": config.get("dimensions"),
},
"config_source": source,
"caliber_notes": CALIBER_NOTES,
}