2026-09-09 18:34:58 +08:00
|
|
|
|
"""管理 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/
|
2026-09-10 12:20:28 +08:00
|
|
|
|
metric_version);批次D 补:prev_kpis 环比块+msg_delta(含前窗零数据
|
|
|
|
|
|
→None)与 anomalies employee_name enrich(详情端点不带);
|
2026-09-09 18:34:58 +08:00
|
|
|
|
- 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 回退。
|
|
|
|
|
|
"""
|
2026-09-09 18:59:45 +08:00
|
|
|
|
import copy
|
2026-09-09 18:34:58 +08:00
|
|
|
|
import datetime as dt
|
|
|
|
|
|
from zoneinfo import ZoneInfo
|
|
|
|
|
|
|
|
|
|
|
|
from app.db.models import (
|
|
|
|
|
|
Customer,
|
|
|
|
|
|
EmployeeCallDaily,
|
|
|
|
|
|
EmployeeMetricDaily,
|
2026-09-09 18:59:45 +08:00
|
|
|
|
EmployeeScoreConfig,
|
2026-09-09 18:34:58 +08:00
|
|
|
|
FirstReplyFact,
|
|
|
|
|
|
Message,
|
|
|
|
|
|
MsgDailyStats,
|
|
|
|
|
|
PhoneCall,
|
|
|
|
|
|
Staff,
|
|
|
|
|
|
WechatAccount,
|
|
|
|
|
|
WhatsappAccount,
|
|
|
|
|
|
)
|
2026-09-09 18:59:45 +08:00
|
|
|
|
from app.management.metric_definitions import DEFAULT_SCORE_CONFIG
|
2026-09-09 18:34:58 +08:00
|
|
|
|
|
|
|
|
|
|
_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 日)
|
2026-09-09 18:59:45 +08:00
|
|
|
|
r = await client.get("/api/management/dashboard",
|
|
|
|
|
|
params={"start": 20260906, "end": 20260901})
|
|
|
|
|
|
assert r.status_code == 422 # start > end
|
2026-09-09 18:34:58 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"]
|
|
|
|
|
|
|
2026-09-10 12:20:28 +08:00
|
|
|
|
# 前窗环比(批次D):prev 窗 0827-0831 与基线窗种子重叠——
|
|
|
|
|
|
# u1(14+14)×5 + u2(5+5)×5 = 190;宽表无 active_friends/串列 → 0
|
|
|
|
|
|
pk = body["prev_kpis"]
|
|
|
|
|
|
assert set(pk) == {"active_customers", "active_employees",
|
|
|
|
|
|
"msg_total", "cust_threads"}
|
|
|
|
|
|
assert pk == {"active_customers": 0, "active_employees": 2,
|
|
|
|
|
|
"msg_total": 190, "cust_threads": 0}
|
|
|
|
|
|
assert body["msg_delta"] == round((155 - 190) / 190, 3) # -0.184
|
|
|
|
|
|
|
2026-09-09 18:34:58 +08:00
|
|
|
|
# 趋势逐日 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
|
|
|
|
|
|
|
2026-09-10 12:20:28 +08:00
|
|
|
|
# 异常:critical 在前(u2),warning 其后按 affected_count desc;
|
|
|
|
|
|
# dashboard 卡片 enrich employee_name(批次D,前端跳员工详情用)
|
2026-09-09 18:34:58 +08:00
|
|
|
|
anoms = body["anomalies"]
|
|
|
|
|
|
assert anoms[0]["severity"] == "critical"
|
|
|
|
|
|
assert anoms[0]["employee_user_id"] == "u2"
|
2026-09-10 12:20:28 +08:00
|
|
|
|
assert anoms[0]["employee_name"] == "李四"
|
2026-09-09 18:34:58 +08:00
|
|
|
|
assert all(a["severity"] == "critical" for a in anoms[:1])
|
|
|
|
|
|
assert all(a["severity"] == "warning" for a in anoms[1:])
|
2026-09-10 12:20:28 +08:00
|
|
|
|
assert all(a["employee_name"] == "张三" for a in anoms[1:])
|
2026-09-09 18:34:58 +08:00
|
|
|
|
|
|
|
|
|
|
assert body["action_summary"] == {"unreplied_2h": 1, "churn_real_30d": 0,
|
|
|
|
|
|
"sensitive_pending": 0}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 12:20:28 +08:00
|
|
|
|
async def test_dashboard_prev_zero_no_delta(make_client):
|
|
|
|
|
|
"""前窗无数据(whatsapp 视图前窗零宽表)→ prev_kpis 四键全 0、
|
|
|
|
|
|
msg_delta None(prev=0 不算环比,None 而非 0/负∞)。"""
|
|
|
|
|
|
async def seed(s):
|
|
|
|
|
|
await _seed_common(s)
|
|
|
|
|
|
|
|
|
|
|
|
client = await make_client(seed)
|
|
|
|
|
|
r = await client.get("/api/management/dashboard",
|
|
|
|
|
|
params={"channel": "whatsapp", "start": START,
|
|
|
|
|
|
"end": END})
|
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
|
body = r.json()
|
|
|
|
|
|
assert body["kpis"]["msg_total"] == 50 # 仅 u3 的 whatsapp 宽表
|
|
|
|
|
|
assert body["prev_kpis"] == {"active_customers": 0,
|
|
|
|
|
|
"active_employees": 0, "msg_total": 0,
|
|
|
|
|
|
"cust_threads": 0}
|
|
|
|
|
|
assert body["msg_delta"] is None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-09 18:34:58 +08:00
|
|
|
|
# ---------------------------------------------------------------- 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
|
2026-09-09 18:59:45 +08:00
|
|
|
|
# M3:comparisons 键域=源指标名(与 sections 端点一致;band 配置 code
|
|
|
|
|
|
# 映射为其 spec.metric——active_friends/call_count,不出现 *_band)
|
2026-09-09 18:34:58 +08:00
|
|
|
|
assert set(u1["comparisons"]) >= {
|
|
|
|
|
|
"active_share", "reply_2h_rate", "reply_24h_rate", "fr_p90_ms",
|
2026-09-09 18:59:45 +08:00
|
|
|
|
"active_friends", "call_count"}
|
|
|
|
|
|
assert "active_friends_band" not in u1["comparisons"]
|
|
|
|
|
|
assert "call_band" not in u1["comparisons"]
|
2026-09-09 18:34:58 +08:00
|
|
|
|
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 # 明细保留展示
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-09 18:59:45 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-09 18:34:58 +08:00
|
|
|
|
# ---------------------------------------------------------------- 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 与基线窗重叠:
|
2026-09-10 12:20:28 +08:00
|
|
|
|
# 5 天 × msg_sent 14 = 70);详情端点 anomalies 不带 employee_name
|
|
|
|
|
|
# (自指无意义,仅 dashboard enrich)
|
2026-09-09 18:34:58 +08:00
|
|
|
|
assert len(body["anomalies"]) == 2
|
|
|
|
|
|
assert all(a["severity"] == "warning" for a in body["anomalies"])
|
2026-09-10 12:20:28 +08:00
|
|
|
|
assert all("employee_name" not in a for a in body["anomalies"])
|
2026-09-09 18:34:58 +08:00
|
|
|
|
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"
|
2026-09-10 09:49:59 +08:00
|
|
|
|
assert item["channel"] == "wechat"
|
2026-09-09 18:34:58 +08:00
|
|
|
|
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 已删
|
2026-09-10 09:49:59 +08:00
|
|
|
|
assert body["items"][0] == {"friend_id": "f2", "channel": "wechat",
|
|
|
|
|
|
"nickname": "客户二",
|
2026-09-09 18:34:58 +08:00
|
|
|
|
"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"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-09 18:59:45 +08:00
|
|
|
|
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")
|
2026-09-10 09:49:59 +08:00
|
|
|
|
assert f3 == {"friend_id": "f3", "channel": "wechat",
|
|
|
|
|
|
"nickname": "共享客户",
|
2026-09-09 18:59:45 +08:00
|
|
|
|
"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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-09 18:34:58 +08:00
|
|
|
|
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"]
|