Files
yunke/yunke-server/tests/test_management_api.py
T

708 lines
31 KiB
Python
Raw Normal View History

"""管理 API 六只读端点判别测试(批次B Task B4)。
覆盖:
- 参数校验:422(缺 start / 跨度>366 / 日历非法 / drilldown 白名单外 /
page_size>100)、404(employees/{ghost}、drilldown 员工不存在);
- dashboard 各段齐全与关键数值(kpis/communication_trend/service_trend/
lifecycle/dimension_summary/anomalies/action_summary/data_as_of/
metric_version);
- employees:默认分页 20/total/critical 优先默认排序/channel=wechat 时
whatsapp-only 员工消失/keyword/status/min_days 不足→insufficient_sample;
- detail:账号清单+metrics 全字段+score 完整明细+anomalies+prev/baseline;
- sections:五段各有 trend 数组与四对比;
- drilldown:p90 分页与 fr_ms desc/unreplied 按 owner 过滤/cooling-dormant
生命周期判定(已删客户剔除)/call_followup 列表+期聚合摘要;
- metric-definitions:trial 标记/口径说明/config_source 回退。
"""
import copy
import datetime as dt
from zoneinfo import ZoneInfo
from app.db.models import (
Customer,
EmployeeCallDaily,
EmployeeMetricDaily,
EmployeeScoreConfig,
FirstReplyFact,
Message,
MsgDailyStats,
PhoneCall,
Staff,
WechatAccount,
WhatsappAccount,
)
from app.management.metric_definitions import DEFAULT_SCORE_CONFIG
_SH = ZoneInfo("Asia/Shanghai")
START, END = 20260901, 20260905 # 当前窗 5 天(min_days=5 恰好够)
def _ts(*args) -> int:
return int(dt.datetime(*args, tzinfo=_SH).timestamp() * 1000)
def _mday(day, *, channel="wechat", user_id="u1", metric_version="v1", **kw):
cols = {
"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,
"data_complete": True,
}
cols.update(kw)
return EmployeeMetricDaily(
day=day, channel=channel, user_id=user_id,
metric_version=metric_version, **cols
)
def _fact(fr_ms, *, channel="wechat", owner="wx1", peer="p1", day=20260901, i=0):
end = _ts(day // 10000, day // 100 % 100, day % 100, 10, 0) + i * 60_000
return FirstReplyFact(
channel=channel, owner_account_id=owner, peer_id=peer,
thread_end_ts=end, reply_ts=end + fr_ms,
reply_msg_id=f"{owner}-{peer}-m{i}", day=day, fr_ms=fr_ms,
)
async def _seed_common(s):
"""三员工全量种子:u1(微信,2 warning)/u2(双渠道,1 critical+1 warning)/
u3(仅 WhatsApp,无异常)——支撑 dashboard/employees/detail/sections/
drilldown 全部判据。"""
for uid, name, onjob in (("u1", "张三", True), ("u2", "李四", True),
("u3", "王五", False)):
s.add(Staff(user_id=uid, name=name, is_on_job=onjob))
s.add(WechatAccount(wx_id="wx1", user_id="u1", nick_name="张三的微信",
user_name="张三"))
s.add(WechatAccount(wx_id="wx2", user_id="u2"))
s.add(WhatsappAccount(uid="wa2", user_id="u2"))
s.add(WhatsappAccount(uid="wa3", user_id="u3"))
# ---- 当前窗宽表(0901-0905)----
for d in range(1, 6):
s.add(_mday(20260900 + d, user_id="u1", msg_sent=1, cust_threads=2,
threads_answered=2, fr_sum_ms=240_000, active_friends=3,
active_rooms=1, cooling_customer_count=2,
dormant_customer_count=1,
first_reply_p50_ms=110_000, first_reply_p90_ms=220_000))
s.add(_mday(20260900 + d, user_id="u2", msg_sent=10, msg_recv=10,
cust_threads=2, threads_answered=2, fr_sum_ms=1_200_000,
active_friends=4, cooling_customer_count=1,
reactivated_customer_count=1))
s.add(_mday(20260900 + d, channel="whatsapp", user_id="u3",
msg_sent=5, msg_recv=5, cust_threads=1, active_friends=1))
# ---- 基线窗宽表(0827-0831,u1 高日均触发 activity_drop)----
for d in range(27, 32):
s.add(_mday(20260800 + d, user_id="u1", msg_sent=14, msg_recv=14))
s.add(_mday(20260800 + d, user_id="u2", msg_sent=5, msg_recv=5))
# ---- fact:当前窗(u1 120s warning / u2 600s critical)+ 基线 60s ----
s.add(_fact(120_000, peer="pA", day=20260901, i=0))
s.add(_fact(120_000, peer="pB", day=20260902, i=1))
s.add(_fact(600_000, owner="wx2", peer="pC", day=20260901, i=2))
s.add(_fact(600_000, owner="wx2", peer="pD", day=20260902, i=3))
for owner, peer, day, i in (("wx1", "pA", 20260827, 10),
("wx1", "pB", 20260828, 11),
("wx2", "pC", 20260827, 12),
("wx2", "pD", 20260828, 13)):
s.add(_fact(60_000, owner=owner, peer=peer, day=day, i=i))
# ---- msg_daily_stats:活跃客户 distinct + data_as_of + 未回候选 ----
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="p1", day=20260901,
send_cnt=0, recv_cnt=1, last_ts=_ts(2026, 9, 1, 10, 0),
last_is_send=False))
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="p2", day=20260902,
send_cnt=1, recv_cnt=0, last_ts=_ts(2026, 9, 2, 10, 0),
last_is_send=True))
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx2",
peer_kind="friend", peer_id="p3", day=20260901,
send_cnt=2, recv_cnt=0, last_ts=_ts(2026, 9, 1, 11, 0),
last_is_send=True))
s.add(MsgDailyStats(channel="whatsapp", owner_account_id="wa3",
peer_kind="friend", peer_id="p4", day=20260903,
send_cnt=1, recv_cnt=0, last_ts=_ts(2026, 9, 3, 9, 0),
last_is_send=True))
# drilldown cooling/dormant 客户互动史(全历史,窗外):
# f1 末次 0801(gap 35, hist 12)→ dormant;f2 末次 0825(gap 11)→ cooling
for day, n in ((20260701, 6), (20260801, 6)):
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="f1", day=day,
send_cnt=n, recv_cnt=0,
last_ts=_ts(day // 10000, day // 100 % 100,
day % 100, 10, 0),
last_is_send=True))
for day in (20260801, 20260825):
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="f2", day=day,
send_cnt=2, recv_cnt=2,
last_ts=_ts(2026, 8, day % 100, 11, 0),
last_is_send=True))
# ---- 客户档案(f9 已删,须被 drilldown 剔除)----
s.add(Customer(friend_id="f1", channel="wechat", owner_account_id="wx1",
friend_nickname="客户一", user_id="u1", delete_flag=0))
s.add(Customer(friend_id="f2", channel="wechat", owner_account_id="wx1",
friend_nickname="客户二", user_id="u1", delete_flag=0))
s.add(Customer(friend_id="f9", channel="wechat", owner_account_id="wx1",
friend_nickname="已删客户", user_id="u1", delete_flag=1))
# ---- 未回候选 p1 的尾部消息(unreplied 决赛圈点查)----
s.add(Message(msg_id="m-p1", channel="wechat", owner_account_id="wx1",
talker_id="p1", is_send=False, type="text", text="在吗",
timestamp=_ts(2026, 9, 1, 10, 0)))
# ---- 通话 ----
s.add(EmployeeCallDaily(day=20260901, user_id="u1", call_count=2,
call_seconds=100, answered_call_count=1,
call_followup_count=1))
s.add(EmployeeCallDaily(day=20260902, user_id="u2", call_count=1,
call_seconds=60, answered_call_count=1,
call_followup_count=0))
s.add(PhoneCall(call_id="c1", channel="voice_call", user_id="u1",
start_call_time=_ts(2026, 9, 1, 15, 0), call_duration=120,
call_number="13800000001"))
s.add(PhoneCall(call_id="c2", channel="voice_call", user_id="u1",
start_call_time=_ts(2026, 9, 2, 15, 0), call_duration=0,
call_number="13800000002"))
await s.commit()
# ---------------------------------------------------------------- 参数与错误
async def test_dashboard_missing_start_422(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/dashboard", params={"end": END})
assert r.status_code == 422
async def test_dashboard_span_and_calendar_422(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/dashboard",
params={"start": 20250101, "end": 20261231})
assert r.status_code == 422
r = await client.get("/api/management/dashboard",
params={"start": 20260230, "end": 20260905})
assert r.status_code == 422 # 日历非法(2 月 30 日)
r = await client.get("/api/management/dashboard",
params={"start": 20260906, "end": 20260901})
assert r.status_code == 422 # start > end
async def test_drilldown_unknown_metric_422(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees/u1/drilldown",
params={"metric_code": "bogus",
"start": START, "end": END})
assert r.status_code == 422
assert "unknown metric_code" in r.json()["detail"]
async def test_employees_page_size_over_100_422(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees",
params={"start": START, "end": END,
"page_size": 101})
assert r.status_code == 422
async def test_employee_detail_404(make_client):
async def seed(s):
await s.commit()
client = await make_client(seed)
r = await client.get("/api/management/employees/ghost",
params={"start": START, "end": END})
assert r.status_code == 404
async def test_drilldown_employee_404(make_client):
async def seed(s):
await s.commit()
client = await make_client(seed)
r = await client.get("/api/management/employees/ghost/drilldown",
params={"metric_code": "unreplied",
"start": START, "end": END})
assert r.status_code == 404
# ---------------------------------------------------------------- dashboard
async def test_dashboard_structure_and_kpis(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/dashboard",
params={"channel": "all", "start": START, "end": END})
assert r.status_code == 200
body = r.json()
for key in ("scope", "data_as_of", "metric_version", "kpis",
"communication_trend", "service_trend", "lifecycle",
"dimension_summary", "anomalies", "action_summary",
"config_source", "caliber_notes"):
assert key in body, key
assert body["metric_version"] == "v1"
assert body["config_source"] == "default"
assert body["scope"] == {"channel": "all", "start": START, "end": END,
"compare": "previous"}
assert body["data_as_of"] == _ts(2026, 9, 3, 9, 0)
k = body["kpis"]
# 活跃客户=PM distinct 求和(u1:2 + u2:1 + u3:1)
assert k["active_customers"] == 4
assert k["active_employees"] == 3
assert k["msg_total"] == 155 # u1 5 + u2 100 + u3 50
assert k["cust_threads"] == 25 # 10 + 10 + 5
assert k["threads_answered"] == 20 # 10 + 10 + 0
assert k["call_count"] == 3
assert k["call_seconds"] == 160
assert k["answered_call"] == 2
# 期末存量:u1 cooling2/dormant1 + u2 cooling1;reactivated 期内求和 u2 5
assert body["lifecycle"] == {"new": 0, "cooling": 3, "dormant": 1,
"reactivated": 5}
assert k["lifecycle"] == body["lifecycle"]
# 趋势逐日 5 点;0901 = u1(1)+u2(20)+u3(10) 消息,宽表 active 合计 8,通话 2
trend = body["communication_trend"]
assert [t["day"] for t in trend] == [20260901 + i for i in range(5)]
assert trend[0]["msg_total"] == 31
assert trend[0]["active_customers"] == 8
assert trend[0]["call_cnt"] == 2
assert trend[1]["call_cnt"] == 1
assert trend[4] == {"day": 20260905, "msg_total": 31,
"active_customers": 8, "call_cnt": 0}
# 服务趋势:0901 fact [120k, 600k] → p50=120k/p90=600k;
# reply_2h_rate = 2/5(两串均 ≤2h——600s=10min / 宽表当日串 5)
st = {t["day"]: t for t in body["service_trend"]}
assert st[20260901]["fr_p50_ms"] == 120_000
assert st[20260901]["fr_p90_ms"] == 600_000
assert st[20260901]["reply_2h_rate"] == 0.4
assert st[20260903]["fr_p50_ms"] is None # 无 fact 日
assert st[20260903]["reply_2h_rate"] == 0.0 # 分母有串、分子 0
# 维度摘要:5 维全员计数,总数=员工数 3
dims = body["dimension_summary"]
assert set(dims) == {"customer", "service", "action", "workload", "risk"}
for d in dims.values():
assert d["scored_count"] + d["insufficient_count"] + d["pending_count"] == 3
assert "avg_score" in d
# 异常:critical 在前(u2),warning 其后按 affected_count desc
anoms = body["anomalies"]
assert anoms[0]["severity"] == "critical"
assert anoms[0]["employee_user_id"] == "u2"
assert all(a["severity"] == "critical" for a in anoms[:1])
assert all(a["severity"] == "warning" for a in anoms[1:])
assert body["action_summary"] == {"unreplied_2h": 1, "churn_real_30d": 0,
"sensitive_pending": 0}
# ---------------------------------------------------------------- employees
async def test_employees_default_sort_and_paging(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees",
params={"channel": "all", "start": START, "end": END})
assert r.status_code == 200
body = r.json()
assert body["total"] == 3
assert body["page"] == 1
assert body["page_size"] == 20
assert body["metric_version"] == "v1"
assert body["config_source"] == "default"
assert len(body["rows"]) == 3
# 默认 anomaly_severity 排序:含 critical 的 u2 在最前
# (u2=1 critical——基线无串样本率不可算,rate 规则不触发;u1=2 warning)
ids = [row["user_id"] for row in body["rows"]]
assert ids == ["u2", "u1", "u3"]
u2, u1, u3 = body["rows"]
assert u2["anomaly_count"] == 1 and u2["anomaly_weight"] == 2
assert u1["anomaly_count"] == 2 and u1["anomaly_weight"] == 2
assert u3["anomaly_count"] == 0 and u3["anomaly_weight"] == 0
# 行结构:metrics 全字段 / comparisons 四值 / score 摘要
assert u1["name"] == "张三" and u1["is_on_job"] is True
assert u1["metrics"]["cust_threads"] == 10
assert u1["metrics"]["end_active_friends"] == 3
# M3:comparisons 键域=源指标名(与 sections 端点一致;band 配置 code
# 映射为其 spec.metric——active_friends/call_count,不出现 *_band)
assert set(u1["comparisons"]) >= {
"active_share", "reply_2h_rate", "reply_24h_rate", "fr_p90_ms",
"active_friends", "call_count"}
assert "active_friends_band" not in u1["comparisons"]
assert "call_band" not in u1["comparisons"]
for c in u1["comparisons"].values():
assert set(c) == {"current", "prev", "baseline", "median"}
assert u1["comparisons"]["fr_p90_ms"]["current"] == 120_000
assert u1["comparisons"]["fr_p90_ms"]["baseline"] == 60_000
assert set(u1["score"]["dimensions"]) == {
"customer", "service", "action", "workload", "risk"}
for d in u1["score"]["dimensions"].values():
assert set(d) == {"score", "status"}
assert u1["score"]["trial"] is True
assert u1["score"]["score_status"] == "scored" # 5 天数据 ≥ min_days
async def test_employees_channel_filter(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees",
params={"channel": "wechat", "start": START,
"end": END})
ids = [row["user_id"] for row in r.json()["rows"]]
assert ids == ["u2", "u1"] # u3 仅 WhatsApp,wechat 视图消失
r = await client.get("/api/management/employees",
params={"channel": "whatsapp", "start": START,
"end": END})
ids = [row["user_id"] for row in r.json()["rows"]]
assert ids == ["u2", "u3"] # u1 无 WA 账号
async def test_employees_keyword(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees",
params={"start": START, "end": END, "keyword": "张"})
assert [row["user_id"] for row in r.json()["rows"]] == ["u1"]
r = await client.get("/api/management/employees",
params={"start": START, "end": END, "keyword": "U3"})
assert [row["user_id"] for row in r.json()["rows"]] == ["u3"]
async def test_employees_status_filter(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees",
params={"start": START, "end": END,
"status": "on_job"})
assert [row["user_id"] for row in r.json()["rows"]] == ["u2", "u1"]
r = await client.get("/api/management/employees",
params={"start": START, "end": END,
"status": "offline"})
assert [row["user_id"] for row in r.json()["rows"]] == ["u3"]
async def test_employees_min_days_insufficient(make_client):
"""B2 评审遗留接线:days_with_data < min_days → score_status 覆盖
insufficient_sample,但可评明细(total_score)保留展示。"""
async def seed(s):
s.add(Staff(user_id="u9", name="赵九", is_on_job=True))
s.add(WechatAccount(wx_id="wx9", user_id="u9"))
# 仅 2 天宽表数据(< min_days=5),但串样本 30 ≥ min_threads 可评分
for d in (1, 2):
s.add(_mday(20260900 + d, user_id="u9", cust_threads=15,
threads_answered=15, fr_sum_ms=900_000,
active_friends=10))
s.add(_fact(60_000, owner="wx9", peer="q1", day=20260901, i=0))
s.add(_fact(60_000, owner="wx9", peer="q2", day=20260902, i=1))
for i in range(6): # 6 个 distinct peer → workload band 可评
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx9",
peer_kind="friend", peer_id=f"q{i}",
day=20260901, send_cnt=1, recv_cnt=0,
last_ts=_ts(2026, 9, 1, 12, 0),
last_is_send=True))
await s.commit()
client = await make_client(seed)
r = await client.get("/api/management/employees",
params={"start": START, "end": END})
assert r.status_code == 200
row = r.json()["rows"][0]
assert row["user_id"] == "u9"
assert row["metrics"]["days_with_data"] == 2
assert row["score"]["score_status"] == "insufficient_sample"
assert row["score"]["total_score"] is not None # 明细保留展示
async def test_employees_db_score_config_guard(make_client):
"""config_source=db 路径:激活配置的 min_days=9 生效——u1 五天数据
在默认配置(min_days=5)下 scored,在 DB 配置下翻为 insufficient_sample。"""
async def seed(s):
await _seed_common(s)
cfg = copy.deepcopy(DEFAULT_SCORE_CONFIG)
cfg["version"] = "v1-test"
cfg["name"] = "评审测试配置"
cfg["sample_guards"]["min_days"] = 9
s.add(EmployeeScoreConfig(version="v1-test", name="评审测试配置",
status="active", config_json=cfg,
created_by="test"))
await s.commit()
client = await make_client(seed)
r = await client.get("/api/management/employees",
params={"channel": "all", "start": START,
"end": END})
assert r.status_code == 200
body = r.json()
assert body["config_source"] == "db"
u1 = next(row for row in body["rows"] if row["user_id"] == "u1")
assert u1["metrics"]["days_with_data"] == 5
assert u1["score"]["score_status"] == "insufficient_sample" # 5 < 9
assert u1["score"]["total_score"] is not None
# ---------------------------------------------------------------- detail
async def test_employee_detail_full(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees/u1",
params={"channel": "all", "start": START, "end": END})
assert r.status_code == 200
body = r.json()
assert body["user_id"] == "u1"
assert body["name"] == "张三"
assert body["accounts"] == [
{"channel": "wechat", "account_id": "wx1",
"nick_name": "张三的微信", "user_name": "张三"}]
# metrics 全字段(22 列 namedtuple)
assert len(body["metrics"]) == 22
assert body["metrics"]["cust_threads"] == 10
# score 完整明细:dimensions 含逐指标 metrics;metrics_detail 扁平
assert body["score"]["dimensions"]["service"]["metrics"]["fr_p90_ms"][
"score"] == 100.0
assert body["score"]["metrics_detail"]["fr_p90_ms"]["value"] == 120_000
assert body["score"]["metrics_detail"]["reply_2h_rate"]["value"] == 0.2
assert body["score"]["metrics_detail"]["fr_p90_ms"]["baseline"] == 60_000
# anomalies + prev/baseline 原始值(prev 窗 0827-0831 与基线窗重叠:
# 5 天 × msg_sent 14 = 70)
assert len(body["anomalies"]) == 2
assert all(a["severity"] == "warning" for a in body["anomalies"])
assert body["prev"]["msg_sent"] == 70
assert body["baseline"]["msg_sent"] == 70
assert body["data_as_of"] == _ts(2026, 9, 3, 9, 0)
# ---------------------------------------------------------------- sections
async def test_employee_sections(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
base = "/api/management/employees/u1/metrics"
for section in ("overview", "service", "customer", "workload", "calls"):
r = await client.get(base, params={
"section": section, "channel": "wechat",
"start": START, "end": END})
assert r.status_code == 200, section
body = r.json()
assert body["section"] == section
assert len(body["trend"]) == 5 # 全窗口逐日(含零填充)
assert all("day" in t for t in body["trend"])
assert body["comparisons"]["reply_2h_rate"]["current"] == 0.2
assert set(body["comparisons"]["reply_2h_rate"]) == {
"current", "prev", "baseline", "median"}
r = await client.get(base, params={"section": "service",
"channel": "wechat",
"start": START, "end": END})
body = r.json()
assert body["metrics"]["cust_threads"] == 10
assert body["metrics"]["reply_2h_count"] == 2
t0 = body["trend"][0]
assert t0 == {"day": 20260901, "cust_threads": 2, "threads_answered": 2,
"fr_sum_ms": 240_000, "first_reply_p50_ms": 110_000,
"first_reply_p90_ms": 220_000}
r = await client.get(base, params={"section": "calls", "channel": "all",
"start": START, "end": END})
calls = {t["day"]: t for t in r.json()["trend"]}
assert calls[20260901]["call_count"] == 2
assert calls[20260901]["call_followup_count"] == 1
assert calls[20260905]["call_count"] == 0 # 零填充
assert r.json()["metrics"]["call_count"] == 2
# ---------------------------------------------------------------- drilldown
async def test_drilldown_fr_p90(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
url = "/api/management/employees/u2/drilldown"
r = await client.get(url, params={
"metric_code": "first_reply_p90", "channel": "all",
"start": START, "end": END, "page": 1, "page_size": 1})
assert r.status_code == 200
body = r.json()
assert body["total"] == 2
assert len(body["items"]) == 1
item = body["items"][0]
# fr_ms desc + 同值按日 desc 决胜:0902 的 pD 在前
assert item["fr_ms"] == 600_000
assert item["peer_id"] == "pD"
assert item["owner_account_id"] == "wx2"
assert item["chat_hint"] == {"owner_account_id": "wx2", "talker_id": "pD"}
r = await client.get(url, params={
"metric_code": "first_reply_p90", "channel": "all",
"start": START, "end": END, "page": 2, "page_size": 1})
assert r.json()["items"][0]["peer_id"] == "pC"
# u2 的 WA 账号无 fact 行
r = await client.get(url, params={
"metric_code": "first_reply_p50", "channel": "whatsapp",
"start": START, "end": END})
assert r.json()["total"] == 0
async def test_drilldown_unreplied(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees/u1/drilldown",
params={"metric_code": "unreplied",
"channel": "wechat",
"start": START, "end": END})
assert r.status_code == 200
body = r.json()
assert body["total"] == 1 # p1(wx1)进入,u1 过滤后
assert body["items"][0]["peer_id"] == "p1"
assert body["items"][0]["owner_account_id"] == "wx1"
# u2 名下无未回候选(p3 尾部为员工消息)
r = await client.get("/api/management/employees/u2/drilldown",
params={"metric_code": "unreplied",
"channel": "wechat",
"start": START, "end": END})
assert r.json()["total"] == 0
async def test_drilldown_lifecycle_customers(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees/u1/drilldown",
params={"metric_code": "cooling_customers",
"channel": "all",
"start": START, "end": END})
assert r.status_code == 200
body = r.json()
assert body["total"] == 1 # f2 降温;f1 沉睡、f9 已删
assert body["items"][0] == {"friend_id": "f2", "nickname": "客户二",
"last_day": 20260825, "state": "cooling"}
r = await client.get("/api/management/employees/u1/drilldown",
params={"metric_code": "dormant_customers",
"channel": "all",
"start": START, "end": END})
body = r.json()
assert body["total"] == 1
assert body["items"][0]["friend_id"] == "f1"
assert body["items"][0]["state"] == "dormant"
async def test_drilldown_shared_customer_history(make_client):
"""I1:共享/换归属客户——互动史挂在他人账号(wx2)名下而 Customer.owner
=wx1:历史按 (channel, peer) 键控跨 owner 合并(对齐批次A _lifecycle_daily
的 peer 键控口径),客户不丢、历史完整(hist 12 ≥ 5 判沉睡)。"""
async def seed(s):
await _seed_common(s)
for day, month in ((20260701, 7), (20260801, 8)):
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx2",
peer_kind="friend", peer_id="f3", day=day,
send_cnt=3, recv_cnt=3,
last_ts=_ts(2026, month, day % 100, 12, 0),
last_is_send=True))
s.add(Customer(friend_id="f3", channel="wechat",
owner_account_id="wx1", friend_nickname="共享客户",
user_id="u1", delete_flag=0))
await s.commit()
client = await make_client(seed)
r = await client.get("/api/management/employees/u1/drilldown",
params={"metric_code": "dormant_customers",
"channel": "all",
"start": START, "end": END})
body = r.json()
assert body["total"] == 2 # f1 + f3(历史全在 wx2 名下也不丢)
f3 = next(i for i in body["items"] if i["friend_id"] == "f3")
assert f3 == {"friend_id": "f3", "nickname": "共享客户",
"last_day": 20260801, "state": "dormant"}
# 归属口径仍是 Customer.owner(wx1):u2(wx2)名下看不到
r = await client.get("/api/management/employees/u2/drilldown",
params={"metric_code": "dormant_customers",
"channel": "all",
"start": START, "end": END})
assert r.json()["total"] == 0
async def test_drilldown_call_followup(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees/u1/drilldown",
params={"metric_code": "call_followup",
"channel": "all",
"start": START, "end": END})
assert r.status_code == 200
body = r.json()
assert body["total"] == 2
# start_call_time desc:c2(0902)在前
assert [i["call_id"] for i in body["items"]] == ["c2", "c1"]
assert set(body["items"][0]) == {
"call_id", "channel", "call_number", "start_call_time",
"call_duration"}
assert body["summary"] == {"call_count": 2, "call_seconds": 100,
"answered_call_count": 1,
"call_followup_count": 1}
# ------------------------------------------------------- metric-definitions
async def test_metric_definitions(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/metric-definitions")
assert r.status_code == 200
body = r.json()
assert body["definitions"]["cust_threads"]["unit"] == "串"
assert body["score_config"]["trial"] is True
assert body["score_config"]["dimensions"]["risk"]["status"] == "pending_data"
assert body["config_source"] == "default" # 未种子配置→回退默认
assert "1:1" in body["caliber_notes"]