diff --git a/yunke-server/app/analytics/metrics.py b/yunke-server/app/analytics/metrics.py index 0df5df0..ca6c2d0 100644 --- a/yunke-server/app/analytics/metrics.py +++ b/yunke-server/app/analytics/metrics.py @@ -84,9 +84,22 @@ def _prev_scope_days(scope: str, days: list[int]) -> list[int]: raise ValueError(f"bad scope: {scope!r}") +async def _account_map(session: AsyncSession, channel: str | None) -> dict[str, str]: + """channel=None(全平台)→ 两渠道账号映射合并;owner 账号格式跨渠道不碰撞。""" + if channel is not None: + return await account_map(session, channel) + merged = await account_map(session, "wechat") + merged.update(await account_map(session, "whatsapp")) + return merged + + async def _day_rows( - session: AsyncSession, channel: str, days: Iterable[int] + session: AsyncSession, channel: str | None, days: Iterable[int] ) -> list[Any]: + # channel=None(全平台)→ 不加渠道条件,两渠道行合并(peer_id 跨渠道不碰撞) + conds = [MsgDailyStats.day.in_(list(days))] + if channel is not None: + conds.append(MsgDailyStats.channel == channel) return ( await session.execute( sa.select( @@ -95,9 +108,7 @@ async def _day_rows( MsgDailyStats.peer_id, MsgDailyStats.send_cnt, MsgDailyStats.recv_cnt, - ).where( - MsgDailyStats.channel == channel, MsgDailyStats.day.in_(list(days)) - ) + ).where(*conds) ) ).all() @@ -125,16 +136,20 @@ async def _call_count(session: AsyncSession, days: list[int]) -> int: async def _first_contact( - session: AsyncSession, channel: str, days: list[int] + session: AsyncSession, channel: str | None, days: list[int] ) -> int: - """首次沟通客户数:peer 按渠道全史 MIN(day) 落窗(跨员工归一,不跨渠道)。""" + """首次沟通客户数:peer 按渠道全史 MIN(day) 落窗(跨员工归一,不跨渠道)。 + + channel=None(全平台)按 peer 全史 MIN(day)(peer 跨渠道不碰撞,直接 + 跨渠道合并首现日)。 + """ + conds = [MsgDailyStats.peer_kind == "friend"] + if channel is not None: + conds.append(MsgDailyStats.channel == channel) rows = ( await session.execute( sa.select(MsgDailyStats.peer_id, sa.func.min(MsgDailyStats.day)) - .where( - MsgDailyStats.channel == channel, - MsgDailyStats.peer_kind == "friend", - ) + .where(*conds) .group_by(MsgDailyStats.peer_id) ) ).all() @@ -143,10 +158,13 @@ async def _first_contact( async def _active_staff( - session: AsyncSession, channel: str, days: list[int] + session: AsyncSession, channel: str | None, days: list[int] ) -> tuple[int, int]: """活跃员工 (distinct 数, 未映射账号数);stat_daily_staff 空表回退 msg_daily_stats+映射。 + channel=None(全平台):distinct user_id 跨渠道去重(同人两渠道各活跃 + 算 1);映射取两渠道合并——看板 all 口径「跨渠道重复计数不拆」即此。 + 未映射口径(spec §8「未映射账号按账号计并标注」): - rollup:当窗 distinct user_id 不在 account_map 值集(=在册员工 user_id 全集)——recompute 以 amap.get(owner, owner) 落表,映射不上的 owner @@ -155,24 +173,25 @@ async def _active_staff( 活跃数)的计未映射——与 rollup 口径一致(映射不上的账号原样出现)。 """ if await _staff_rows(session): + conds = [StatDailyStaff.day.in_(days)] + if channel is not None: + conds.append(StatDailyStaff.channel == channel) uids = set( ( await session.execute( - sa.select(StatDailyStaff.user_id).where( - StatDailyStaff.channel == channel, - StatDailyStaff.day.in_(days), - ) + sa.select(StatDailyStaff.user_id).where(*conds) ) ).scalars() ) - mapped = set((await account_map(session, channel)).values()) + mapped = set((await _account_map(session, channel)).values()) return len(uids), len(uids - mapped) - amap = await account_map(session, channel) + amap = await _account_map(session, channel) + conds = [MsgDailyStats.day.in_(days)] + if channel is not None: + conds.append(MsgDailyStats.channel == channel) owners = {o for (o,) in ( await session.execute( - sa.select(MsgDailyStats.owner_account_id).where( - MsgDailyStats.channel == channel, MsgDailyStats.day.in_(days) - ) + sa.select(MsgDailyStats.owner_account_id).where(*conds) ) ).all()} unmapped = sum(1 for o in owners if o not in amap) @@ -183,8 +202,12 @@ def anomaly_threshold() -> float: return 0.5 # 昨日 vs 前 7 日均值偏离阈值,v1 固定 -async def anomaly(session: AsyncSession, channel: str) -> dict | None: - """E0-lite 异常警示:昨日总量 vs 前 7 日均值偏离>50%,明细到员工。""" +async def anomaly(session: AsyncSession, channel: str | None) -> dict | None: + """E0-lite 异常警示:昨日总量 vs 前 7 日均值偏离>50%,明细到员工。 + + channel=None(全平台):rollup/回退查询去渠道条件后,同 user_id 跨渠道 + 行在逐日累加阶段自然合并(user 级总量/明细跨渠道口径)。 + """ today = today_key() yesterday = _day_date(today) - dt.timedelta(days=1) @@ -193,19 +216,22 @@ async def anomaly(session: AsyncSession, channel: str) -> dict | None: use_rollup = await _staff_rows(session) if use_rollup: + conds = [StatDailyStaff.day.in_([yday] + prev7)] + if channel is not None: + conds.append(StatDailyStaff.channel == channel) rows = ( await session.execute( sa.select( StatDailyStaff.day, StatDailyStaff.user_id, sa.func.coalesce(StatDailyStaff.msg_sent + StatDailyStaff.msg_recv, 0), - ).where( - StatDailyStaff.channel == channel, - StatDailyStaff.day.in_([yday] + prev7), - ) + ).where(*conds) ) ).all() else: - amap = await account_map(session, channel) + amap = await _account_map(session, channel) + conds = [MsgDailyStats.day.in_([yday] + prev7)] + if channel is not None: + conds.append(MsgDailyStats.channel == channel) raw = ( await session.execute( sa.select( @@ -214,10 +240,7 @@ async def anomaly(session: AsyncSession, channel: str) -> dict | None: sa.func.sum(MsgDailyStats.send_cnt + MsgDailyStats.recv_cnt), 0 ), ) - .where( - MsgDailyStats.channel == channel, - MsgDailyStats.day.in_([yday] + prev7), - ) + .where(*conds) .group_by(MsgDailyStats.day, MsgDailyStats.owner_account_id) ) ).all() @@ -266,7 +289,7 @@ async def anomaly(session: AsyncSession, channel: str) -> dict | None: async def staff_ranking( - session: AsyncSession, *, channel: str, scope: str, + session: AsyncSession, *, channel: str | None, scope: str, ) -> list[dict]: """员工活跃排行(A2 核心子集,2026-09-07 管理者反馈补强)。 @@ -276,6 +299,9 @@ async def staff_ranking( staff.name,无 staff 行/未映射账号回退 user_id 原值标 unmapped。 排序 msg_total desc + user_id asc 决胜。返回全部行(员工 ~41)。 + channel=None(全平台):查询去渠道条件+映射合并,同 user 跨渠道量 + 自然合到一行(员工排行本就是 user 级)。 + active_friends/active_rooms 两路径统一从 msg_daily_stats 算窗内 COUNT(DISTINCT peer)(映射合并到员工后,同 recompute_days 模式): stat_daily_staff 的 active_* 是**当日快照**,跨日 SUM 会重复计数、 @@ -283,11 +309,14 @@ async def staff_ranking( 避免两路径语义分叉。 """ days = scope_day_keys(scope, today_key()) - amap = await account_map(session, channel) + amap = await _account_map(session, channel) # 活跃客户/群 + 回退 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) rows = ( await session.execute( sa.select( @@ -296,9 +325,7 @@ async def staff_ranking( MsgDailyStats.peer_id, MsgDailyStats.send_cnt, MsgDailyStats.recv_cnt, - ).where( - MsgDailyStats.channel == channel, MsgDailyStats.day.in_(days) - ) + ).where(*conds) ) ).all() act: dict[str, dict[str, set]] = {} @@ -314,6 +341,9 @@ async def staff_ranking( use_rollup = await _staff_rows(session) if use_rollup: + conds = [StatDailyStaff.day.in_(days)] + if channel is not None: + conds.append(StatDailyStaff.channel == channel) rollup = ( await session.execute( sa.select( @@ -325,10 +355,7 @@ async def staff_ranking( 0, ), ) - .where( - StatDailyStaff.channel == channel, - StatDailyStaff.day.in_(days), - ) + .where(*conds) .group_by(StatDailyStaff.user_id) ) ).all() @@ -360,6 +387,9 @@ async def staff_ranking( if scope in ("week", "month"): prev_days = _prev_scope_days(scope, days) if use_rollup: + conds = [StatDailyStaff.day.in_(prev_days)] + if channel is not None: + conds.append(StatDailyStaff.channel == channel) prev_rows = ( await session.execute( sa.select( @@ -371,15 +401,15 @@ async def staff_ranking( 0, ), ) - .where( - StatDailyStaff.channel == channel, - StatDailyStaff.day.in_(prev_days), - ) + .where(*conds) .group_by(StatDailyStaff.user_id) ) ).all() prev_total = {uid: int(v) for uid, v in prev_rows} else: + conds = [MsgDailyStats.day.in_(prev_days)] + if channel is not None: + conds.append(MsgDailyStats.channel == channel) prev_rows = ( await session.execute( sa.select( @@ -391,10 +421,7 @@ async def staff_ranking( 0, ), ) - .where( - MsgDailyStats.channel == channel, - MsgDailyStats.day.in_(prev_days), - ) + .where(*conds) .group_by(MsgDailyStats.owner_account_id) ) ).all() @@ -428,17 +455,20 @@ async def staff_ranking( return out -async def action_counts(session: AsyncSession, *, channel: str) -> dict: - """预警行动入口计数(看板顶部卡片):复用已测试的查询路径,各取 total。""" +async def action_counts(session: AsyncSession, *, channel: str | None) -> dict: + """预警行动入口计数(看板顶部卡片):复用已测试的查询路径,各取 total。 + + channel=None(全平台):三计数全部去渠道过滤(含 sensitive_pending)。 + """ un = await unreplied(session, channel=channel, hours=2, page=1, page_size=1) ch = await churn(session, channel=channel, kind="real", tier=30, min_msgs=5, page=1, page_size=1) + pending_conds = [SensitiveHit.disposition == "pending"] + if channel is not None: + pending_conds.append(SensitiveHit.channel == channel) pending = ( await session.execute( - sa.select(sa.func.count()).select_from(SensitiveHit).where( - SensitiveHit.channel == channel, - SensitiveHit.disposition == "pending", - ) + sa.select(sa.func.count()).select_from(SensitiveHit).where(*pending_conds) ) ).scalar_one() return { @@ -449,10 +479,17 @@ async def action_counts(session: AsyncSession, *, channel: str) -> dict: async def overview( - session: AsyncSession, *, channel: str, scope: str, trend_days: int = 30 + session: AsyncSession, *, channel: str | None, scope: str, trend_days: int = 30 ) -> dict[str, Any]: """A1:卡片 + 环比(仅周/月,同期对齐) + 近 N 天趋势 + 异常警示。 + channel=None(全平台,看板 v2 默认):所有指标跨渠道合并——msg/活跃客户 + 直接合计(peer_id 跨渠道不碰撞);活跃员工按 user 去重(跨渠道重复计数 + 不拆);通话量本就是 call-type 无渠道语义。此时追加: + - by_channel:微信/WhatsApp 两渠道的 msg_total/active_customers/ + first_contact 拆分(卡片下小字;活跃员工/通话量不拆,口径不成立); + - trend_by_channel:两渠道各自趋势序列(仅 all 时出现;单渠道不加键)。 + trend 在 all 时=两渠道合计(单系列口径保持可用)。 cards.unmapped_accounts=活跃数里映射不上的账号数(spec §8 按账号计并标注)。 """ if trend_days < 1 or trend_days > 90: @@ -490,12 +527,6 @@ async def overview( while cur <= end: t_days.append(_date_key(cur)) cur += dt.timedelta(days=1) - t_rows = await _day_rows(session, channel, t_days) - by_day: dict[int, dict] = {d: {"msg": 0, "cust": set()} for d in t_days} - for d, kind, p, s, r in t_rows: - by_day[d]["msg"] += int(s or 0) + int(r or 0) - if kind == "friend": - by_day[d]["cust"].add(p) # 通话趋势:phone_call 拉窗内 (start_call_time) Python 侧按日分桶(3k 行) lo, _ = day_bounds_ms(t_days[0]) hi_day, _ = day_bounds_ms(t_days[-1]) @@ -512,15 +543,25 @@ async def overview( for ts in calls: k = _date_key(dt.datetime.fromtimestamp(ts / 1000, _SH).date()) call_by_day[k] = call_by_day.get(k, 0) + 1 - trend = [ - {"day": d, "msg": v["msg"], "active_customers": len(v["cust"]), - "call_cnt": call_by_day.get(d, 0)} - for d, v in by_day.items() - ] - return { + def _trend_points(rows: list[Any], call_by: dict[int, int]) -> list[dict]: + by_day: dict[int, dict] = {d: {"msg": 0, "cust": set()} for d in t_days} + for d, kind, p, s, r in rows: + by_day[d]["msg"] += int(s or 0) + int(r or 0) + if kind == "friend": + by_day[d]["cust"].add(p) + return [ + {"day": d, "msg": v["msg"], "active_customers": len(v["cust"]), + "call_cnt": call_by.get(d, 0)} + for d, v in by_day.items() + ] + + t_rows = await _day_rows(session, channel, t_days) + trend = _trend_points(t_rows, call_by_day) + + out: dict[str, Any] = { "scope": scope, - "channel": channel, + "channel": "all" if channel is None else channel, "days": days, "cards": cards, "trend": trend, @@ -528,6 +569,27 @@ async def overview( "staff_ranking": await staff_ranking(session, channel=channel, scope=scope), "actions": await action_counts(session, channel=channel), } + if channel is None: + # 拆分小字/双线趋势的数据源:复用同查询路径按渠道各跑一遍(小表毫秒级)。 + # 渠道序列 call_cnt 恒 0——通话是 call-type 无渠道语义不拆,前端 all + # 模式通话量画总趋势单线。 + out["by_channel"] = {} + out["trend_by_channel"] = {} + for ch in ("wechat", "whatsapp"): + ch_rows = await _day_rows(session, ch, days) + out["by_channel"][ch] = { + "msg_total": sum( + int(s or 0) + int(r or 0) for _d, _k, _p, s, r in ch_rows + ), + "active_customers": len( + {p for _d, kind, p, _s, _r in ch_rows if kind == "friend"} + ), + "first_contact": await _first_contact(session, ch, days), + } + out["trend_by_channel"][ch] = _trend_points( + await _day_rows(session, ch, t_days), {} + ) + return out def _now_ms() -> int: @@ -542,19 +604,24 @@ def _next_tier(tier: int) -> int: async def churn( - session: AsyncSession, *, channel: str, kind: str, tier: int, + session: AsyncSession, *, channel: str | None, kind: str, tier: int, min_msgs: int, page: int, page_size: int, ) -> dict: """A3 三分法(spec §3.3)。tier ∈ {30,60,90} 分层区间 [tier, next) ,90=≥90。 + channel=None(全平台):去渠道过滤,两渠道客户合并(peer_id 跨渠道不 + 碰撞,历史消息半连接按 peer 聚合同样成立);items[].channel 保留原值 + 供前端跳转分流。 + active_days=历史活跃天数(distinct 自然日;peer 跨账号转移同日多行只计一日)。 """ day_ms = 86_400_000 now = _now_ms() base = [ - Customer.channel == channel, sa.or_(Customer.delete_flag.is_(None), Customer.delete_flag == 0), ] + if channel is not None: + base.append(Customer.channel == channel) if kind == "real": lo = now - _next_tier(tier) * day_ms hi = now - tier * day_ms @@ -563,13 +630,13 @@ async def churn( Customer.last_chat_time > lo, Customer.last_chat_time <= hi] # 历史消息 ≥ min_msgs 放 SQL 端(分页 total 语义正确):半连接聚合 + hist_conds = [MsgDailyStats.peer_kind == "friend"] + if channel is not None: + hist_conds.append(MsgDailyStats.channel == channel) base.append( Customer.friend_id.in_( sa.select(MsgDailyStats.peer_id) - .where( - MsgDailyStats.channel == channel, - MsgDailyStats.peer_kind == "friend", - ) + .where(*hist_conds) .group_by(MsgDailyStats.peer_id) .having( sa.func.coalesce( @@ -611,6 +678,10 @@ async def churn( items = [] if rows: ids = [c.friend_id for c in rows] + agg_conds = [MsgDailyStats.peer_kind == "friend", + MsgDailyStats.peer_id.in_(ids)] + if channel is not None: + agg_conds.append(MsgDailyStats.channel == channel) agg = ( await session.execute( sa.select( @@ -620,11 +691,7 @@ async def churn( ), sa.func.count(sa.distinct(MsgDailyStats.day)), ) - .where( - MsgDailyStats.channel == channel, - MsgDailyStats.peer_kind == "friend", - MsgDailyStats.peer_id.in_(ids), - ) + .where(*agg_conds) .group_by(MsgDailyStats.peer_id) ) ).all() @@ -663,10 +730,16 @@ def _norm_phone(p: str | None) -> str | None: async def unreplied( - session: AsyncSession, *, channel: str, hours: int, page: int, page_size: int, + session: AsyncSession, *, channel: str | None, hours: int, page: int, + page_size: int, ) -> dict: """B2 该回未回(spec §3.4)。候选=汇总表最新日行;决赛圈点查类型/预览/通话豁免。 + channel=None(全平台,看板 v2 计数用):候选去渠道条件,窗口 partition + 本含 channel 列——(渠道,owner,peer) 三元组各自取最新日行,跨渠道 + 天然正确;决赛圈点查以 (owner,peer) 定位(peer 跨渠道不碰撞),回显 + channel 取尾部消息实值供前端跳转分流。 + 候选资格看「最新日行」(row_number 先于方向/超时过滤——员工次日已回的 旧未回日不复活);决赛圈再点查尾部消息:须员工未回(is_send 非 True—— stats/message 漂移时以 message 实尾为准,写钩子吞重算失败是已记录状态; @@ -696,6 +769,12 @@ async def unreplied( ) .label("rn") ) + sub_conds = [ + MsgDailyStats.peer_kind == "friend", + MsgDailyStats.last_ts.is_not(None), + ] + if channel is not None: + sub_conds.append(MsgDailyStats.channel == channel) sub = ( sa.select( MsgDailyStats.owner_account_id.label("owner"), @@ -704,11 +783,7 @@ async def unreplied( MsgDailyStats.last_is_send.label("last_is_send"), rn, ) - .where( - MsgDailyStats.channel == channel, - MsgDailyStats.peer_kind == "friend", - MsgDailyStats.last_ts.is_not(None), - ) + .where(*sub_conds) ).subquery() cand = ( await session.execute( @@ -724,15 +799,18 @@ async def unreplied( async def _finalist(owner: str, peer: str, ts: int) -> dict | None: # 决赛圈点查:尾部消息类型+预览(走 ix_message_channel_owner_talker_time) + msg_conds = [ + Message.owner_account_id == owner, + Message.talker_id == peer, + sa.or_(Message.room_id.is_(None), Message.room_id == ""), + Message.timestamp.is_not(None), + ] + if channel is not None: + msg_conds.append(Message.channel == channel) 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) + sa.select(Message).where(*msg_conds) + .order_by(Message.timestamp.desc()).limit(1) ) ).scalar_one_or_none() if last_msg is None or last_msg.is_send or last_msg.type not in _VALID_TAIL_TYPES: @@ -759,7 +837,7 @@ async def unreplied( text = (last_msg.text or "").replace("\n", " ") return { "peer_id": peer, - "channel": channel, + "channel": last_msg.channel, "owner_account_id": owner, "last_ts": ts, "wait_ms": now - ts, diff --git a/yunke-server/app/api/routers/analytics.py b/yunke-server/app/api/routers/analytics.py index 4f46f3f..915bfdd 100644 --- a/yunke-server/app/api/routers/analytics.py +++ b/yunke-server/app/api/routers/analytics.py @@ -18,6 +18,9 @@ router = APIRouter(prefix="/api/analytics", tags=["analytics"]) SessionDep = Annotated[AsyncSession, Depends(get_session)] 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"], Query()] @@ -31,15 +34,19 @@ async def analytics_health(session: SessionDep) -> dict[str, Any]: @router.get("/overview") async def analytics_overview( session: SessionDep, - channel: ChannelParam = "wechat", + channel: AllChannelParam = "all", scope: ScopeParam = "today", ) -> dict[str, Any]: """A1 经营总览:卡片+趋势+异常警示一次返回(单往返)。 + channel=all(默认)=全平台口径:卡片合计+by_channel 拆分+trend_by_channel + 双渠道趋势序列;单渠道行为不变。 cards 不含 data_as_of——数据截至走 /health(data_as_of 为 ms-epoch int 与 entities[].last_seen 为 ISO 字符串的双格式)。 """ - return await metrics_svc.overview(session, channel=channel, scope=scope) + return await metrics_svc.overview( + session, channel=None if channel == "all" else channel, scope=scope + ) @router.get("/churn") diff --git a/yunke-server/tests/test_analytics_overview.py b/yunke-server/tests/test_analytics_overview.py index df724de..20210e2 100644 --- a/yunke-server/tests/test_analytics_overview.py +++ b/yunke-server/tests/test_analytics_overview.py @@ -344,3 +344,84 @@ async def test_action_counts(make_client, monkeypatch): "sensitive_pending": 1} await make_client(seed) + + +async def test_overview_all_channel(make_client, monkeypatch): + """看板 v2 全平台口径(channel=None):卡片=两渠道合计、by_channel 拆分、 + trend=合计、trend_by_channel 双序列(仅 all 时出现;单渠道不加键)。""" + monkeypatch.setattr(metrics, "today_key", lambda: _TODAY) + async def seed(session): + await _seed(session) # wechat 当日:t1 friend 15 + r1 room 7 = 22 + # whatsapp 当日:w1 friend 6(全史首现=今日,首沟通落窗) + session.add(MsgDailyStats(channel="whatsapp", owner_account_id="wa1", + peer_kind="friend", peer_id="w1", day=20260904, + send_cnt=4, recv_cnt=2, last_ts=1788500000000, + last_is_send=True)) + await session.commit() + + out = await metrics.overview(session, channel=None, scope="today") + assert out["channel"] == "all" + # 合计卡片(peer 跨渠道不碰撞:活跃客户/首沟通直接相加) + assert out["cards"]["msg_total"] == 28 # 22 + 6 + assert out["cards"]["active_customers"] == 2 # t1 + w1 + assert out["cards"]["first_contact"] == 2 + # 拆分小字数据源 + assert out["by_channel"]["wechat"] == { + "msg_total": 22, "active_customers": 1, "first_contact": 1} + assert out["by_channel"]["whatsapp"] == { + "msg_total": 6, "active_customers": 1, "first_contact": 1} + # 趋势:合计单系列保持可用 + 双渠道序列(30 天窗末日有值) + assert len(out["trend"]) == 30 + assert out["trend"][-1]["msg"] == 28 + assert out["trend"][-1]["active_customers"] == 2 + tbc = out["trend_by_channel"] + assert len(tbc["wechat"]) == 30 and len(tbc["whatsapp"]) == 30 + assert tbc["wechat"][-1]["msg"] == 22 + assert tbc["whatsapp"][-1]["msg"] == 6 + # 通话量无渠道语义不拆:渠道序列 call_cnt 恒 0,总量走 trend + assert tbc["wechat"][-1]["call_cnt"] == 0 + assert out["trend"][-1]["call_cnt"] == 1 + + # 单渠道:不加 by_channel/trend_by_channel 键(既有行为不破坏) + single = await metrics.overview(session, channel="wechat", scope="today") + assert single["cards"]["msg_total"] == 22 + assert "by_channel" not in single + assert "trend_by_channel" not in single + + await make_client(seed) + + +async def test_action_counts_all(make_client, monkeypatch): + """all 口径行动计数:两渠道各一条未回 → unreplied_2h=2;sensitive_pending + 同样跨渠道合计(只计 pending)。""" + monkeypatch.setattr(metrics, "_now_ms", lambda: 1756950000000) + async def seed(session): + now = 1756950000000 + h3 = 3 * 3600 * 1000 + for i, (ch, owner, peer) in enumerate( + [("wechat", "wx1", "t1"), ("whatsapp", "wa1", "w1")] + ): + session.add(MsgDailyStats(channel=ch, owner_account_id=owner, + peer_kind="friend", peer_id=peer, day=20260904, + send_cnt=5, recv_cnt=3, last_ts=now - h3, + last_is_send=False)) + session.add(Message(msg_id=f"m{i}", channel=ch, owner_account_id=owner, + talker_id=peer, timestamp=now - h3, is_send=False, + type="text", text="在吗", + fetched_at=dt.datetime(2026, 9, 4, 12, 0))) + w = SensitiveWord(word="测试词") + session.add(w) + await session.flush() + session.add(SensitiveHit(word_id=w.id, msg_id="s1", channel="wechat", + disposition="pending")) + session.add(SensitiveHit(word_id=w.id, msg_id="s2", channel="whatsapp", + disposition="pending")) + session.add(SensitiveHit(word_id=w.id, msg_id="s3", channel="whatsapp", + disposition="confirmed")) + await session.commit() + + out = await metrics.action_counts(session, channel=None) + assert out == {"unreplied_2h": 2, "churn_real_30d": 0, + "sensitive_pending": 2} + + await make_client(seed) diff --git a/yunkes/src/api/analytics.ts b/yunkes/src/api/analytics.ts index c87e5de..9d63b4b 100644 --- a/yunkes/src/api/analytics.ts +++ b/yunkes/src/api/analytics.ts @@ -5,6 +5,9 @@ import type { Channel, Page } from './types' export type { Channel } export type Scope = 'today' | 'week' | 'month' +// 看板 v2(2026-09-07):overview 渠道参数支持 'all'=全平台(后端译成 +// channel=None);列表页(churn/unreplied)仍是单渠道,沿用 types.ts 的 Channel +export type DashboardChannel = 'all' | Channel export interface OverviewCards { msg_total: number @@ -27,15 +30,20 @@ export interface StaffRankingRow { call_cnt: number; msg_delta: number | null } export interface ActionCounts { unreplied_2h: number; churn_real_30d: number; sensitive_pending: number } +// by_channel/trend_by_channel 仅 channel='all' 时后端才返回(可选字段); +// 渠道趋势序列 call_cnt 恒 0——通话量无渠道语义不拆,总量走 trend +export interface PerChannelCards { msg_total: number; active_customers: number; first_contact: number } export interface OverviewResp { scope: Scope - channel: Channel + channel: Channel | 'all' days: number[] cards: OverviewCards trend: TrendPoint[] anomaly: Anomaly | null staff_ranking: StaffRankingRow[] actions: ActionCounts + by_channel?: { wechat: PerChannelCards; whatsapp: PerChannelCards } + trend_by_channel?: { wechat: TrendPoint[]; whatsapp: TrendPoint[] } } export interface HealthResp { data_as_of: number | null; entities: { table: string; last_seen: string | null }[] } export interface ChurnRow { @@ -56,7 +64,7 @@ export interface SensitiveHitRow { room_id: string | null; ts: number | null; snippet: string | null; disposition: string } -export const getOverview = (channel: Channel, scope: Scope) => +export const getOverview = (channel: DashboardChannel, scope: Scope) => getJson('/analytics/overview', { channel, scope }) export const getAnalyticsHealth = () => getJson('/analytics/health') export const getChurn = (params: Record) => diff --git a/yunkes/src/views/analytics/ChurnView.vue b/yunkes/src/views/analytics/ChurnView.vue index b76d555..f57f973 100644 --- a/yunkes/src/views/analytics/ChurnView.vue +++ b/yunkes/src/views/analytics/ChurnView.vue @@ -52,7 +52,7 @@ // 沉默分层(仅真流失) + CSV 导出 + 行点击跳聊天窗。 // 数据来自 /analytics/churn;/analytics/health 挂载拉一次做「数据截至」角标。 import { computed, onMounted, ref } from 'vue' -import { useRouter } from 'vue-router' +import { useRoute, useRouter } from 'vue-router' import { ElMessage } from 'element-plus' import { getAnalyticsHealth, getChurn, type Channel, type ChurnRow } from '@/api/analytics' import { exportListToCsv } from '@/utils/csvExport' @@ -68,9 +68,18 @@ const KIND_HINTS: Record = { } const router = useRouter() -const channel = ref('wechat') -const kind = ref('real') -const tier = ref(30) +// 看板行动卡跳转带 query 预选筛选(白名单外回默认;只在初始化读一次,不 watch) +const route = useRoute() +const qChannel = route.query.channel +const channel = ref( + qChannel === 'wechat' || qChannel === 'whatsapp' ? qChannel : 'wechat', +) +const qType = route.query.type +const kind = ref( + qType === 'real' || qType === 'inactive' || qType === 'handover' ? qType : 'real', +) +const qTier = Number(route.query.tier) +const tier = ref([30, 60, 90].includes(qTier) ? qTier : 30) const page = ref(1) const pageSize = 20 const rows = ref([]) diff --git a/yunkes/src/views/analytics/DashboardView.vue b/yunkes/src/views/analytics/DashboardView.vue index 033e64c..2b47960 100644 --- a/yunkes/src/views/analytics/DashboardView.vue +++ b/yunkes/src/views/analytics/DashboardView.vue @@ -2,6 +2,7 @@
+ 全部 微信 WhatsApp @@ -17,7 +18,7 @@ 行动入口」);计数随 channel 切换自然跟随 overview 载荷,0 灰显仍可点 -->
@@ -112,11 +113,14 @@