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