From 085a9b4fd3e26f2d5eb13c25d68f9f7f999728b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Mon, 7 Sep 2026 14:41:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(analytics):=20=E7=AE=A1=E7=90=86=E5=B1=82?= =?UTF-8?q?=E6=8C=87=E6=A0=87=E5=91=88=E7=8E=B0=E2=80=94=E2=80=94=E9=A6=96?= =?UTF-8?q?=E5=93=8D=E5=9B=9E=E5=A4=8D=E7=8E=87=E4=B8=8A=E5=A2=99/?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E8=B5=84=E4=BA=A7=E6=80=BB=E8=A7=88/?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E6=97=A5=E6=9C=9F/=E6=8E=92?= =?UTF-8?q?=E8=A1=8C=E4=BA=94=E6=96=B0=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- yunke-server/app/analytics/metrics.py | 246 +++++++++++++++--- yunke-server/app/api/routers/analytics.py | 29 ++- yunke-server/tests/test_analytics_api.py | 48 ++++ yunke-server/tests/test_analytics_overview.py | 158 ++++++++++- yunkes/src/api/analytics.ts | 33 ++- yunkes/src/views/analytics/DashboardView.vue | 135 +++++++++- 6 files changed, 606 insertions(+), 43 deletions(-) diff --git a/yunke-server/app/analytics/metrics.py b/yunke-server/app/analytics/metrics.py index a2055c0..a8b1126 100644 --- a/yunke-server/app/analytics/metrics.py +++ b/yunke-server/app/analytics/metrics.py @@ -43,23 +43,43 @@ def _day_date(day: int) -> dt.date: 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天 滚动窗(含端点)。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": - start = end + start_d = end_d 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": - start = end.replace(day=1) + start_d = end_d.replace(day=1) elif scope == "last30": - start = end - dt.timedelta(days=29) + start_d = end_d - dt.timedelta(days=29) else: raise ValueError(f"bad scope: {scope!r}") - out, cur = [], start - while cur <= end: + out, cur = [], start_d + while cur <= end_d: out.append(_date_key(cur)) cur += dt.timedelta(days=1) return out @@ -69,7 +89,8 @@ def _prev_scope_days(scope: str, days: list[int]) -> list[int]: """同期对齐环比窗(与 days 等长逐日对齐;今日 scope 不调用)。 week=各日-7天(本周一..今 vs 上周一..上周同日);month=上月同日 - (短月钳到上月末,如 3/31→2/28);last30=各日-30天(前一个滚动窗)。 + (短月钳到上月末,如 3/31→2/28);last30=各日-30天(前一个滚动窗); + custom=各日-N 天(等长紧邻前窗,例外不做同期对齐)。 同期对齐而非滚动紧邻窗——后者周会跨周末、月取上月尾巴,管理者会 误读成「上周/上月」(spec §8)。 """ @@ -88,6 +109,11 @@ def _prev_scope_days(scope: str, days: list[int]) -> list[int]: return out if scope == "last30": 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}") @@ -297,8 +323,9 @@ async def anomaly(session: AsyncSession, channel: str | None) -> dict | None: async def staff_ranking( session: AsyncSession, *, channel: str | None, scope: str, + start: int | None = None, end: int | None = None, ) -> list[dict]: - """员工活跃排行(A2 核心子集,2026-09-07 管理者反馈补强)。 + """员工活跃排行(A2 核心子集,2026-09-07 管理者反馈补强;批2 五新列)。 口径:msg_total=stat_daily_staff 聚合(空表回退 msg_daily_stats+映射, 同 _active_staff 模式);call_cnt=phone_call 按 user_id 落窗直查(3k 行 @@ -306,6 +333,19 @@ async def staff_ranking( staff.name,无 staff 行/未映射账号回退 user_id 原值标 unmapped。 排序 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 跨渠道量 自然合到一行(员工排行本就是 user 级)。 @@ -315,12 +355,12 @@ async def staff_ranking( 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) - # 活跃客户/群 + 回退 msg_total:落窗拉行,Python 侧映射后按 user 去重 - # (COUNT(DISTINCT peer) 必须在「账号合并到员工」之后——同人多账号 - # 跨账号去重,写 SQL 反而绕且踩 only_full_group_by) + # 活跃客户/群 + 回退 msg_total/首响三列:落窗拉行,Python 侧映射后按 + # user 去重(COUNT(DISTINCT peer) 必须在「账号合并到员工」之后——同人 + # 多账号跨账号去重,写 SQL 反而绕且踩 only_full_group_by) conds = [MsgDailyStats.day.in_(days)] if channel is not None: conds.append(MsgDailyStats.channel == channel) @@ -332,12 +372,16 @@ async def staff_ranking( MsgDailyStats.peer_id, MsgDailyStats.send_cnt, MsgDailyStats.recv_cnt, + MsgDailyStats.cust_threads, + MsgDailyStats.threads_answered, + MsgDailyStats.fr_sum_ms, ).where(*conds) ) ).all() act: dict[str, dict[str, set]] = {} 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) a = act.setdefault(uid, {"friends": set(), "rooms": set()}) if kind == "friend": # 显式分支:未来新 kind 跳过,不误入 rooms @@ -345,8 +389,13 @@ async def staff_ranking( elif kind == "room": a["rooms"].add(peer) 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) + th: dict[str, tuple[int, int, int]] = {} if use_rollup: conds = [StatDailyStaff.day.in_(days)] if channel is not None: @@ -361,23 +410,45 @@ async def staff_ranking( ), 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) .group_by(StatDailyStaff.user_id) ) ).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: 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] = {} + call_secs: dict[str, int] = {} + call_ans: dict[str, int] = {} if days: lo, _ = day_bounds_ms(days[0]) hi_start, _ = day_bounds_ms(days[-1]) call_rows = ( 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( PhoneCall.user_id.is_not(None), PhoneCall.start_call_time.is_not(None), @@ -387,11 +458,61 @@ async def staff_ranking( .group_by(PhoneCall.user_id) ) ).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 同口径) 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) if use_rollup: 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), key=lambda u: (-msg_total.get(u, 0), u)): 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({ "user_id": uid, "name": staff_names.get(uid) or uid, @@ -456,8 +579,15 @@ async def staff_ranking( "msg_total": cur, "active_friends": len(act[uid]["friends"]) 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, + "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 @@ -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( - 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]: """A1:卡片 + 环比(仅周/月,同期对齐) + 近 N 天趋势 + 异常警示。 + 批2:C1 自定义日期(scope=custom + start/end yyyymmdd,环比=等长紧邻 + 前窗滚动,不做同期对齐——见 _prev_scope_days);B1 customer_assets + 客户资产总览(全库存量,不随 scope/channel)。 + channel=None(全平台,看板 v2 默认):所有指标跨渠道合并——msg/活跃客户 直接合计(peer_id 跨渠道不碰撞);活跃员工按 user 去重(跨渠道重复计数 不拆);通话量本就是 call-type 无渠道语义。此时追加: @@ -502,7 +683,7 @@ async def overview( if trend_days < 1 or trend_days > 90: raise ValueError("trend_days must be 1..90") today = today_key() - days = scope_day_keys(scope, today) + days = scope_day_keys(scope, today, start, end) 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) 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), } - # 环比仅周/月,同期对齐(今日 vs 昨日全天会误导,spec §8) - if scope in ("week", "month", "last30"): + # 环比仅周/月,同期对齐(今日 vs 昨日全天会误导,spec §8); + # custom=等长紧邻前窗滚动(例外,见 _prev_scope_days) + if scope in ("week", "month", "last30", "custom"): prev_days = _prev_scope_days(scope, 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) @@ -528,10 +710,11 @@ async def overview( ) # 趋势:近 trend_days 天 每日 消息量/活跃客户/通话量 - end = _day_date(today) + # (t_end 不复用 end 参数名——那是 custom scope 的 yyyymmdd 入参) + t_end = _day_date(today) t_days = [] - cur = end - dt.timedelta(days=trend_days - 1) - while cur <= end: + cur = t_end - dt.timedelta(days=trend_days - 1) + while cur <= t_end: t_days.append(_date_key(cur)) cur += dt.timedelta(days=1) # 通话趋势:phone_call 拉窗内 (start_call_time) Python 侧按日分桶(3k 行) @@ -571,9 +754,12 @@ async def overview( "channel": "all" if channel is None else channel, "days": days, "cards": cards, + "customer_assets": await _customer_assets(session), "trend": trend, "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), } if channel is None: diff --git a/yunke-server/app/api/routers/analytics.py b/yunke-server/app/api/routers/analytics.py index f8a1b29..80c4d61 100644 --- a/yunke-server/app/api/routers/analytics.py +++ b/yunke-server/app/api/routers/analytics.py @@ -1,6 +1,7 @@ """GET /api/analytics/* —— 数据分析板块端组(前缀分组=未来角色权限落点)。""" from __future__ import annotations +import datetime as dt from typing import Annotated, Any, Literal import sqlalchemy as sa @@ -21,7 +22,11 @@ ChannelParam = Annotated[Literal["wechat", "whatsapp"], Query()] # 看板 v2(2026-09-07):overview 默认全平台——"all" 在 router 层翻译成 # metrics 的 channel=None,不把哨兵字符串传进 SQL 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") @@ -36,6 +41,8 @@ async def analytics_overview( session: SessionDep, channel: AllChannelParam = "all", scope: ScopeParam = "today", + start: YmdParam | None = None, + end: YmdParam | None = None, ) -> dict[str, Any]: """A1 经营总览:卡片+趋势+异常警示一次返回(单往返)。 @@ -43,9 +50,27 @@ async def analytics_overview( 双渠道趋势序列;单渠道行为不变。 cards 不含 data_as_of——数据截至走 /health(data_as_of 为 ms-epoch int 与 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( - session, channel=None if channel == "all" else channel, scope=scope + session, channel=None if channel == "all" else channel, scope=scope, + start=s, end=e, ) diff --git a/yunke-server/tests/test_analytics_api.py b/yunke-server/tests/test_analytics_api.py index b76fab0..d2220ac 100644 --- a/yunke-server/tests/test_analytics_api.py +++ b/yunke-server/tests/test_analytics_api.py @@ -147,3 +147,51 @@ async def test_overview_unmapped_rollup(make_client, monkeypatch): body = r.json() assert body["cards"]["active_staff"] == 2 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 diff --git a/yunke-server/tests/test_analytics_overview.py b/yunke-server/tests/test_analytics_overview.py index 20210e2..e2c6a44 100644 --- a/yunke-server/tests/test_analytics_overview.py +++ b/yunke-server/tests/test_analytics_overview.py @@ -280,27 +280,37 @@ async def test_staff_ranking(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) async def seed(session): session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三")) session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1", peer_kind="friend", peer_id="t1", day=20260904, 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 原值计入并标未映射 session.add(MsgDailyStats(channel="wechat", owner_account_id="wx9", peer_kind="friend", peer_id="t2", day=20260904, 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() out = await metrics.staff_ranking(session, channel="wechat", scope="today") 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]["unmapped"] is False 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]["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) @@ -425,3 +435,145 @@ async def test_action_counts_all(make_client, monkeypatch): "sensitive_pending": 2} 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) diff --git a/yunkes/src/api/analytics.ts b/yunkes/src/api/analytics.ts index aebac56..cce3950 100644 --- a/yunkes/src/api/analytics.ts +++ b/yunkes/src/api/analytics.ts @@ -4,7 +4,8 @@ import { getJson, requestJson } from './client' import type { Channel, Page } from './types' 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'=全平台(后端译成 // channel=None);列表页(churn/unreplied)仍是单渠道,沿用 types.ts 的 Channel export type DashboardChannel = 'all' | Channel @@ -19,15 +20,35 @@ export interface OverviewCards { prev_msg_total?: number 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 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[] } // 员工活跃排行(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 { user_id: string; name: string; unmapped: boolean msg_total: number; active_friends: number; active_rooms: number 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 } // by_channel/trend_by_channel 仅 channel='all' 时后端才返回(可选字段); @@ -38,6 +59,7 @@ export interface OverviewResp { channel: Channel | 'all' days: number[] cards: OverviewCards + customer_assets: CustomerAssets trend: TrendPoint[] anomaly: Anomaly | null staff_ranking: StaffRankingRow[] @@ -64,8 +86,11 @@ export interface SensitiveHitRow { room_id: string | null; ts: number | null; snippet: string | null; disposition: string } -export const getOverview = (channel: DashboardChannel, scope: Scope) => - getJson('/analytics/overview', { channel, scope }) +// scope='custom' 时必须传 start/end(yyyymmdd,后端校验跨度≤366 天) +export const getOverview = ( + channel: DashboardChannel, scope: Scope, start?: string, end?: string, +) => + getJson('/analytics/overview', { channel, scope, start, end }) export const getAnalyticsHealth = () => getJson('/analytics/health') export const getChurn = (params: Record) => getJson>('/analytics/churn', params) diff --git a/yunkes/src/views/analytics/DashboardView.vue b/yunkes/src/views/analytics/DashboardView.vue index db0a9d2..7f93e91 100644 --- a/yunkes/src/views/analytics/DashboardView.vue +++ b/yunkes/src/views/analytics/DashboardView.vue @@ -11,7 +11,23 @@ 本周 本月 近30天 + 自定义 + + 数据截至 {{ asOfText }} @@ -29,6 +45,21 @@ + + +
+ 客户资产 + 总客户 {{ fmt(assets.total) }} + 活跃(30天) {{ fmt(assets.active30) }} + 沉默(30-90天) {{ fmt(assets.silent30_90) }} + 沉睡(90天+) {{ fmt(assets.sleeping90p) }} + 从未激活 {{ fmt(assets.never) }} + 微信 {{ fmt(assets.wechat) }} · WA {{ fmt(assets.whatsapp) }} + 已删好友 {{ fmt(assets.deleted) }} +
+
+ @@ -105,6 +136,27 @@ + + + + + + + + + + + + + + + + + + +