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