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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user