fix(management): drilldown生命周期历史改peer键控对齐批次A口径+comparisons键域统一+补测试

评审修正(I1/M1-M4/M6):
- I1 lifecycle_customers 历史改 (channel,peer) 键控跨owner合并,归属仍由
  Customer.owner_account_id 决定——与批次A _lifecycle_daily 同口径;新增
  共享客户测试(历史在他人账号下仍全量呈现)
- M3 employees 行 comparisons 键域统一为指标源名(config code→spec.metric
  映射,active_friends_band/call_band 不再出现),与 sections 一致
- M4 未回复分支空 owners 先短路,免去无效 finalize 调用
- M1 employee_metrics._percentile 改公有 percentile 供同包复用,删除
  api_assembly.percentile_nearest(存储层 _percentile 不同层保留)
- M2 drilldown 内联 SQL(FirstReplyFact/PhoneCall)下沉 api_assembly
  (fr_fact_rows/user_calls_page),路由层无裸查询
- M6 补 start>end→422 断言;新增 config_source=db 测试(激活配置
  min_days=9→五天数据员工翻 insufficient_sample,守护生效可见)

tests/test_management_api.py: 21 passed;全量 721 passed

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-09 18:59:45 +08:00
parent 31cd2d92aa
commit 3d5644c42a
4 changed files with 201 additions and 87 deletions
+75 -1
View File
@@ -14,6 +14,7 @@
生命周期判定(已删客户剔除)/call_followup 列表+期聚合摘要;
- metric-definitions:trial 标记/口径说明/config_source 回退。
"""
import copy
import datetime as dt
from zoneinfo import ZoneInfo
@@ -21,6 +22,7 @@ from app.db.models import (
Customer,
EmployeeCallDaily,
EmployeeMetricDaily,
EmployeeScoreConfig,
FirstReplyFact,
Message,
MsgDailyStats,
@@ -29,6 +31,7 @@ from app.db.models import (
WechatAccount,
WhatsappAccount,
)
from app.management.metric_definitions import DEFAULT_SCORE_CONFIG
_SH = ZoneInfo("Asia/Shanghai")
@@ -190,6 +193,9 @@ async def test_dashboard_span_and_calendar_422(make_client):
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):
@@ -343,9 +349,13 @@ async def test_employees_default_sort_and_paging(make_client):
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_band", "call_band"}
"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
@@ -436,6 +446,34 @@ async def test_employees_min_days_insufficient(make_client):
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
@@ -593,6 +631,42 @@ async def test_drilldown_lifecycle_customers(make_client):
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)