feat(analytics): 管理层指标呈现——首响回复率上墙/客户资产总览/自定义日期/排行五新列
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -43,23 +43,43 @@ def _day_date(day: int) -> dt.date:
|
|||||||
return dt.date(day // 10000, (day // 100) % 100, day % 100)
|
return dt.date(day // 10000, (day // 100) % 100, day % 100)
|
||||||
|
|
||||||
|
|
||||||
def scope_day_keys(scope: str, today: int) -> list[int]:
|
def scope_day_keys(
|
||||||
|
scope: str, today: int, start: int | None = None, end: int | None = None
|
||||||
|
) -> list[int]:
|
||||||
"""today={今};week=本周一..今天;month=本月1号..今天;last30=近30天
|
"""today={今};week=本周一..今天;month=本月1号..今天;last30=近30天
|
||||||
滚动窗(含端点)。last30 让排行/卡片命中任意有数据的月份(镜像停更期
|
滚动窗(含端点)。last30 让排行/卡片命中任意有数据的月份(镜像停更期
|
||||||
间日历月窗可能全空)。"""
|
间日历月窗可能全空)。
|
||||||
end = _day_date(today)
|
|
||||||
|
custom=显式 [start..end] yyyymmdd(C1):start≤end 且跨度≤366 天
|
||||||
|
(router 层已 422 校验,这里防御性同检——metrics 直调也拿到 ValueError
|
||||||
|
而非空窗/怪窗)。
|
||||||
|
"""
|
||||||
|
if scope == "custom":
|
||||||
|
if start is None or end is None:
|
||||||
|
raise ValueError("custom scope requires start and end")
|
||||||
|
s, e = _day_date(start), _day_date(end) # 非法日历值此处抛 ValueError
|
||||||
|
if s > e:
|
||||||
|
raise ValueError("custom scope requires start <= end")
|
||||||
|
if (e - s).days > 365: # 含端点共 366 天
|
||||||
|
raise ValueError("custom span must be <= 366 days")
|
||||||
|
out, cur = [], s
|
||||||
|
while cur <= e:
|
||||||
|
out.append(_date_key(cur))
|
||||||
|
cur += dt.timedelta(days=1)
|
||||||
|
return out
|
||||||
|
end_d = _day_date(today)
|
||||||
if scope == "today":
|
if scope == "today":
|
||||||
start = end
|
start_d = end_d
|
||||||
elif scope == "week":
|
elif scope == "week":
|
||||||
start = end - dt.timedelta(days=end.weekday()) # 周一=0
|
start_d = end_d - dt.timedelta(days=end_d.weekday()) # 周一=0
|
||||||
elif scope == "month":
|
elif scope == "month":
|
||||||
start = end.replace(day=1)
|
start_d = end_d.replace(day=1)
|
||||||
elif scope == "last30":
|
elif scope == "last30":
|
||||||
start = end - dt.timedelta(days=29)
|
start_d = end_d - dt.timedelta(days=29)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"bad scope: {scope!r}")
|
raise ValueError(f"bad scope: {scope!r}")
|
||||||
out, cur = [], start
|
out, cur = [], start_d
|
||||||
while cur <= end:
|
while cur <= end_d:
|
||||||
out.append(_date_key(cur))
|
out.append(_date_key(cur))
|
||||||
cur += dt.timedelta(days=1)
|
cur += dt.timedelta(days=1)
|
||||||
return out
|
return out
|
||||||
@@ -69,7 +89,8 @@ def _prev_scope_days(scope: str, days: list[int]) -> list[int]:
|
|||||||
"""同期对齐环比窗(与 days 等长逐日对齐;今日 scope 不调用)。
|
"""同期对齐环比窗(与 days 等长逐日对齐;今日 scope 不调用)。
|
||||||
|
|
||||||
week=各日-7天(本周一..今 vs 上周一..上周同日);month=上月同日
|
week=各日-7天(本周一..今 vs 上周一..上周同日);month=上月同日
|
||||||
(短月钳到上月末,如 3/31→2/28);last30=各日-30天(前一个滚动窗)。
|
(短月钳到上月末,如 3/31→2/28);last30=各日-30天(前一个滚动窗);
|
||||||
|
custom=各日-N 天(等长紧邻前窗,例外不做同期对齐)。
|
||||||
同期对齐而非滚动紧邻窗——后者周会跨周末、月取上月尾巴,管理者会
|
同期对齐而非滚动紧邻窗——后者周会跨周末、月取上月尾巴,管理者会
|
||||||
误读成「上周/上月」(spec §8)。
|
误读成「上周/上月」(spec §8)。
|
||||||
"""
|
"""
|
||||||
@@ -88,6 +109,11 @@ def _prev_scope_days(scope: str, days: list[int]) -> list[int]:
|
|||||||
return out
|
return out
|
||||||
if scope == "last30":
|
if scope == "last30":
|
||||||
return [_date_key(_day_date(d) - dt.timedelta(days=30)) for d in days]
|
return [_date_key(_day_date(d) - dt.timedelta(days=30)) for d in days]
|
||||||
|
if scope == "custom":
|
||||||
|
# C1 例外:等长紧邻前窗(各日 -N 天滚动)。custom 不做同期对齐——
|
||||||
|
# 任意起点无「周/月」节律可对,紧邻窗是最不易误读的对照。
|
||||||
|
n = len(days)
|
||||||
|
return [_date_key(_day_date(d) - dt.timedelta(days=n)) for d in days]
|
||||||
raise ValueError(f"bad scope: {scope!r}")
|
raise ValueError(f"bad scope: {scope!r}")
|
||||||
|
|
||||||
|
|
||||||
@@ -297,8 +323,9 @@ async def anomaly(session: AsyncSession, channel: str | None) -> dict | None:
|
|||||||
|
|
||||||
async def staff_ranking(
|
async def staff_ranking(
|
||||||
session: AsyncSession, *, channel: str | None, scope: str,
|
session: AsyncSession, *, channel: str | None, scope: str,
|
||||||
|
start: int | None = None, end: int | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""员工活跃排行(A2 核心子集,2026-09-07 管理者反馈补强)。
|
"""员工活跃排行(A2 核心子集,2026-09-07 管理者反馈补强;批2 五新列)。
|
||||||
|
|
||||||
口径:msg_total=stat_daily_staff 聚合(空表回退 msg_daily_stats+映射,
|
口径:msg_total=stat_daily_staff 聚合(空表回退 msg_daily_stats+映射,
|
||||||
同 _active_staff 模式);call_cnt=phone_call 按 user_id 落窗直查(3k 行
|
同 _active_staff 模式);call_cnt=phone_call 按 user_id 落窗直查(3k 行
|
||||||
@@ -306,6 +333,19 @@ async def staff_ranking(
|
|||||||
staff.name,无 staff 行/未映射账号回退 user_id 原值标 unmapped。
|
staff.name,无 staff 行/未映射账号回退 user_id 原值标 unmapped。
|
||||||
排序 msg_total desc + user_id asc 决胜。返回全部行(员工 ~41)。
|
排序 msg_total desc + user_id asc 决胜。返回全部行(员工 ~41)。
|
||||||
|
|
||||||
|
批2 新列(A3/A4/A5):
|
||||||
|
- fr_avg_ms=Σfr_sum_ms/Σthreads_answered(answered=0→None);
|
||||||
|
reply_rate=Σanswered/Σthreads(threads=0→None)——两路径同源
|
||||||
|
(rollup 三列 / 回退 msg_daily_stats 三列累加,含 room 行,与批1
|
||||||
|
管道口径一致);
|
||||||
|
- first_contact_cnt=该员工当期首沟通客户数:(owner,peer) 全史
|
||||||
|
MIN(day) 落窗的 distinct peer(映射到 user 后跨账号去重);
|
||||||
|
- call_seconds/answer_rate=phone_call 落窗 SUM(call_duration)(秒)
|
||||||
|
与 call_duration>0 占比(count=0→None);
|
||||||
|
- deleted/blocked_friends=customer 表按 user_id 归属的**全库**存量
|
||||||
|
delete_flag=1 / is_block 计数(与 churn handover 同归属口径;
|
||||||
|
不随窗——删粉是存量状态)。
|
||||||
|
|
||||||
channel=None(全平台):查询去渠道条件+映射合并,同 user 跨渠道量
|
channel=None(全平台):查询去渠道条件+映射合并,同 user 跨渠道量
|
||||||
自然合到一行(员工排行本就是 user 级)。
|
自然合到一行(员工排行本就是 user 级)。
|
||||||
|
|
||||||
@@ -315,12 +355,12 @@ async def staff_ranking(
|
|||||||
MAX 只是峰值,皆非「窗内活跃去重数」——rollup 只供 msg_total,
|
MAX 只是峰值,皆非「窗内活跃去重数」——rollup 只供 msg_total,
|
||||||
避免两路径语义分叉。
|
避免两路径语义分叉。
|
||||||
"""
|
"""
|
||||||
days = scope_day_keys(scope, today_key())
|
days = scope_day_keys(scope, today_key(), start, end)
|
||||||
amap = await _account_map(session, channel)
|
amap = await _account_map(session, channel)
|
||||||
|
|
||||||
# 活跃客户/群 + 回退 msg_total:落窗拉行,Python 侧映射后按 user 去重
|
# 活跃客户/群 + 回退 msg_total/首响三列:落窗拉行,Python 侧映射后按
|
||||||
# (COUNT(DISTINCT peer) 必须在「账号合并到员工」之后——同人多账号
|
# user 去重(COUNT(DISTINCT peer) 必须在「账号合并到员工」之后——同人
|
||||||
# 跨账号去重,写 SQL 反而绕且踩 only_full_group_by)
|
# 多账号跨账号去重,写 SQL 反而绕且踩 only_full_group_by)
|
||||||
conds = [MsgDailyStats.day.in_(days)]
|
conds = [MsgDailyStats.day.in_(days)]
|
||||||
if channel is not None:
|
if channel is not None:
|
||||||
conds.append(MsgDailyStats.channel == channel)
|
conds.append(MsgDailyStats.channel == channel)
|
||||||
@@ -332,12 +372,16 @@ async def staff_ranking(
|
|||||||
MsgDailyStats.peer_id,
|
MsgDailyStats.peer_id,
|
||||||
MsgDailyStats.send_cnt,
|
MsgDailyStats.send_cnt,
|
||||||
MsgDailyStats.recv_cnt,
|
MsgDailyStats.recv_cnt,
|
||||||
|
MsgDailyStats.cust_threads,
|
||||||
|
MsgDailyStats.threads_answered,
|
||||||
|
MsgDailyStats.fr_sum_ms,
|
||||||
).where(*conds)
|
).where(*conds)
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
act: dict[str, dict[str, set]] = {}
|
act: dict[str, dict[str, set]] = {}
|
||||||
msg_fallback: dict[str, int] = {}
|
msg_fallback: dict[str, int] = {}
|
||||||
for owner, kind, peer, s, r in rows:
|
th_fallback: dict[str, list[int]] = {}
|
||||||
|
for owner, kind, peer, s, r, t, a_ns, fr in rows:
|
||||||
uid = amap.get(owner, owner)
|
uid = amap.get(owner, owner)
|
||||||
a = act.setdefault(uid, {"friends": set(), "rooms": set()})
|
a = act.setdefault(uid, {"friends": set(), "rooms": set()})
|
||||||
if kind == "friend": # 显式分支:未来新 kind 跳过,不误入 rooms
|
if kind == "friend": # 显式分支:未来新 kind 跳过,不误入 rooms
|
||||||
@@ -345,8 +389,13 @@ async def staff_ranking(
|
|||||||
elif kind == "room":
|
elif kind == "room":
|
||||||
a["rooms"].add(peer)
|
a["rooms"].add(peer)
|
||||||
msg_fallback[uid] = msg_fallback.get(uid, 0) + int(s or 0) + int(r or 0)
|
msg_fallback[uid] = msg_fallback.get(uid, 0) + int(s or 0) + int(r or 0)
|
||||||
|
tf = th_fallback.setdefault(uid, [0, 0, 0])
|
||||||
|
tf[0] += int(t or 0)
|
||||||
|
tf[1] += int(a_ns or 0)
|
||||||
|
tf[2] += int(fr or 0)
|
||||||
|
|
||||||
use_rollup = await _staff_rows(session)
|
use_rollup = await _staff_rows(session)
|
||||||
|
th: dict[str, tuple[int, int, int]] = {}
|
||||||
if use_rollup:
|
if use_rollup:
|
||||||
conds = [StatDailyStaff.day.in_(days)]
|
conds = [StatDailyStaff.day.in_(days)]
|
||||||
if channel is not None:
|
if channel is not None:
|
||||||
@@ -361,23 +410,45 @@ async def staff_ranking(
|
|||||||
),
|
),
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
|
sa.func.coalesce(sa.func.sum(StatDailyStaff.cust_threads), 0),
|
||||||
|
sa.func.coalesce(
|
||||||
|
sa.func.sum(StatDailyStaff.threads_answered), 0
|
||||||
|
),
|
||||||
|
sa.func.coalesce(sa.func.sum(StatDailyStaff.fr_sum_ms), 0),
|
||||||
)
|
)
|
||||||
.where(*conds)
|
.where(*conds)
|
||||||
.group_by(StatDailyStaff.user_id)
|
.group_by(StatDailyStaff.user_id)
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
msg_total = {uid: int(v) for uid, v in rollup}
|
msg_total = {}
|
||||||
|
for uid, mv, t, a_ns, fr in rollup:
|
||||||
|
msg_total[uid] = int(mv)
|
||||||
|
th[uid] = (int(t), int(a_ns), int(fr))
|
||||||
else:
|
else:
|
||||||
msg_total = msg_fallback
|
msg_total = msg_fallback
|
||||||
|
th = {uid: (v[0], v[1], v[2]) for uid, v in th_fallback.items()}
|
||||||
|
|
||||||
# 通话:按 user_id 落窗直查(窗口换算同 _call_count 写法)
|
# 通话:按 user_id 落窗直查(窗口换算同 _call_count 写法)——量/时长/
|
||||||
|
# 接通率一次取回(call_duration>0 计为接通)
|
||||||
calls: dict[str, int] = {}
|
calls: dict[str, int] = {}
|
||||||
|
call_secs: dict[str, int] = {}
|
||||||
|
call_ans: dict[str, int] = {}
|
||||||
if days:
|
if days:
|
||||||
lo, _ = day_bounds_ms(days[0])
|
lo, _ = day_bounds_ms(days[0])
|
||||||
hi_start, _ = day_bounds_ms(days[-1])
|
hi_start, _ = day_bounds_ms(days[-1])
|
||||||
call_rows = (
|
call_rows = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
sa.select(PhoneCall.user_id, sa.func.count())
|
sa.select(
|
||||||
|
PhoneCall.user_id,
|
||||||
|
sa.func.count(),
|
||||||
|
sa.func.coalesce(sa.func.sum(PhoneCall.call_duration), 0),
|
||||||
|
sa.func.coalesce(
|
||||||
|
sa.func.sum(
|
||||||
|
sa.case((PhoneCall.call_duration > 0, 1), else_=0)
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
PhoneCall.user_id.is_not(None),
|
PhoneCall.user_id.is_not(None),
|
||||||
PhoneCall.start_call_time.is_not(None),
|
PhoneCall.start_call_time.is_not(None),
|
||||||
@@ -387,11 +458,61 @@ async def staff_ranking(
|
|||||||
.group_by(PhoneCall.user_id)
|
.group_by(PhoneCall.user_id)
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
calls = {uid: int(v) for uid, v in call_rows}
|
for uid, cnt, secs, ans in call_rows:
|
||||||
|
calls[uid] = int(cnt)
|
||||||
|
call_secs[uid] = int(secs)
|
||||||
|
call_ans[uid] = int(ans)
|
||||||
|
|
||||||
|
# 首沟通归属:peer 全史 MIN(day) 落窗 → 记到 (channel,)owner 映射后的
|
||||||
|
# user;distinct 在 user 级(同人多账号/客户换跟进人不重复计)
|
||||||
|
fc_conds = [MsgDailyStats.peer_kind == "friend"]
|
||||||
|
if channel is not None:
|
||||||
|
fc_conds.append(MsgDailyStats.channel == channel)
|
||||||
|
fc_rows = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(
|
||||||
|
MsgDailyStats.owner_account_id,
|
||||||
|
MsgDailyStats.peer_id,
|
||||||
|
sa.func.min(MsgDailyStats.day),
|
||||||
|
)
|
||||||
|
.where(*fc_conds)
|
||||||
|
.group_by(MsgDailyStats.owner_account_id, MsgDailyStats.peer_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
dayset = set(days)
|
||||||
|
first_contacts: dict[str, set[str]] = {}
|
||||||
|
for owner, peer, first in fc_rows:
|
||||||
|
if first in dayset:
|
||||||
|
first_contacts.setdefault(amap.get(owner, owner), set()).add(peer)
|
||||||
|
|
||||||
|
# 删粉/拉黑:customer 全库存量按 user_id 归属(NULL user_id 跳过——
|
||||||
|
# 排行是员工视图,无归属客户进 overview 的 customer_assets)
|
||||||
|
del_conds = [Customer.user_id.is_not(None)]
|
||||||
|
if channel is not None:
|
||||||
|
del_conds.append(Customer.channel == channel)
|
||||||
|
del_rows = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(
|
||||||
|
Customer.user_id,
|
||||||
|
sa.func.coalesce(
|
||||||
|
sa.func.sum(sa.case((Customer.delete_flag == 1, 1), else_=0)),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
sa.func.coalesce(
|
||||||
|
sa.func.sum(sa.case((Customer.is_block.is_(True), 1), else_=0)),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(*del_conds)
|
||||||
|
.group_by(Customer.user_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
deleted = {uid: int(d) for uid, d, _b in del_rows}
|
||||||
|
blocked = {uid: int(b) for uid, _d, b in del_rows}
|
||||||
|
|
||||||
# 环比:同期对齐前窗(仅周/月;今日 vs 昨日全天会误导,与 overview 同口径)
|
# 环比:同期对齐前窗(仅周/月;今日 vs 昨日全天会误导,与 overview 同口径)
|
||||||
prev_total: dict[str, int] = {}
|
prev_total: dict[str, int] = {}
|
||||||
if scope in ("week", "month", "last30"):
|
if scope in ("week", "month", "last30", "custom"):
|
||||||
prev_days = _prev_scope_days(scope, days)
|
prev_days = _prev_scope_days(scope, days)
|
||||||
if use_rollup:
|
if use_rollup:
|
||||||
conds = [StatDailyStaff.day.in_(prev_days)]
|
conds = [StatDailyStaff.day.in_(prev_days)]
|
||||||
@@ -449,6 +570,8 @@ async def staff_ranking(
|
|||||||
for uid in sorted(set(msg_total) | set(act) | set(calls),
|
for uid in sorted(set(msg_total) | set(act) | set(calls),
|
||||||
key=lambda u: (-msg_total.get(u, 0), u)):
|
key=lambda u: (-msg_total.get(u, 0), u)):
|
||||||
cur, prev = msg_total.get(uid, 0), prev_total.get(uid, 0)
|
cur, prev = msg_total.get(uid, 0), prev_total.get(uid, 0)
|
||||||
|
t, a_ns, fr = th.get(uid, (0, 0, 0))
|
||||||
|
cnt = calls.get(uid, 0)
|
||||||
out.append({
|
out.append({
|
||||||
"user_id": uid,
|
"user_id": uid,
|
||||||
"name": staff_names.get(uid) or uid,
|
"name": staff_names.get(uid) or uid,
|
||||||
@@ -456,8 +579,15 @@ async def staff_ranking(
|
|||||||
"msg_total": cur,
|
"msg_total": cur,
|
||||||
"active_friends": len(act[uid]["friends"]) if uid in act else 0,
|
"active_friends": len(act[uid]["friends"]) if uid in act else 0,
|
||||||
"active_rooms": len(act[uid]["rooms"]) if uid in act else 0,
|
"active_rooms": len(act[uid]["rooms"]) if uid in act else 0,
|
||||||
"call_cnt": calls.get(uid, 0),
|
"call_cnt": cnt,
|
||||||
"msg_delta": round((cur - prev) / prev, 3) if cur and prev else None,
|
"msg_delta": round((cur - prev) / prev, 3) if cur and prev else None,
|
||||||
|
"fr_avg_ms": round(fr / a_ns) if a_ns else None,
|
||||||
|
"reply_rate": round(a_ns / t, 4) if t else None,
|
||||||
|
"first_contact_cnt": len(first_contacts.get(uid, ())),
|
||||||
|
"call_seconds": call_secs.get(uid, 0),
|
||||||
|
"answer_rate": round(call_ans[uid] / cnt, 4) if cnt else None,
|
||||||
|
"deleted_friends": deleted.get(uid, 0),
|
||||||
|
"blocked_friends": blocked.get(uid, 0),
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -485,11 +615,62 @@ async def action_counts(session: AsyncSession, *, channel: str | None) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _customer_assets(session: AsyncSession) -> dict:
|
||||||
|
"""B1 客户资产总览:全库存量快照,不随 scope/channel(分层/删粉是存量
|
||||||
|
状态,与筛选无关)。
|
||||||
|
|
||||||
|
剔除 delete_flag=1 后按 last_chat_time 分层(边界与 churn real 同式:
|
||||||
|
>now-Nd 为近 N 天):active30=≤30 天;silent30_90=(30,90] 天前;
|
||||||
|
sleeping90p=≥90 天前;从未激活(last_chat_time NULL,云客没聊过/新加)
|
||||||
|
单列 never。渠道计数与已删好友单列。customer ~12k 行单条聚合直查。
|
||||||
|
"""
|
||||||
|
day_ms = 86_400_000
|
||||||
|
now = _now_ms()
|
||||||
|
not_deleted = sa.or_(Customer.delete_flag.is_(None), Customer.delete_flag == 0)
|
||||||
|
lt = Customer.last_chat_time
|
||||||
|
|
||||||
|
def _n(cond: Any) -> int:
|
||||||
|
return sa.func.coalesce(sa.func.sum(sa.case((cond, 1), else_=0)), 0)
|
||||||
|
|
||||||
|
row = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(
|
||||||
|
_n(sa.and_(not_deleted, lt.is_(None))),
|
||||||
|
_n(sa.and_(not_deleted, lt.is_not(None), lt > now - 30 * day_ms)),
|
||||||
|
_n(sa.and_(not_deleted, lt.is_not(None),
|
||||||
|
lt <= now - 30 * day_ms, lt > now - 90 * day_ms)),
|
||||||
|
_n(sa.and_(not_deleted, lt.is_not(None), lt <= now - 90 * day_ms)),
|
||||||
|
_n(sa.and_(not_deleted, Customer.channel == "wechat")),
|
||||||
|
_n(sa.and_(not_deleted, Customer.channel == "whatsapp")),
|
||||||
|
_n(Customer.delete_flag == 1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
never, active30, silent, sleeping, wechat, whatsapp, deleted = (
|
||||||
|
int(v) for v in row
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"total": never + active30 + silent + sleeping,
|
||||||
|
"active30": active30,
|
||||||
|
"silent30_90": silent,
|
||||||
|
"sleeping90p": sleeping,
|
||||||
|
"never": never,
|
||||||
|
"wechat": wechat,
|
||||||
|
"whatsapp": whatsapp,
|
||||||
|
"deleted": deleted,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def overview(
|
async def overview(
|
||||||
session: AsyncSession, *, channel: str | None, scope: str, trend_days: int = 30
|
session: AsyncSession, *, channel: str | None, scope: str, trend_days: int = 30,
|
||||||
|
start: int | None = None, end: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""A1:卡片 + 环比(仅周/月,同期对齐) + 近 N 天趋势 + 异常警示。
|
"""A1:卡片 + 环比(仅周/月,同期对齐) + 近 N 天趋势 + 异常警示。
|
||||||
|
|
||||||
|
批2:C1 自定义日期(scope=custom + start/end yyyymmdd,环比=等长紧邻
|
||||||
|
前窗滚动,不做同期对齐——见 _prev_scope_days);B1 customer_assets
|
||||||
|
客户资产总览(全库存量,不随 scope/channel)。
|
||||||
|
|
||||||
channel=None(全平台,看板 v2 默认):所有指标跨渠道合并——msg/活跃客户
|
channel=None(全平台,看板 v2 默认):所有指标跨渠道合并——msg/活跃客户
|
||||||
直接合计(peer_id 跨渠道不碰撞);活跃员工按 user 去重(跨渠道重复计数
|
直接合计(peer_id 跨渠道不碰撞);活跃员工按 user 去重(跨渠道重复计数
|
||||||
不拆);通话量本就是 call-type 无渠道语义。此时追加:
|
不拆);通话量本就是 call-type 无渠道语义。此时追加:
|
||||||
@@ -502,7 +683,7 @@ async def overview(
|
|||||||
if trend_days < 1 or trend_days > 90:
|
if trend_days < 1 or trend_days > 90:
|
||||||
raise ValueError("trend_days must be 1..90")
|
raise ValueError("trend_days must be 1..90")
|
||||||
today = today_key()
|
today = today_key()
|
||||||
days = scope_day_keys(scope, today)
|
days = scope_day_keys(scope, today, start, end)
|
||||||
rows = await _day_rows(session, channel, days)
|
rows = await _day_rows(session, channel, days)
|
||||||
msg_total = sum(int(s or 0) + int(r or 0) for _d, _k, _p, s, r in rows)
|
msg_total = sum(int(s or 0) + int(r or 0) for _d, _k, _p, s, r in rows)
|
||||||
active_customers = len({p for _d, kind, p, _s, _r in rows if kind == "friend"})
|
active_customers = len({p for _d, kind, p, _s, _r in rows if kind == "friend"})
|
||||||
@@ -517,8 +698,9 @@ async def overview(
|
|||||||
"first_contact": await _first_contact(session, channel, days),
|
"first_contact": await _first_contact(session, channel, days),
|
||||||
}
|
}
|
||||||
|
|
||||||
# 环比仅周/月,同期对齐(今日 vs 昨日全天会误导,spec §8)
|
# 环比仅周/月,同期对齐(今日 vs 昨日全天会误导,spec §8);
|
||||||
if scope in ("week", "month", "last30"):
|
# custom=等长紧邻前窗滚动(例外,见 _prev_scope_days)
|
||||||
|
if scope in ("week", "month", "last30", "custom"):
|
||||||
prev_days = _prev_scope_days(scope, days)
|
prev_days = _prev_scope_days(scope, days)
|
||||||
prev_rows = await _day_rows(session, channel, prev_days)
|
prev_rows = await _day_rows(session, channel, prev_days)
|
||||||
prev_total = sum(int(s or 0) + int(r or 0) for _d, _k, _p, s, r in prev_rows)
|
prev_total = sum(int(s or 0) + int(r or 0) for _d, _k, _p, s, r in prev_rows)
|
||||||
@@ -528,10 +710,11 @@ async def overview(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 趋势:近 trend_days 天 每日 消息量/活跃客户/通话量
|
# 趋势:近 trend_days 天 每日 消息量/活跃客户/通话量
|
||||||
end = _day_date(today)
|
# (t_end 不复用 end 参数名——那是 custom scope 的 yyyymmdd 入参)
|
||||||
|
t_end = _day_date(today)
|
||||||
t_days = []
|
t_days = []
|
||||||
cur = end - dt.timedelta(days=trend_days - 1)
|
cur = t_end - dt.timedelta(days=trend_days - 1)
|
||||||
while cur <= end:
|
while cur <= t_end:
|
||||||
t_days.append(_date_key(cur))
|
t_days.append(_date_key(cur))
|
||||||
cur += dt.timedelta(days=1)
|
cur += dt.timedelta(days=1)
|
||||||
# 通话趋势:phone_call 拉窗内 (start_call_time) Python 侧按日分桶(3k 行)
|
# 通话趋势:phone_call 拉窗内 (start_call_time) Python 侧按日分桶(3k 行)
|
||||||
@@ -571,9 +754,12 @@ async def overview(
|
|||||||
"channel": "all" if channel is None else channel,
|
"channel": "all" if channel is None else channel,
|
||||||
"days": days,
|
"days": days,
|
||||||
"cards": cards,
|
"cards": cards,
|
||||||
|
"customer_assets": await _customer_assets(session),
|
||||||
"trend": trend,
|
"trend": trend,
|
||||||
"anomaly": await anomaly(session, channel),
|
"anomaly": await anomaly(session, channel),
|
||||||
"staff_ranking": await staff_ranking(session, channel=channel, scope=scope),
|
"staff_ranking": await staff_ranking(
|
||||||
|
session, channel=channel, scope=scope, start=start, end=end
|
||||||
|
),
|
||||||
"actions": await action_counts(session, channel=channel),
|
"actions": await action_counts(session, channel=channel),
|
||||||
}
|
}
|
||||||
if channel is None:
|
if channel is None:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""GET /api/analytics/* —— 数据分析板块端组(前缀分组=未来角色权限落点)。"""
|
"""GET /api/analytics/* —— 数据分析板块端组(前缀分组=未来角色权限落点)。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -21,7 +22,11 @@ ChannelParam = Annotated[Literal["wechat", "whatsapp"], Query()]
|
|||||||
# 看板 v2(2026-09-07):overview 默认全平台——"all" 在 router 层翻译成
|
# 看板 v2(2026-09-07):overview 默认全平台——"all" 在 router 层翻译成
|
||||||
# metrics 的 channel=None,不把哨兵字符串传进 SQL
|
# metrics 的 channel=None,不把哨兵字符串传进 SQL
|
||||||
AllChannelParam = Annotated[Literal["all", "wechat", "whatsapp"], Query()]
|
AllChannelParam = Annotated[Literal["all", "wechat", "whatsapp"], Query()]
|
||||||
ScopeParam = Annotated[Literal["today", "week", "month", "last30"], Query()]
|
# C1(批2):custom=自定义日期范围,配合 start/end yyyymmdd
|
||||||
|
ScopeParam = Annotated[
|
||||||
|
Literal["today", "week", "month", "last30", "custom"], Query()
|
||||||
|
]
|
||||||
|
YmdParam = Annotated[int, Query(ge=20000101, le=29991231)]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health")
|
@router.get("/health")
|
||||||
@@ -36,6 +41,8 @@ async def analytics_overview(
|
|||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
channel: AllChannelParam = "all",
|
channel: AllChannelParam = "all",
|
||||||
scope: ScopeParam = "today",
|
scope: ScopeParam = "today",
|
||||||
|
start: YmdParam | None = None,
|
||||||
|
end: YmdParam | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""A1 经营总览:卡片+趋势+异常警示一次返回(单往返)。
|
"""A1 经营总览:卡片+趋势+异常警示一次返回(单往返)。
|
||||||
|
|
||||||
@@ -43,9 +50,27 @@ async def analytics_overview(
|
|||||||
双渠道趋势序列;单渠道行为不变。
|
双渠道趋势序列;单渠道行为不变。
|
||||||
cards 不含 data_as_of——数据截至走 /health(data_as_of 为 ms-epoch int
|
cards 不含 data_as_of——数据截至走 /health(data_as_of 为 ms-epoch int
|
||||||
与 entities[].last_seen 为 ISO 字符串的双格式)。
|
与 entities[].last_seen 为 ISO 字符串的双格式)。
|
||||||
|
|
||||||
|
scope=custom(C1):start/end=yyyymmdd(缺一/日历非法/Start>End/跨度
|
||||||
|
>366 天皆 422);环比=等长紧邻前窗滚动(不做同期对齐)。
|
||||||
"""
|
"""
|
||||||
|
s = e = None
|
||||||
|
if scope == "custom":
|
||||||
|
if start is None or end is None:
|
||||||
|
raise HTTPException(422, "scope=custom requires start and end")
|
||||||
|
try:
|
||||||
|
sd = dt.date(start // 10000, (start // 100) % 100, start % 100)
|
||||||
|
ed = dt.date(end // 10000, (end // 100) % 100, end % 100)
|
||||||
|
except ValueError as exc: # 如 20260230——Query 只拦范围不拦日历
|
||||||
|
raise HTTPException(422, "invalid start/end calendar date") from exc
|
||||||
|
if sd > ed:
|
||||||
|
raise HTTPException(422, "start must be <= end")
|
||||||
|
if (ed - sd).days > 365: # 含端点共 366 天
|
||||||
|
raise HTTPException(422, "custom span must be <= 366 days")
|
||||||
|
s, e = start, end
|
||||||
return await metrics_svc.overview(
|
return await metrics_svc.overview(
|
||||||
session, channel=None if channel == "all" else channel, scope=scope
|
session, channel=None if channel == "all" else channel, scope=scope,
|
||||||
|
start=s, end=e,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -147,3 +147,51 @@ async def test_overview_unmapped_rollup(make_client, monkeypatch):
|
|||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["cards"]["active_staff"] == 2
|
assert body["cards"]["active_staff"] == 2
|
||||||
assert body["cards"]["unmapped_accounts"] == 1
|
assert body["cards"]["unmapped_accounts"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_overview_custom_scope_api(make_client, monkeypatch):
|
||||||
|
"""C1 自定义日期:正常窗 200 + customer_assets 恒在;缺参/start>end/
|
||||||
|
跨度超 366 天/非法日历 全 422;闰年整 366 天放行。"""
|
||||||
|
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
|
||||||
|
|
||||||
|
async def seed(session):
|
||||||
|
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||||
|
peer_kind="friend", peer_id="t1", day=20260815,
|
||||||
|
send_cnt=10, recv_cnt=0, last_ts=1786000000000,
|
||||||
|
last_is_send=True))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/analytics/overview", params={
|
||||||
|
"channel": "wechat", "scope": "custom",
|
||||||
|
"start": 20260801, "end": 20260827,
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["scope"] == "custom"
|
||||||
|
assert len(body["days"]) == 27
|
||||||
|
assert body["cards"]["msg_total"] == 10
|
||||||
|
# B1 客户资产总览恒在(全库快照,不随 scope)
|
||||||
|
assert body["customer_assets"]["total"] >= 0
|
||||||
|
|
||||||
|
# 缺 start/end → 422(不让静默落到空窗)
|
||||||
|
r2 = await client.get("/api/analytics/overview",
|
||||||
|
params={"scope": "custom"})
|
||||||
|
assert r2.status_code == 422
|
||||||
|
# start > end → 422
|
||||||
|
r3 = await client.get("/api/analytics/overview", params={
|
||||||
|
"scope": "custom", "start": 20260827, "end": 20260801})
|
||||||
|
assert r3.status_code == 422
|
||||||
|
# 跨度 367 天 → 422(2024-01-01..2025-01-01 含端点 367 天)
|
||||||
|
r4 = await client.get("/api/analytics/overview", params={
|
||||||
|
"scope": "custom", "start": 20240101, "end": 20250101})
|
||||||
|
assert r4.status_code == 422
|
||||||
|
# 非法日历(2 月 30)→ 422——Query ge/le 只拦数值范围不拦日历
|
||||||
|
r5 = await client.get("/api/analytics/overview", params={
|
||||||
|
"scope": "custom", "start": 20260230, "end": 20260301})
|
||||||
|
assert r5.status_code == 422
|
||||||
|
# 闰年整年 366 天(含端点)→ 放行
|
||||||
|
r6 = await client.get("/api/analytics/overview", params={
|
||||||
|
"scope": "custom", "start": 20240101, "end": 20241231})
|
||||||
|
assert r6.status_code == 200
|
||||||
|
assert len(r6.json()["days"]) == 366
|
||||||
|
|||||||
@@ -280,27 +280,37 @@ async def test_staff_ranking(make_client, monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
async def test_staff_ranking_fallback_when_rollup_empty(make_client, monkeypatch):
|
async def test_staff_ranking_fallback_when_rollup_empty(make_client, monkeypatch):
|
||||||
"""stat_daily_staff 空表 → msg_total 回退 msg_daily_stats 聚合(部署即用)。"""
|
"""stat_daily_staff 空表 → msg_total/首响三列回退 msg_daily_stats 聚合(部署即用)。"""
|
||||||
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
|
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
|
||||||
async def seed(session):
|
async def seed(session):
|
||||||
session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三"))
|
session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三"))
|
||||||
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||||
peer_kind="friend", peer_id="t1", day=20260904,
|
peer_kind="friend", peer_id="t1", day=20260904,
|
||||||
send_cnt=10, recv_cnt=5, last_ts=1788500000000,
|
send_cnt=10, recv_cnt=5, last_ts=1788500000000,
|
||||||
last_is_send=True))
|
last_is_send=True,
|
||||||
|
cust_threads=2, threads_answered=1,
|
||||||
|
fr_sum_ms=60000))
|
||||||
# wx9 无账号行 → 映射不上按 owner 原值计入并标未映射
|
# wx9 无账号行 → 映射不上按 owner 原值计入并标未映射
|
||||||
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx9",
|
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx9",
|
||||||
peer_kind="friend", peer_id="t2", day=20260904,
|
peer_kind="friend", peer_id="t2", day=20260904,
|
||||||
send_cnt=2, recv_cnt=0, last_ts=1788500000000,
|
send_cnt=2, recv_cnt=0, last_ts=1788500000000,
|
||||||
last_is_send=True))
|
last_is_send=True,
|
||||||
|
cust_threads=3, threads_answered=0,
|
||||||
|
fr_sum_ms=0))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
out = await metrics.staff_ranking(session, channel="wechat", scope="today")
|
out = await metrics.staff_ranking(session, channel="wechat", scope="today")
|
||||||
assert [r["user_id"] for r in out] == ["u1", "wx9"]
|
assert [r["user_id"] for r in out] == ["u1", "wx9"]
|
||||||
assert out[0]["msg_total"] == 15 and out[0]["active_friends"] == 1
|
assert out[0]["msg_total"] == 15 and out[0]["active_friends"] == 1
|
||||||
assert out[0]["unmapped"] is False
|
assert out[0]["unmapped"] is False
|
||||||
assert out[0]["msg_delta"] is None # 今日 scope 无环比
|
assert out[0]["msg_delta"] is None # 今日 scope 无环比
|
||||||
|
# 回退路径首响三列:60000/1=60000;回复率 1/2
|
||||||
|
assert out[0]["fr_avg_ms"] == 60000
|
||||||
|
assert out[0]["reply_rate"] == 0.5
|
||||||
assert out[1]["msg_total"] == 2 and out[1]["unmapped"] is True
|
assert out[1]["msg_total"] == 2 and out[1]["unmapped"] is True
|
||||||
assert out[1]["name"] == "wx9"
|
assert out[1]["name"] == "wx9"
|
||||||
|
# threads>0 answered=0 → 回复率 0.0(有串无回);首响 None(无样本)
|
||||||
|
assert out[1]["reply_rate"] == 0.0
|
||||||
|
assert out[1]["fr_avg_ms"] is None
|
||||||
|
|
||||||
await make_client(seed)
|
await make_client(seed)
|
||||||
|
|
||||||
@@ -425,3 +435,145 @@ async def test_action_counts_all(make_client, monkeypatch):
|
|||||||
"sensitive_pending": 2}
|
"sensitive_pending": 2}
|
||||||
|
|
||||||
await make_client(seed)
|
await make_client(seed)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 批2(2026-09-07):排行五新列 / B1 客户资产 / C1 自定义日期 ──────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_staff_ranking_batch2_columns(make_client, monkeypatch):
|
||||||
|
"""排行五新列:首响均值+回复率(rollup 三列)、首沟通归属、通话时长+
|
||||||
|
接通率、删粉/拉黑(全库存量按 user_id 归属,不随窗)。"""
|
||||||
|
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
|
||||||
|
async def seed(session):
|
||||||
|
session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三"))
|
||||||
|
session.add(WechatAccount(wx_id="wx2", user_id="u2", user_name="李四"))
|
||||||
|
session.add(Staff(user_id="u1", name="张三"))
|
||||||
|
session.add(Staff(user_id="u2", name="李四"))
|
||||||
|
# rollup 当窗行:u1 threads=10 answered=4 fr=1_200_000(→300000ms/0.4);
|
||||||
|
# u2 三列缺省 0 → 两率皆 None
|
||||||
|
session.add(StatDailyStaff(day=20260904, channel="wechat", user_id="u1",
|
||||||
|
msg_sent=20, msg_recv=0, active_friends=1,
|
||||||
|
active_rooms=0, cust_threads=10,
|
||||||
|
threads_answered=4, fr_sum_ms=1_200_000))
|
||||||
|
session.add(StatDailyStaff(day=20260904, channel="wechat", user_id="u2",
|
||||||
|
msg_sent=5, msg_recv=0, active_friends=1,
|
||||||
|
active_rooms=0))
|
||||||
|
# 首沟通:f1 全史 MIN=20260831 落窗(计);f2 MIN=20260801 窗外(不计);
|
||||||
|
# r1 是 room 不计;f3 归 wx2→u2(计)
|
||||||
|
for day, kind, peer, owner in [
|
||||||
|
(20260831, "friend", "f1", "wx1"),
|
||||||
|
(20260902, "friend", "f1", "wx1"), # 同 peer 次日不影响 MIN
|
||||||
|
(20260801, "friend", "f2", "wx1"),
|
||||||
|
(20260903, "room", "r1", "wx1"),
|
||||||
|
(20260903, "friend", "f3", "wx2"),
|
||||||
|
]:
|
||||||
|
session.add(MsgDailyStats(channel="wechat", owner_account_id=owner,
|
||||||
|
peer_kind=kind, peer_id=peer, day=day,
|
||||||
|
send_cnt=1, recv_cnt=0,
|
||||||
|
last_ts=1788500000000, last_is_send=True))
|
||||||
|
# 通话:u1 三通落窗(60/0/120s → 180s、2/3 接通);u2 通话在 2025 年
|
||||||
|
for cid, uid, ts, dur in [
|
||||||
|
("c1", "u1", 1788500000000, 60),
|
||||||
|
("c2", "u1", 1788500001000, 0),
|
||||||
|
("c3", "u1", 1788500002000, 120),
|
||||||
|
("c4", "u2", 1756950000000, 60),
|
||||||
|
]:
|
||||||
|
session.add(PhoneCall(call_id=cid, channel="voice_call", user_id=uid,
|
||||||
|
start_call_time=ts, call_duration=dur))
|
||||||
|
# 客户归属存量:u1 删 1 拉黑 1;u2 双属性各 1;无归属(user_id NULL)跳过
|
||||||
|
session.add(Customer(friend_id="d1", channel="wechat", user_id="u1",
|
||||||
|
delete_flag=1))
|
||||||
|
session.add(Customer(friend_id="b1", channel="wechat", user_id="u1",
|
||||||
|
is_block=True))
|
||||||
|
session.add(Customer(friend_id="x1", channel="wechat", user_id="u2",
|
||||||
|
delete_flag=1, is_block=True))
|
||||||
|
session.add(Customer(friend_id="n1", channel="wechat", user_id=None))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
out = await metrics.staff_ranking(session, channel="wechat", scope="week")
|
||||||
|
by = {r["user_id"]: r for r in out}
|
||||||
|
r1, r2 = by["u1"], by["u2"]
|
||||||
|
assert r1["fr_avg_ms"] == 300000 # 1_200_000 / 4
|
||||||
|
assert r1["reply_rate"] == 0.4
|
||||||
|
assert r1["first_contact_cnt"] == 1 # f1;f2 窗外、r1 群不计
|
||||||
|
assert r1["call_seconds"] == 180
|
||||||
|
assert r1["answer_rate"] == round(2 / 3, 4)
|
||||||
|
assert r1["deleted_friends"] == 1 and r1["blocked_friends"] == 1
|
||||||
|
assert r2["fr_avg_ms"] is None and r2["reply_rate"] is None
|
||||||
|
assert r2["first_contact_cnt"] == 1 # f3
|
||||||
|
assert r2["call_seconds"] == 0 and r2["answer_rate"] is None # 窗外通话
|
||||||
|
assert r2["deleted_friends"] == 1 and r2["blocked_friends"] == 1
|
||||||
|
|
||||||
|
await make_client(seed)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_customer_assets_layers(make_client, monkeypatch):
|
||||||
|
"""B1:剔除已删后按 last_chat_time 分层(从未激活单列 never);渠道计数;
|
||||||
|
已删单列。全库口径——channel=wechat 也不滤 whatsapp 客户。"""
|
||||||
|
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
|
||||||
|
monkeypatch.setattr(metrics, "_now_ms", lambda: 1788550000000)
|
||||||
|
async def seed(session):
|
||||||
|
now = 1788550000000
|
||||||
|
day_ms = 86_400_000
|
||||||
|
rows = [
|
||||||
|
("a1", "wechat", now - 5 * day_ms, 0), # active
|
||||||
|
("a2", "whatsapp", now - 29 * day_ms, 0), # active(边界内)
|
||||||
|
("s1", "whatsapp", now - 45 * day_ms, 0), # silent
|
||||||
|
("p1", "wechat", now - 91 * day_ms, 0), # sleeping
|
||||||
|
("n1", "whatsapp", None, 0), # never
|
||||||
|
("n2", "wechat", None, None), # never(delete_flag NULL 同未删)
|
||||||
|
]
|
||||||
|
for fid, ch, lt, df in rows:
|
||||||
|
session.add(Customer(friend_id=fid, channel=ch, last_chat_time=lt,
|
||||||
|
delete_flag=df))
|
||||||
|
# 已删不进分层、单列(deleted)
|
||||||
|
session.add(Customer(friend_id="d1", channel="wechat",
|
||||||
|
last_chat_time=now - 1 * day_ms, delete_flag=1))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
out = await metrics.overview(session, channel="wechat", scope="today")
|
||||||
|
assert out["customer_assets"] == {
|
||||||
|
"total": 6, "active30": 2, "silent30_90": 1, "sleeping90p": 1,
|
||||||
|
"never": 2, "wechat": 3, "whatsapp": 3, "deleted": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
await make_client(seed)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_overview_custom_scope(make_client, monkeypatch):
|
||||||
|
"""C1:custom days=[start..end](含端点);环比=等长紧邻前窗滚动(各日 -N);
|
||||||
|
metrics 直调的参数防御(缺参/逆序/超 366 天)。"""
|
||||||
|
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
|
||||||
|
async def seed(session):
|
||||||
|
# 窗内 20260801..20260827:t1 10 条;紧邻前窗 20260705..20260731:t1 20 条
|
||||||
|
for day, sent in [(20260815, 10), (20260710, 20)]:
|
||||||
|
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||||
|
peer_kind="friend", peer_id="t1", day=day,
|
||||||
|
send_cnt=sent, recv_cnt=0,
|
||||||
|
last_ts=1786000000000, last_is_send=True))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
out = await metrics.overview(session, channel="wechat", scope="custom",
|
||||||
|
start=20260801, end=20260827)
|
||||||
|
assert out["days"][0] == 20260801 and out["days"][-1] == 20260827
|
||||||
|
assert len(out["days"]) == 27
|
||||||
|
assert out["cards"]["msg_total"] == 10
|
||||||
|
assert out["cards"]["prev_msg_total"] == 20 # 紧邻前窗命中 07-10 行
|
||||||
|
assert out["cards"]["msg_total_delta"] == -0.5
|
||||||
|
# 排行吃同一窗(回退路径):wx1 未映射按原值出列
|
||||||
|
assert out["staff_ranking"][0]["user_id"] == "wx1"
|
||||||
|
assert out["staff_ranking"][0]["msg_total"] == 10
|
||||||
|
# 紧邻前窗逐日 -27:08-01→07-05、08-27→07-31
|
||||||
|
prev = metrics._prev_scope_days("custom", out["days"])
|
||||||
|
assert (len(prev), prev[0], prev[-1]) == (27, 20260705, 20260731)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await metrics.overview(session, channel="wechat", scope="custom")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await metrics.overview(session, channel="wechat", scope="custom",
|
||||||
|
start=20260827, end=20260801)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await metrics.overview(session, channel="wechat", scope="custom",
|
||||||
|
start=20240101, end=20250101) # 367 天
|
||||||
|
|
||||||
|
await make_client(seed)
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { getJson, requestJson } from './client'
|
|||||||
import type { Channel, Page } from './types'
|
import type { Channel, Page } from './types'
|
||||||
|
|
||||||
export type { Channel }
|
export type { Channel }
|
||||||
export type Scope = 'today' | 'week' | 'month' | 'last30'
|
// 批2(2026-09-07):'custom'=自定义日期范围,须配合 start/end yyyymmdd
|
||||||
|
export type Scope = 'today' | 'week' | 'month' | 'last30' | 'custom'
|
||||||
// 看板 v2(2026-09-07):overview 渠道参数支持 'all'=全平台(后端译成
|
// 看板 v2(2026-09-07):overview 渠道参数支持 'all'=全平台(后端译成
|
||||||
// channel=None);列表页(churn/unreplied)仍是单渠道,沿用 types.ts 的 Channel
|
// channel=None);列表页(churn/unreplied)仍是单渠道,沿用 types.ts 的 Channel
|
||||||
export type DashboardChannel = 'all' | Channel
|
export type DashboardChannel = 'all' | Channel
|
||||||
@@ -19,15 +20,35 @@ export interface OverviewCards {
|
|||||||
prev_msg_total?: number
|
prev_msg_total?: number
|
||||||
msg_total_delta?: number | null
|
msg_total_delta?: number | null
|
||||||
}
|
}
|
||||||
|
// B1 客户资产总览:全库存量快照,不随 scope/channel
|
||||||
|
export interface CustomerAssets {
|
||||||
|
total: number
|
||||||
|
active30: number
|
||||||
|
silent30_90: number
|
||||||
|
sleeping90p: number
|
||||||
|
never: number
|
||||||
|
wechat: number
|
||||||
|
whatsapp: number
|
||||||
|
deleted: number
|
||||||
|
}
|
||||||
export interface TrendPoint { day: number; msg: number; active_customers: number; call_cnt: number }
|
export interface TrendPoint { day: number; msg: number; active_customers: number; call_cnt: number }
|
||||||
export interface AnomalyStaff { user_id: string; yesterday: number; prev7_mean: number; new?: boolean }
|
export interface AnomalyStaff { user_id: string; yesterday: number; prev7_mean: number; new?: boolean }
|
||||||
export interface Anomaly { total_delta_pct: number; yesterday_total: number; prev7_mean: number; staff: AnomalyStaff[] }
|
export interface Anomaly { total_delta_pct: number; yesterday_total: number; prev7_mean: number; staff: AnomalyStaff[] }
|
||||||
// 员工活跃排行(2026-09-07 管理者反馈补强):active_*=窗内 distinct peer,
|
// 员工活跃排行(2026-09-07 管理者反馈补强):active_*=窗内 distinct peer,
|
||||||
// msg_delta 仅周/月且当前/前窗均非 0 才有值
|
// msg_delta 仅周/月/custom 且当前/前窗均非 0 才有值。
|
||||||
|
// 批2 五新列:fr_avg_ms/reply_rate(=null 无样本/无串)、first_contact_cnt、
|
||||||
|
// call_seconds/answer_rate(=null 无通话);deleted/blocked_friends=全库存量
|
||||||
export interface StaffRankingRow {
|
export interface StaffRankingRow {
|
||||||
user_id: string; name: string; unmapped: boolean
|
user_id: string; name: string; unmapped: boolean
|
||||||
msg_total: number; active_friends: number; active_rooms: number
|
msg_total: number; active_friends: number; active_rooms: number
|
||||||
call_cnt: number; msg_delta: number | null
|
call_cnt: number; msg_delta: number | null
|
||||||
|
fr_avg_ms: number | null
|
||||||
|
reply_rate: number | null
|
||||||
|
first_contact_cnt: number
|
||||||
|
call_seconds: number
|
||||||
|
answer_rate: number | null
|
||||||
|
deleted_friends: number
|
||||||
|
blocked_friends: number
|
||||||
}
|
}
|
||||||
export interface ActionCounts { unreplied_2h: number; churn_real_30d: number; sensitive_pending: number }
|
export interface ActionCounts { unreplied_2h: number; churn_real_30d: number; sensitive_pending: number }
|
||||||
// by_channel/trend_by_channel 仅 channel='all' 时后端才返回(可选字段);
|
// by_channel/trend_by_channel 仅 channel='all' 时后端才返回(可选字段);
|
||||||
@@ -38,6 +59,7 @@ export interface OverviewResp {
|
|||||||
channel: Channel | 'all'
|
channel: Channel | 'all'
|
||||||
days: number[]
|
days: number[]
|
||||||
cards: OverviewCards
|
cards: OverviewCards
|
||||||
|
customer_assets: CustomerAssets
|
||||||
trend: TrendPoint[]
|
trend: TrendPoint[]
|
||||||
anomaly: Anomaly | null
|
anomaly: Anomaly | null
|
||||||
staff_ranking: StaffRankingRow[]
|
staff_ranking: StaffRankingRow[]
|
||||||
@@ -64,8 +86,11 @@ export interface SensitiveHitRow {
|
|||||||
room_id: string | null; ts: number | null; snippet: string | null; disposition: string
|
room_id: string | null; ts: number | null; snippet: string | null; disposition: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getOverview = (channel: DashboardChannel, scope: Scope) =>
|
// scope='custom' 时必须传 start/end(yyyymmdd,后端校验跨度≤366 天)
|
||||||
getJson<OverviewResp>('/analytics/overview', { channel, scope })
|
export const getOverview = (
|
||||||
|
channel: DashboardChannel, scope: Scope, start?: string, end?: string,
|
||||||
|
) =>
|
||||||
|
getJson<OverviewResp>('/analytics/overview', { channel, scope, start, end })
|
||||||
export const getAnalyticsHealth = () => getJson<HealthResp>('/analytics/health')
|
export const getAnalyticsHealth = () => getJson<HealthResp>('/analytics/health')
|
||||||
export const getChurn = (params: Record<string, unknown>) =>
|
export const getChurn = (params: Record<string, unknown>) =>
|
||||||
getJson<Page<ChurnRow>>('/analytics/churn', params)
|
getJson<Page<ChurnRow>>('/analytics/churn', params)
|
||||||
|
|||||||
@@ -11,7 +11,23 @@
|
|||||||
<el-radio-button value="week">本周</el-radio-button>
|
<el-radio-button value="week">本周</el-radio-button>
|
||||||
<el-radio-button value="month">本月</el-radio-button>
|
<el-radio-button value="month">本月</el-radio-button>
|
||||||
<el-radio-button value="last30">近30天</el-radio-button>
|
<el-radio-button value="last30">近30天</el-radio-button>
|
||||||
|
<el-radio-button value="custom">自定义</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
|
<!-- C1 自定义日期范围:daterange 自保证 start≤end,选完触发 load;
|
||||||
|
跨度>366 天后端 422 走页面 error alert -->
|
||||||
|
<el-date-picker
|
||||||
|
v-if="scope === 'custom'"
|
||||||
|
v-model="customRange"
|
||||||
|
type="daterange"
|
||||||
|
size="small"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
value-format="YYYYMMDD"
|
||||||
|
:clearable="false"
|
||||||
|
style="width: 260px"
|
||||||
|
@change="load"
|
||||||
|
/>
|
||||||
<span v-if="asOfText" class="as-of">数据截至 {{ asOfText }}</span>
|
<span v-if="asOfText" class="as-of">数据截至 {{ asOfText }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -29,6 +45,21 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- B1 客户资产总览:全库存量快照(不随筛选变)——先看客户盘子,再看
|
||||||
|
下方当期经营卡片;分层按最近沟通时间(从未激活=加过没聊过) -->
|
||||||
|
<el-card v-if="assets" shadow="never" class="assets-card">
|
||||||
|
<div class="assets-row">
|
||||||
|
<span class="assets-title">客户资产</span>
|
||||||
|
<span class="asset-item">总客户 <b>{{ fmt(assets.total) }}</b></span>
|
||||||
|
<span class="asset-item">活跃(30天) <b>{{ fmt(assets.active30) }}</b></span>
|
||||||
|
<span class="asset-item">沉默(30-90天) <b>{{ fmt(assets.silent30_90) }}</b></span>
|
||||||
|
<span class="asset-item">沉睡(90天+) <b>{{ fmt(assets.sleeping90p) }}</b></span>
|
||||||
|
<span class="asset-item">从未激活 <b>{{ fmt(assets.never) }}</b></span>
|
||||||
|
<span class="asset-item small">微信 {{ fmt(assets.wechat) }} · WA {{ fmt(assets.whatsapp) }}</span>
|
||||||
|
<span class="asset-item muted">已删好友 {{ fmt(assets.deleted) }}</span>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
<el-alert v-if="error" type="error" :title="error" :closable="false" class="anomaly" />
|
<el-alert v-if="error" type="error" :title="error" :closable="false" class="anomaly" />
|
||||||
|
|
||||||
<el-alert v-if="anomalyView" type="warning" :closable="false" class="anomaly">
|
<el-alert v-if="anomalyView" type="warning" :closable="false" class="anomaly">
|
||||||
@@ -105,6 +136,27 @@
|
|||||||
<el-table-column label="通话量" align="right">
|
<el-table-column label="通话量" align="right">
|
||||||
<template #default="{ row }">{{ fmt(row.call_cnt) }}</template>
|
<template #default="{ row }">{{ fmt(row.call_cnt) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<!-- 批2 五新列(2026-09-07 管理者需求):新增客户=当期首沟通;
|
||||||
|
首响/回复率=消息串口径(null=无样本/无串);
|
||||||
|
通话时长/接通率=落窗通话;删粉/拉黑=全库存量(「删/拉」) -->
|
||||||
|
<el-table-column label="新增客户" align="right">
|
||||||
|
<template #default="{ row }">{{ fmt(row.first_contact_cnt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="首响" align="right">
|
||||||
|
<template #default="{ row }">{{ fmtFr(row.fr_avg_ms) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="回复率" align="right">
|
||||||
|
<template #default="{ row }">{{ fmtPct(row.reply_rate) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="通话时长" align="right">
|
||||||
|
<template #default="{ row }">{{ fmtDur(row.call_seconds) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="接通率" align="right">
|
||||||
|
<template #default="{ row }">{{ fmtPct(row.answer_rate) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="删粉/拉黑" align="right">
|
||||||
|
<template #default="{ row }">{{ fmtDelBlocked(row) }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="环比" align="right" width="90">
|
<el-table-column label="环比" align="right" width="90">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span
|
<span
|
||||||
@@ -121,8 +173,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// A1 数据看板:筛选栏(渠道×时间范围) → 预警行动入口 → 异常警示条 → 五卡片
|
// A1 数据看板:筛选栏(渠道×时间范围,自定义日期 C1) → 预警行动入口 →
|
||||||
// → 30 日趋势折线 → 员工活跃排行表。
|
// 客户资产总览(B1 全库快照) → 异常警示条 → 五卡片 → 30 日趋势折线 →
|
||||||
|
// 员工活跃排行表(批2 五新列:首响/回复率/新增客户/通话时长+接通率/删粉拉黑)。
|
||||||
// 看板 v2(2026-09-07):渠道默认「全部」=全平台口径——卡片合计+微信/WA
|
// 看板 v2(2026-09-07):渠道默认「全部」=全平台口径——卡片合计+微信/WA
|
||||||
// 拆分小字,趋势双线(微信蓝/WA 绿)+legend,行动卡跳转带 query 预选目标页。
|
// 拆分小字,趋势双线(微信蓝/WA 绿)+legend,行动卡跳转带 query 预选目标页。
|
||||||
// 数据来自 /analytics/overview + /analytics/health(数据截至角标)。
|
// 数据来自 /analytics/overview + /analytics/health(数据截至角标)。
|
||||||
@@ -130,7 +183,8 @@ import { computed, onMounted, ref, watch } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
getAnalyticsHealth, getOverview, type AnomalyStaff, type Channel,
|
getAnalyticsHealth, getOverview, type AnomalyStaff, type Channel,
|
||||||
type DashboardChannel, type OverviewResp, type Scope,
|
type CustomerAssets, type DashboardChannel, type OverviewResp, type Scope,
|
||||||
|
type StaffRankingRow,
|
||||||
} from '@/api/analytics'
|
} from '@/api/analytics'
|
||||||
import { useChart } from '@/composables/useChart'
|
import { useChart } from '@/composables/useChart'
|
||||||
import { formatTimeShort } from '@/utils/format'
|
import { formatTimeShort } from '@/utils/format'
|
||||||
@@ -163,6 +217,8 @@ interface AnomalyView {
|
|||||||
// 渠道默认「全部」(看板 v2:管理人员先看全平台,一眼看清再下钻单渠道)
|
// 渠道默认「全部」(看板 v2:管理人员先看全平台,一眼看清再下钻单渠道)
|
||||||
const channel = ref<DashboardChannel>('all')
|
const channel = ref<DashboardChannel>('all')
|
||||||
const scope = ref<Scope>('today')
|
const scope = ref<Scope>('today')
|
||||||
|
// C1 自定义日期范围:[start, end] yyyymmdd 字符串(value-format)
|
||||||
|
const customRange = ref<[string, string] | null>(null)
|
||||||
const trendMetric = ref<'msg' | 'active_customers' | 'call_cnt'>('msg')
|
const trendMetric = ref<'msg' | 'active_customers' | 'call_cnt'>('msg')
|
||||||
const data = ref<OverviewResp | null>(null)
|
const data = ref<OverviewResp | null>(null)
|
||||||
const asOf = ref<number | null>(null)
|
const asOf = ref<number | null>(null)
|
||||||
@@ -174,11 +230,19 @@ const { render } = useChart(chartEl)
|
|||||||
let seq = 0 // 竞态守卫:channel/scope 快速切换时旧响应作废(项目惯例)
|
let seq = 0 // 竞态守卫:channel/scope 快速切换时旧响应作废(项目惯例)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
// custom 未选完区间不请求——保留上一次数据等选择(daterange 保证 start≤end)
|
||||||
|
if (scope.value === 'custom' && (!customRange.value?.[0] || !customRange.value?.[1])) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const my = ++seq
|
const my = ++seq
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
const ov = await getOverview(channel.value, scope.value)
|
const ov = await getOverview(
|
||||||
|
channel.value, scope.value,
|
||||||
|
scope.value === 'custom' ? customRange.value?.[0] : undefined,
|
||||||
|
scope.value === 'custom' ? customRange.value?.[1] : undefined,
|
||||||
|
)
|
||||||
if (my !== seq) return
|
if (my !== seq) return
|
||||||
data.value = ov
|
data.value = ov
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -227,6 +291,33 @@ const anomalyView = computed<AnomalyView | null>(() => {
|
|||||||
|
|
||||||
const fmt = (n: number | undefined | null) => (n ?? 0).toLocaleString()
|
const fmt = (n: number | undefined | null) => (n ?? 0).toLocaleString()
|
||||||
|
|
||||||
|
// ── B1 客户资产总览(全库存量,随 overview 载荷回来) ──
|
||||||
|
const assets = computed<CustomerAssets | null>(() => data.value?.customer_assets ?? null)
|
||||||
|
|
||||||
|
// ── 排行新列格式化 ──
|
||||||
|
// 首响:<1h 显「X分Y秒」,≥1h 显「X小时Y分」;null(无样本)显 '-'
|
||||||
|
const fmtFr = (ms: number | null) => {
|
||||||
|
if (ms == null) return '-'
|
||||||
|
const totalSec = Math.round(ms / 1000)
|
||||||
|
const mins = Math.floor(totalSec / 60)
|
||||||
|
if (mins < 60) return `${mins}分${totalSec % 60}秒`
|
||||||
|
return `${Math.floor(mins / 60)}小时${mins % 60}分`
|
||||||
|
}
|
||||||
|
// 百分比 1 位小数(回复率/接通率);null 显 '-'
|
||||||
|
const fmtPct = (v: number | null) => (v == null ? '-' : `${(v * 100).toFixed(1)}%`)
|
||||||
|
// 通话时长(秒):<1h 显「X分」,否则「X小时Y分」;0/null 显 '-'
|
||||||
|
const fmtDur = (s: number | null) => {
|
||||||
|
if (!s) return '-'
|
||||||
|
const mins = Math.round(s / 60)
|
||||||
|
if (mins < 60) return `${mins}分`
|
||||||
|
return `${Math.floor(mins / 60)}小时${mins % 60}分`
|
||||||
|
}
|
||||||
|
// 删粉/拉黑合并一列「删/拉」;都 0 显 '-'
|
||||||
|
const fmtDelBlocked = (r: StaffRankingRow) =>
|
||||||
|
r.deleted_friends || r.blocked_friends
|
||||||
|
? `${r.deleted_friends}/${r.blocked_friends}`
|
||||||
|
: '-'
|
||||||
|
|
||||||
// 趋势摘要:先给数字再看图(合计/日均/峰值及日期)
|
// 趋势摘要:先给数字再看图(合计/日均/峰值及日期)
|
||||||
const trendVals = (): number[] =>
|
const trendVals = (): number[] =>
|
||||||
(data.value?.trend ?? []).map((p) => p[trendMetric.value] as number)
|
(data.value?.trend ?? []).map((p) => p[trendMetric.value] as number)
|
||||||
@@ -583,6 +674,42 @@ onMounted(() => {
|
|||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
/* B1 客户资产总览:轻量一行条(参照 action-card 的密度) */
|
||||||
|
.assets-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.assets-card :deep(.el-card__body) {
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.assets-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 20px;
|
||||||
|
}
|
||||||
|
.assets-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.asset-item {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
.asset-item b {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
.asset-item.small {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.asset-item.muted {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
.rank-bar {
|
.rank-bar {
|
||||||
height: 480px;
|
height: 480px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
|||||||
Reference in New Issue
Block a user