feat(analytics): B2 该回未回——尾部判定/有效类型/通话豁免/已删剔除

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-04 19:00:15 +08:00
parent 247bfc7277
commit 52af531598
3 changed files with 312 additions and 2 deletions
+148 -1
View File
@@ -14,7 +14,14 @@ from zoneinfo import ZoneInfo
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Customer, MsgDailyStats, PhoneCall, Staff, StatDailyStaff
from app.db.models import (
Customer,
Message,
MsgDailyStats,
PhoneCall,
Staff,
StatDailyStaff,
)
from app.storage.staff_summary import account_map
from app.storage.staff_summary import has_rows as _staff_rows
from app.storage.summary import day_bounds_ms
@@ -449,3 +456,143 @@ async def churn(
"active_days": d,
})
return {"items": items, "total": int(total), "page": page, "page_size": page_size}
_VALID_TAIL_TYPES = ("text", "image", "voice", "video", "file", "link")
def _norm_phone(p: str | None) -> str | None:
"""归一:+86/空格/横线剥掉(spec B1 同款规则)。"""
if not p:
return None
out = p.replace(" ", "").replace("-", "")
for pre in ("+86", "86"):
if out.startswith(pre) and len(out) > len(pre):
out = out[len(pre):]
break
return out or None
async def unreplied(
session: AsyncSession, *, channel: str, hours: int, page: int, page_size: int,
) -> dict:
"""B2 该回未回(spec §3.4)。候选=汇总表最新日行;决赛圈点查类型/预览/通话豁免。
候选资格看「最新日行」(row_number 先于方向/超时过滤——员工次日已回的
旧未回日不复活);决赛圈再点查尾部消息有效类型(排除 unknown/divider 等)、
剔除已删/拉黑客户、通话豁免(该客户 phone 任一晚于最后消息的通话=已回应,
不限定通话员工——客户被回应过即可,spec:尾部=max(最后消息,最后通话))。
total 口径:候选 ≤500 时决赛圈全过滤后分页(total 精确);>500 时按候选
分页(total=候选数,注释口径——决赛圈过滤可能让个别页少几行)。
"""
now = _now_ms()
cutoff = now - hours * 3600 * 1000
# 候选集:每 (channel,owner,peer) 最新日行(row_number 双方言:SQLite
# 3.25+/MySQL 8)。方向=客户 且 last_ts ≤ cutoff 的过滤作用在 rn==1 行上
rn = (
sa.func.row_number()
.over(
partition_by=(
MsgDailyStats.channel,
MsgDailyStats.owner_account_id,
MsgDailyStats.peer_id,
),
order_by=MsgDailyStats.day.desc(),
)
.label("rn")
)
sub = (
sa.select(
MsgDailyStats.owner_account_id.label("owner"),
MsgDailyStats.peer_id.label("peer"),
MsgDailyStats.last_ts.label("ts"),
MsgDailyStats.last_is_send.label("last_is_send"),
rn,
)
.where(
MsgDailyStats.channel == channel,
MsgDailyStats.peer_kind == "friend",
MsgDailyStats.last_ts.is_not(None),
)
).subquery()
cand = (
await session.execute(
sa.select(sub.c.owner, sub.c.peer, sub.c.ts)
.where(
sub.c.rn == 1,
sub.c.last_is_send.is_(False),
sub.c.ts <= cutoff,
)
.order_by(sub.c.ts.asc(), sub.c.peer.asc(), sub.c.owner.asc())
)
).all()
async def _finalist(owner: str, peer: str, ts: int) -> dict | None:
# 决赛圈点查:尾部消息类型+预览(走 ix_message_channel_owner_talker_time)
last_msg = (
await session.execute(
sa.select(Message).where(
Message.channel == channel,
Message.owner_account_id == owner,
Message.talker_id == peer,
sa.or_(Message.room_id.is_(None), Message.room_id == ""),
Message.timestamp.is_not(None),
).order_by(Message.timestamp.desc()).limit(1)
)
).scalar_one_or_none()
if last_msg is None or last_msg.type not in _VALID_TAIL_TYPES:
return None
cust = await session.get(Customer, peer)
if cust is not None and (
cust.delete_flag == 1 or cust.is_block is True
):
return None
# 通话豁免:该客户手机号有「晚于最后消息」的通话 → 已回应。
# customer 侧归一后与 call_number 精确等值——call_number 若带 +86
# 前缀变体则不中,v1 可接受(spec B1 同款规则)。
phone = _norm_phone(cust.phone if cust else None)
if phone:
call_ts = (
await session.execute(
sa.select(sa.func.max(PhoneCall.start_call_time)).where(
PhoneCall.call_number == phone,
)
)
).scalar_one_or_none()
if call_ts and call_ts > ts:
return None
text = (last_msg.text or "").replace("\n", " ")
return {
"peer_id": peer,
"channel": channel,
"owner_account_id": owner,
"last_ts": ts,
"wait_ms": now - ts,
"preview": text[:50],
"msg_id": last_msg.msg_id,
}
async def _finalize(rows: list[Any]) -> list[dict]:
# 顺序逐个 await——AsyncSession 不能并发执行查询(禁 asyncio.gather);
# 每页 ≤100 个点查、每个毫秒级,顺序总耗时百毫秒级可接受
out = []
for o, p, t in rows:
r = await _finalist(o, p, t)
if r is not None:
out.append(r)
return out
if len(cand) <= 500:
items = await _finalize(cand)
# keep 候选顺序(ts asc, peer asc)——顺序循环天然保序
total = len(items)
start = (page - 1) * page_size
return {"items": items[start : start + page_size], "total": total,
"page": page, "page_size": page_size}
# 大候选量:按候选分页,total=候选数(口径注释——个别页决赛圈过滤后少几行)
start = (page - 1) * page_size
items = await _finalize(cand[start : start + page_size])
return {"items": items, "total": len(cand), "page": page,
"page_size": page_size}