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
+11 -56
View File
@@ -21,13 +21,7 @@ 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.db.models import Staff, WechatAccount, WhatsappAccount
from app.management import api_assembly as asm
from app.management.anomalies import detect_anomalies
from app.management.employee_metrics import (
@@ -39,7 +33,6 @@ from app.management.employee_metrics import (
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"])
@@ -316,7 +309,7 @@ async def management_employees(
"total": total,
"page": page,
"page_size": page_size,
"rows": [asm.build_employee_row(u, d) for u, d in page_rows],
"rows": [asm.build_employee_row(u, d, config) 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),
@@ -479,35 +472,15 @@ async def management_employee_drilldown(
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,
total, items = await asm.fr_fact_rows(
session, owners, ch, start, end, page=page, page_size=page_size
)
return {"items": items, "total": total, "page": page,
"page_size": page_size, "scope": scope}
if metric_code == "unreplied":
if not owners: # 无账号→必空,免进上游决赛圈点查
return _paged([])
# 上游无私有 owner 过滤:一次拉全量决赛圈结果(候选 ≤500 时完整),
# 按 owner 账号过滤后内存分页;候选超限标 truncated(过滤可能不全)
res = await metrics_svc.unreplied(
@@ -533,27 +506,9 @@ async def management_employee_drilldown(
# 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]
total, items = await asm.user_calls_page(
session, user_id, start, end, page=page, page_size=page_size
)
summary_rows = await asm.call_daily_rows(session, start, end,
user_id=user_id)
summary = {col: sum(r[col] for r in summary_rows)