Compare commits

..

50 Commits

Author SHA1 Message Date
浅念 682249a3fd perf(management): message补无渠道前缀索引——修驾驶舱channel=all超时34s→1.3s
该回未回决赛圈点查(owner+talker定位末条消息)在channel=None时无渠道
前缀条件,现有索引全以channel打头用不上,服务器100万行实测dashboard
34~37s撞前端15s超时(ECONNABORTED);补(owner,talker,timestamp)索引
后实测1.3~1.8s。服务器已先行手工建索引止血,本迁移幂等兜底对齐。
模型+迁移双声明防autogenerate反DROP
2026-09-10 15:26:25 +08:00
浅念 80b721b84f fix(groups): 字节序排序按方言——MySQL CAST AS BINARY / SQLite COLLATE BINARY
collate("BINARY") 被 SQLAlchemy 反引号成标识符致 MySQL 500;SQLite 的
CAST AS BINARY 语义不符(排序键失效退化 room_id 序,测试失去判别力)。
按 session.bind.dialect 分支,两库都用真字节序。

726 测试绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:37:07 +08:00
浅念 acd6997bf0 fix(groups): 排序终判——owner 二进制字符串序(云客逐位吻合),弃 list_order
上版按 list_order 排不对:云客员工列表序 ≠ 群统计页员工序。真机对照
切换点员工终判:群统计员工顺序 = owner(微信号/wxid 混存)的**区分大小写
二进制字符串升序**(Nancyjade < csf7169708 < wxid_01gv…,大写在小写前,
逐位吻合),组内 room_id 字符串升序。COLLATE BINARY 双库通用,且无需
JOIN wechat_account。

726 测试绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:23:43 +08:00
浅念 253be99eca fix(groups): 微信群排序复刻云客+群详情去掉重复的「成员列表」字段
用户报:①群详情有成员墙后「成员列表」原始串字段重复;②排序与云客不一致。

- 排序:云客「微信群统计」真机判别=员工分组(云客员工列表序)+
  组内 chatRoomId 字符串升序——/api/groups 微信路径改 JOIN
  wechat_account 按 list_order(NULL 垫底按 owner asc)+room_id asc;
  WA 保持最近活跃优先
- 群详情:memberIds 从「其他字段」兜底区排除(成员墙已替代,不再
  以原始 wxid 串重复出现)

726 测试绿(默认排序断言更新为员工分组序)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:11:13 +08:00
浅念 e00d399195 docs(management): 批次F调度接入验证——滚动段耗时/幂等/本地scheduler镜像重建
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:11:02 +08:00
浅念 2ac63e3844 feat(management): 夜间全量脚本13/14步——fact与宽表全量自愈+终局快照三表行数 2026-09-10 14:00:34 +08:00
浅念 0dc5993af7 feat(management): 4h分析链接入管理域滚动段——独立事务隔离/fact活跃会话+宽表+通话滚动3天
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 13:59:44 +08:00
浅念 0b975a8289 docs(management): 批次F调度子批计划——tick管理段+夜间13/14步(Task F1-F3) 2026-09-10 13:55:11 +08:00
浅念 8f9ef747cc fix: prune 改逐行删——asyncmy 拒 tuple IN 的 list 参数
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 13:54:34 +08:00
浅念 3d2889bcde fix: prune 块漏 import sqlalchemy——NameError 中断对账
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 13:48:06 +08:00
浅念 2c7625c3e4 docs(management): 批次D全链路走查+验收记录——管理者动线七步/清单逐项/门禁全绿
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 12:44:38 +08:00
浅念 422a726ee8 feat(management): 驾驶舱KPI环比(prev_kpis+delta)+异常卡带员工名跳详情+列表进详情带筛选
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 12:20:28 +08:00
浅念 be0d6f844e feat(group-members): 群列表对账云客总数+群详情成员墙(头像+昵称)
用户报两点:①微信群列表与云客群统计总数不一致(实测云客 1,864 vs 本地
2,012——friend_list/es 时代残留的 148 行 owner 组合已不在 getChatroomList
返回里);②群详情成员列表全是 wxid 无法识别。

- chatroom-stats 全窗模式加对账:名单取满(>=声明 total)才删本地不在
  云客名单里的 chat_group(wechat)行——页脚从此与云客一字不差;短页宁
  可不对账防误删
- backfill_group_members 增强:头像列(消息最新一条 sender_head_url)+
  customer 兜底 UPDATE(member_id=friend_id 命中本司好友补昵称/头像,
  覆盖未发言成员);full_sync 8b 原位生效
- 群详情 drawer 成员墙:头像+昵称网格(群昵称>好友昵称>wxid 回退+wxid
  副行),替代原 memberIds 原始串;仅微信 tab(WA 群成员未派生)

722 后端测试绿,前端构建过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 12:11:42 +08:00
浅念 577617545f feat(management): 员工详情页——五维条/五页签懒加载/四对比/评分明细/事实下钻复用
- EmployeeAnalyticsDetailView:头卡(账号跳聊天/试算分grade色/辅导记录批次E占位)
  +五维条(3可用实分bar+2待数据灰条,以metrics键缺失判pending_data) +五页签
  (section懒加载·loaded缓存一次·切页签replace进URL section) +概览四对比表
  (derive_metrics六键,unit感知) +服务/工作负载双线趋势(connectNulls:false)
  +异常区(AnomalyList showEmployee=false) +评分明细(metrics_detail全表
  +prev/baseline原始字典二级折叠) +伪卡片下钻(超时明细/降温沉睡名单/通话明细)
- seq守卫:detail单seq,每页签独立seq;筛选/换人清缓存重取;404→员工不存在结果态
- router /management/employees/:userId(props:true);MainLayout activeMenu
  沿用route.name→父路径先例映射management-employees-detail→/management/employees
- AnomalyList 预挂showEmployee prop(默认true;D3员工行消费,本页先传false)——D3清单项提前

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 12:08:35 +08:00
浅念 4e53a99629 fix(media): upsert_media MySQL 悬空 new.fetched_at——录音本地化停更多日
第二轮同步审计实测复现(最小用例 0.3s 即炸):upsert_media 的 update_cols
取了全部表列,而 values 只含 11 个业务列——server_default 列
(fetched_at/updated_at)不在 INSERT 列集,MySQL 编译 ON DUPLICATE 时生成
new.fetched_at 悬空引用 → 1054 Unknown column → 每夜 full 的
call_recordings 步骤(录音下载本地化)自生产部署起**每夜静默失败**,
新录音一直没本地化(异常被 SyncRun 吞成 failed 日志,序列继续)。

修:update_cols 只取 values 里实际有的列(对齐 _build_upsert_stmt 的
「只更新实际插入列」原则);updated_at 在冲突重下时刷 now()。

SQLite 分支(excluded 引用宽容)同逻辑修正;722 测试绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:54:14 +08:00
浅念 b72e957da2 feat(management): 员工总览页——四视图/服务端排序分页/对比抽屉/CSV口径注释导出
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:44:03 +08:00
浅念 602bee8fdf fix(sync): 空对话「已扫」记忆——根除 2,468 个无消息对话的每轮全量翻史
前项审计的深挖:带窗验证发现活跃仅 27 对话,但访问集仍有 2,493 个——
2,468 个「从未扫过」的对话(纯语音/从未聊过客户)水位恒 0,拉完 0 条
仍是 unscanned,下一轮又被全量翻史(~2.6s/个,每 4h×每晚都翻)——
这才是消息任务时长的真正黑洞(昨夜 53,682 条绝大部分源于此)。

修:复用 sync_state 表记扫描标记(entity_type={channel}_conv_empty)——
- 拉到 0 条有效消息即打标(微信/WA 1:1)
- 发现层排除「已扫空且不活跃」的;客户一旦发消息(last_chat_time 进
  活跃窗)自动回到访问集——不会漏新消息
- 周日 full_history 强制全访问兜底(云客补历史场景)
- 空群(115 个,每轮 ~5min)本轮不动,量小

722 测试绿。预期效果:4h 序列访问集 2,493→~30,时长分钟级。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:24:59 +08:00
浅念 a9197f696a fix(sync): 同步链路审计修复——水位边界白拉/全量无超时/群活跃窗判定源
全项目数据同步审计发现四漏洞(前项为证据):

① 消息增量边界白拉(证据:昨夜 wa-messages fetched=53,682 而真实新消息
   仅 321 条,99.4% 是白拉白写的已入库旧消息)——云客 chatRecords/msg-chats
   的 start 参数是 inclusive(返回 ts>=start 含边界),四个消息实体(微信
   1:1/WA 1:1/微信群/WA 群)统一在归一化后过滤 ts<=watermark 的边界行,
   不计数不写库(省 99% 写放大,计数从此反映真实新增)
② full_sync_all.sh 无超时——极端情况(常态 10-20h)拖进次日相位把 02:00
   全量又挤掉(前两天事故同型);加 22h 上限
③ 周日 full-history 无超时——4 实体全历史可能 24h+,必挤周一;单实体
   加 6h 上限
④ 群消息活跃窗判定源是昨晚 full 的 chat_group.last_chat_time 快照——
   「昨天安静、今天突然活跃」的群会被 24h 窗口漏到明晚;4h 序列在
   group-messages 前加 chatroom-stats 全窗(~1min)刷新群活跃判定源

722 测试绿(4h 序列断言 9→10 命令+chatroom-stats 位序)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:08:15 +08:00
浅念 ddeda3fa22 feat(management): client补员工详情/分段接口+时间预设补今日昨日本周上周
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:07:25 +08:00
浅念 2587e4dd08 docs(management): 批次D员工页计划——总览+详情+环比+预设补全(Task D0-D4)
评审3真问题全修:排序白名单四列(余列不排序)/服务页签图改P50P90双线
(员工级无每日率序列)/CSV扩csvExport注释行+rows适配;advisory全采:
异常卡补employee_name(一行后端)/showEmployee复用开关/formula_note归位/
伪卡片下钻衔接/compare仅显示态
2026-09-10 11:02:27 +08:00
浅念 10b7e5e534 fix(management): 待回复/风险卡标注时点口径——当前指标不随窗口,防管理者误读(批次C终评I1) 2026-09-10 10:45:16 +08:00
浅念 e3fadfe1a6 fix(scheduler): 调度根治——4h 增量只扫活跃对话+账号表进 4h+超时保险丝
用户报「员工列表时间与云客差 2 天」。根因链:4h 增量序列全对话遍历
(WA 9,357 对×~3.5s≈9h,整序列 15h+)超过自身 4h 间隔→与 02:00 全量在
asyncio.Lock 上背靠背互相排队→近两晚全量被挤掉→账号表(仅全量刷)停更
两天,员工列表「数据更新/最新消息时间」显示 09-08 快照。

三层修复:
- 活跃窗:_discover_conversations/群发现加 active_since_ms——已扫过的
  对话仅保留 last_chat_time 在窗内的(云客 lastChatTime 滞后小时级,
  24h 窗足够容错),从未扫过的(新覆盖优先)不受限;陈旧对话无新消息,
  每日 full 兜底。CLI --active-window-hours,四消息实体透传
- 4h 序列头部加 accounts/wa-accounts(~2min)——员工列表时间最多滞后
  4h,不再依赖每晚全量
- 超时保险丝:_page_subprocess 加 timeout(超时 kill 返回 124,上游
  break 下轮补)——messages 各实体 15-80min 预算,单命令拖不死相位;
  full/full-history 不设限

722 测试绿(序列断言 7→9 命令+窗口参数)。干跑验证:近 24h 活跃私聊/
群相对全量降一个数量级(本地旧库群 2011→537;私聊依赖 friends 先刷,
序列顺序已保证)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 10:38:47 +08:00
浅念 3cf77ec6d5 feat(management): 管理驾驶舱页——KPI/双趋势/生命周期/维度分布/异常两次点击到事实
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 10:38:05 +08:00
浅念 7343e1c9aa feat(management): 经营管理导航分组+驾驶舱路由 2026-09-10 10:20:13 +08:00
浅念 5196a72ac2 feat(management): 驾驶舱六组件——KPI卡/筛选栏/异常榜/下钻抽屉/口径抽屉/数据截至角标+时长比率格式化共享
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 10:15:10 +08:00
浅念 df8d2b30d5 feat(management): useManagementFilters——URL query 真源/预设窗/渠道哨兵
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 10:02:35 +08:00
浅念 c56f13059d fix(group-stats): 群统计「查看详情」进纯聊天窗——无会话侧栏,操作列单按钮
用户反馈:微信群统计列表点查看聊天记录进入的页面带「好友列表/群列表」
侧栏不合适——云客该入口是专注的群详情聊天页(截图验证:无会话侧栏,
仅系统菜单+群名+工具栏+消息流)。

- ChatTimeline 增 bare 专注模式(?bare=1 隐藏会话侧栏);员工聊天窗口
  入口(不带 bare)侧栏常驻——昨天 WA 侧栏修复的场景不受影响
- GroupList 微信 tab 操作列精简为单按钮「查看详情」(=查看聊天记录,
  跳 bare 模式;导出HTML移除——聊天页头部本有导出),WA tab 不动

本地双路径验证:bare 无好友列表/有群聊标题;无 bare 侧栏在;列表点击
跳转 ?bare=1 正常。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 09:58:22 +08:00
浅念 7b0e1601b7 feat(management): 前端API client——管理端点typed契约(批次B冻结面)
对批次B as-built逐键核对后相对任务稿的修正(以真机响应为准):
- DashboardResp 补 caliber_notes(总述口径说明)
- DrilldownResp 补 page/page_size/scope(各分支均返回)
- FactDrillRow 补 chat_hint(owner+talker 直达聊天记录)
- LifecycleDrillRow.nickname 收窄为非空(后端 friend_id 兜底)
- EmployeeRow.metrics 注明 user_id 键为字符串透传字段

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 09:57:44 +08:00
浅念 940762e7ff fix(management): drilldown行补channel字段——前端聊天路由判渠道用(契约冻结前补齐)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 09:49:59 +08:00
浅念 2b10c07fb7 docs(management): 批次C前端计划——驾驶舱+导航(Task C0-C6)
评审4真问题全修:drilldown行补channel字段(C0后端契约补全)/DimSummary
补name+weight/端口3000→8000;advisory全采:服务图双轴/共享格式化提升
format.ts/去硬编码41/client契约字段补全
2026-09-10 09:44:39 +08:00
浅念 9fbd572bb7 docs(management): 批次B终评收尾——validate_config补源名唯一性+遗留观察项归档
批次B终评Approve后两处闭环:
- validate_config补源指标名跨维度唯一校验(comparisons按源名建键的
  静默覆盖防线)+测试
- 观察项归档:call_band样本基数(批次F)/源名唯一(已修)/性能与winsorize(批次F)
2026-09-09 19:28:48 +08:00
浅念 23ac95f2e4 docs(management): 批次B真机冒烟——六端点耗时/回测员工抽查/记录
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 19:17:56 +08:00
浅念 3d5644c42a fix(management): drilldown生命周期历史改peer键控对齐批次A口径+comparisons键域统一+补测试
评审修正(I1/M1-M4/M6):
- I1 lifecycle_customers 历史改 (channel,peer) 键控跨owner合并,归属仍由
  Customer.owner_account_id 决定——与批次A _lifecycle_daily 同口径;新增
  共享客户测试(历史在他人账号下仍全量呈现)
- M3 employees 行 comparisons 键域统一为指标源名(config code→spec.metric
  映射,active_friends_band/call_band 不再出现),与 sections 一致
- M4 未回复分支空 owners 先短路,免去无效 finalize 调用
- M1 employee_metrics._percentile 改公有 percentile 供同包复用,删除
  api_assembly.percentile_nearest(存储层 _percentile 不同层保留)
- M2 drilldown 内联 SQL(FirstReplyFact/PhoneCall)下沉 api_assembly
  (fr_fact_rows/user_calls_page),路由层无裸查询
- M6 补 start>end→422 断言;新增 config_source=db 测试(激活配置
  min_days=9→五天数据员工翻 insufficient_sample,守护生效可见)

tests/test_management_api.py: 21 passed;全量 721 passed

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 18:59:45 +08:00
浅念 31cd2d92aa feat(management): 只读管理API六端点——dashboard/employees/detail/sections/drilldown白名单/指标定义
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 18:34:58 +08:00
浅念 57ace98792 docs(management): 批次B计划同步band basis决议——active_friends周期distinct(与B2/B3实现一致) 2026-09-09 18:12:26 +08:00
浅念 b0ebe8a15a feat(group-stats): 微信群列表对齐云客「微信群统计」——getChatroomList 数据源+窗内统计
用户报微信群列表数据/内容不对,应对齐云客「沟通→微信号管理→微信群统计」
(/cms/wechat/wechatgroup)。真机探明(表单登录+页面导航抓 network):

- 数据源=POST /wechatweb/chatroom/getChatroomList(form:departmentId+
  startTime/endTime 窗+分页;信封 data.overviews[]/totalCount 1,854 行)
- 云客窗是任意时间窗实时算:沟通消息数实测同群全史 417/7天 36/单日 6
- **修正存量映射错误**:member_count 原存 friendNum(员工好友数),真群成员
  数是 friendCount/getChatroomList.memberCount
- 新增/退出成员数云客按窗实时算,本地无事件流 → 按天积累

实现:
- ChatGroup 补 group_remark 列(备注仅 friend_list/es 携带,两归一化均
  「有值才写」防互清);迁移含存量 raw_json 回填(须先于基线抓取执行)
- 新表 chat_group_member_stats(渠道×账号×群×天一行 added/lost)
- 新实体 chatroom-stats:全窗刷档案列(真成员数/群备注/员工信息,owner
  经 wechat_account alias 解析)+ --day 单日窗落进出日行;full_sync 5c 步
- /api/groups 扩展:msg_start/msg_end 窗内聚合(window_msg_count=
  msg_daily_stats 按天和、window_added/lost=日表和)、last_start/end
  最新消息窗、group_remark 子串;行补员工微信/员工信息关联列
- GroupList 微信 tab:统计时段(默认近7天)/最新消息时间段/群名称/群备注/
  员工下拉/查询 + 9 列(群信息|群备注|员工微信|员工信息|群成员数量|
  新增成员数|退出成员数|沟通消息数|操作);WA tab 不动

700 测试绿(新增窗聚合/筛选/员工列 3 例)。本地实测:基线 1,854 群=云客
total 一字不差,今日日窗 added 407/lost 166 落表,页面 9 列/员工列渲染正常。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 18:09:20 +08:00
浅念 03c08881b6 feat(management): 确定性异常规则×4——P90恶化/回复率骤降/活动骤降/过载+derive_metrics公有化
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 18:06:08 +08:00
浅念 07c9d091a1 fix(management): validate_config补grade_bounds形状与跨维度code唯一性校验 2026-09-09 17:56:55 +08:00
浅念 c9f8c844d6 feat(management): 评分引擎——三类标准化/维度归一/样本保护/等级/四对比明细+期末active_friends接口修订
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 17:42:57 +08:00
浅念 b51b32a14b fix(management): 期聚合active_friends按(uid,peer)去重——多账号共享客户不再双计;reply计数并入Python/单一METRIC_VERSION
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 17:32:34 +08:00
浅念 729710da63 feat(management): 期聚合查询——fact精确分位/期末存量跨渠道合并/distinct活跃/前窗基线窗口
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 17:18:33 +08:00
浅念 8b555a5ad4 refactor(management): 种子函数移入management包+深拷贝冻结配置+清理死代码
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 17:06:14 +08:00
浅念 afc71e61ef feat(management): 指标元数据+试算评分配置v1.0+种子CLI——权重校验/幂等落库
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 16:51:56 +08:00
浅念 d523813435 docs(management): 批次B实施计划——评分/异常/六只读API端点(Task B0-B5)
评审两minor+五advisory全采纳:active_share期末行口径/异常严重度加权排序/
窗口函数列表达式/affected_count定义/channel=all翻译/错误风格偏差注明
2026-09-09 16:46:39 +08:00
浅念 7758a3351f docs(management): 批次A终评修正——部署顺序理由/data_complete语义/通话类型注释
终评 Approve 后三处文档级修正:
- CLI help与DEPLOY的顺序理由改为事实(宽表不读fact,先跑不会出0;
  顺序约定保留,fact是批次B评分/下钻数据源)
- data_complete 注释改为预留语义待批次B定义(当前恒True)
- EmployeeCallDaily 注释通话类型实测值(voice_call/wa_voice_call)
2026-09-09 16:30:05 +08:00
浅念 639edf56a6 docs(management): 批次A回填实测+对账记录+DEPLOY部署注记
- 核实文档追加Task8小节:回填实测汇总(fact 68,476行/72s,宽表12,371+
  通话1,163/71s,幂等,同源68,476≡68,476,孤儿0,通话314天)
- 对账抽查:王云青+田瑞志×3日期×三组,①②组12/12逐日全一致,③组宽表
  ≤日切(差值=跨夜串归属效应,全期12,055 vs 20,981折叠57.4%验证串未丢)
- 批次A验收清单11项全过(653测试本机复跑绿,写钩子零改动逐commit核查)
- DEPLOY.md §7 新增批次A附加步骤:b7c8d9e0f1a2手动alembic+两条回填
  命令及顺序依赖(employee-metrics必须在first-reply-facts之后)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 16:20:33 +08:00
浅念 0fce21aaf7 fix(management): 回填CLI通话日并集——消息空窗日的通话不再漏(8天27条实测)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 16:11:04 +08:00
浅念 b2e8edacc2 feat(management): 两条回填CLI——first-reply-facts/employee-metrics全量重建幂等
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 14:55:16 +08:00
浅念 e725c7032a docs(management): 批次A计划Task6代码块同步评审修正——hist时点语义/防御性flush
与 ad3beb7 实际实现对齐:day_msgs+cum 累积数组(hist=≤as_of,历史日沉睡门槛
不被未来消息虚增)、每日循环末 flush、accumulate import
2026-09-09 14:45:58 +08:00
浅念 ad3beb7625 fix(management): 宽表生命周期hist改时点语义(≤as_of累积)——历史日dormant不再被未来消息虚增;补正向同源/percentile None判别+防御性flush
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 14:44:35 +08:00
59 changed files with 10577 additions and 212 deletions
+26
View File
@@ -228,6 +228,32 @@ alembic 链自当前线上版本起新增 3 支迁移:`a7d3e9f1c2b4` msg_daily_s
幂等自动补种,无需手插;后续加新词默认只扫增量,要扫历史消息需重跑
`--full`。
### 管理后台批次A附加步骤(一次性,2026-09-09)
迁移链新增 `b7c8d9e0f1a2`(经营管理域六表:first_reply_fact /
employee_metric_daily / employee_call_daily / employee_score_config /
employee_score_snapshot / management_followup)。**服务器 backend 容器
不会自动跑 alembic**,须手动:
```bash
sudo docker compose -f docker-compose.server.yml exec backend alembic upgrade head
```
部署带该迁移后在**夜间低峰**跑两条一次性回填(幂等;本地实测耗时:
fact 72s / 宽表+通话 71s,均分钟级):
```bash
sudo docker compose -f docker-compose.server.yml exec backend python -m app.sync.cli first-reply-facts
sudo docker compose -f docker-compose.server.yml exec backend python -m app.sync.cli employee-metrics
```
- `first-reply-facts`:跨日精确首响事实表(本地实测 11,020 会话 → 6.8 万行)。
- `employee-metrics`:员工指标宽表 + 通话日小表(本地实测 12,371 宽表行 +
1,163 通话行);**必须在 first-reply-facts 之后跑**——fact 表是后续评分/
下钻的数据源,须先填充(宽表自身从 message 表经同一算法重算串类列,
不读 fact 表,先跑宽表不会出 0,但 fact 缺失会阻塞后续能力)。
- 两命令均为按日 delete+insert 幂等重建,复跑结果一致,中断重跑无副作用。
## 8. 本地收尾(部署验证通过后)
```bash
@@ -170,3 +170,242 @@
| 6 | 群消息占总消息量 64%(微信侧 69%) | Task 3/6(friend 过滤必须,佐证 spec 口径) |
| 7 | call_number↔talker_id 命中率 voice_call 95.0% / wa_voice_call 99.3% | Task 5(call_followup_count 高置信,不降级) |
| 8 | staff 41 行无幽灵账号;`stat_daily_staff` 仅 40 名员工有行 | Task 5/6(known_user_ids 过滤零损耗) |
---
# 批次A回填实测与对账(Task 8,2026-09-09)
- **环境**:本地 Docker MySQL(`yunke-mysql-1`),数据快照同上(stat/msg 覆盖 20251024~20260907)。
- **方式**:Task 7 真机全量回填后本任务只读对账抽查(未重跑回填);对账前全库同源校验复验一次,确认库状态即 Task 7 收尾状态。
## 1. 回填实测汇总(Task 7 真机数字,Task 8 复验)
| 项目 | 实测 |
|---|---|
| `first-reply-facts` | 11,020 会话 → **68,476** fact 行,**72s**(冷) |
| `employee-metrics` | **12,371** 宽表行(day×channel×user)+ **1,163** 通话行(**314** 天并集),**71s** 冷 / 36s 热 |
| 幂等复跑 | 两命令复跑结果一致(delete+insert 语义,无重复无漂移) |
| 全库同源 | `SUM(employee_metric_daily.threads_answered)` = **68,476** ≡ fact 行数(Task 8 复验通过) |
| 孤儿检查 | 宽表/通话表 user_id 不在 staff = **0**;fact 账号不在对应渠道账号表 = **0**(Task 8 复验通过) |
| 通话日并集修复 | `employee_call_daily` 覆盖 **314** 天(修复前 306):8 个消息空窗日找回 27 通话 / 13 接通 |
## 2. 对账抽查(2 员工 × 3 日期 × 三组)
员工取自本文回测名单:**王云青** `369D257EEC9A4357935FDE19DDD99CAA`(#1,高负载+群运营,混合口径差异最大场景;1 微信 + 2 WA 账号)、**田瑞志** `9C0B195A2C234C829FF507E011B4B460`(#2,WA 为主;1 微信 + 2 WA 账号)。日期 20260815 / 20260801 / 20260601(近/中/远)。跨渠道员工按 channel 分别对账(①②组逐渠道逐日须一致)。
判定标准:**①组(消息计数列 ↔ msg_daily_stats friend 聚合)与 ②组(串类列 ↔ fact 行数)必须逐日一致;③组(宽表串类 cust_threads ↔ 日切 stat_daily_staff.cust_threads)允许差异,差值=跨夜串归属效应。**
### 王云青
| 渠道 | 日期 | ① mds sent/recv | ① 宽表 sent/recv | ① 判定 | ② fact 行 | ② 宽表 answered | ② 判定 | ③ 宽表 threads | ③ 日切 threads | ③ 差值 |
|---|---|---|---|---|---|---|---|---|---|---|
| wechat | 20260601 | 186 / 177 | 186 / 177 | ✅ | 76 | 76 | ✅ | 76 | 121 | 45 |
| wechat | 20260801 | 0 / 38 | 0 / 38 | ✅ | 0 | 0 | ✅ | 0 | 16 | 16 |
| wechat | 20260815 | 0 / 23 | 0 / 23 | ✅ | 0 | 0 | ✅ | 0 | 17 | 17 |
| whatsapp | 20260601 | (无行) | 0 / 0 | ✅ | (无行) | 0 | ✅ | 0 | (无行) | — |
| whatsapp | 20260801 | 0 / 1 | 0 / 1 | ✅ | 1 | 1 | ✅ | 1 | 1 | 0 |
| whatsapp | 20260815 | 0 / 4 | 0 / 4 | ✅ | 1 | 1 | ✅ | 1 | 2 | 1 |
### 田瑞志
| 渠道 | 日期 | ① mds sent/recv | ① 宽表 sent/recv | ① 判定 | ② fact 行 | ② 宽表 answered | ② 判定 | ③ 宽表 threads | ③ 日切 threads | ③ 差值 |
|---|---|---|---|---|---|---|---|---|---|---|
| wechat | 三日期均 | (无行) | 0 / 0 | ✅ | (无行) | 0 | ✅ | 0 | (无行) | — |
| whatsapp | 20260601 | 1 / 1 | 1 / 1 | ✅ | 1 | 1 | ✅ | 1 | 1 | 0 |
| whatsapp | 20260801 | (无行) | 0 / 0 | ✅ | (无行) | 0 | ✅ | 0 | 2 | 2 |
| whatsapp | 20260815 | (无行) | 0 / 0 | ✅ | (无行) | 0 | ✅ | 0 | (无行) | — |
注:「无行」= 源表该 (渠道, 日) 无该员工数据,宽表记 0/0——一致。20260601 无 WA 数据未换日期:田瑞志 whatsapp 该日恰好有 1 条收发(对账有效),王云青 whatsapp 全期仅 369 条消息、无行属真实分布。
**结论:①②组 12/12 逐日全部一致(含渠道拆分);③组宽表全部 ≤ 日切值。**
## 3. 跨夜串差值观察(③组差异解释)
- **归属语义**:宽表串按「串末条客户消息所在上海自然日」归属(`first_reply_facts.compute_conversation`);日切表按「当日有客户消息的 peer 数」计。串末条必在归属日当天 ⇒ 结构上保证 ③组逐日 宽表 ≤ 日切,差值=归属到其他日终了的跨夜/多日串。
- **差值形态**:王云青 wechat 三日差 45/16/17——该员工深夜消息多(20260801/15 发送 0、仅收 38/23 条,客户消息跨零点后串归属顺延);whatsapp 差 0/1。田瑞志 whatsapp 20260801 差 2(2 个 peer 当日来消息但串末条在次日)。
- **串未丢失验证**:王云青 wechat 全期 宽表 12,055 threads(11,930 answered,314 天)vs 日切 20,981(14,647 answered,312 天)——串模型把多日串折叠为 1(比值 57.4%),且抽查 20260814~31 逐日,日切 17 线程的 20260815 对应串终落在真实结束日(0819=8、0821=10、0828=9),无凭空丢失。
- 另:宽表 `cust_threads ≥ threads_answered`(如王云青 wechat 20260602 = 54/52)——未闭合尾串计入分母不产 fact 样本,与算法注释一致。
## 4. 批次A验收清单(Task 8 收尾核对)
| # | 项 | 状态 | 依据 |
|---|---|---|---|
| 1 | 6 张表本地存在,migration 幂等 + downgrade 干净 | ✅ | Task 2(迁移 `b7c8d9e0f1a2`,alembic head 实测一致;表实测在库:first_reply_fact / employee_metric_daily / employee_call_daily / employee_score_config / employee_score_snapshot / management_followup) |
| 2 | first-reply-facts 全量回填成功且复跑幂等 | ✅ | Task 7(本文 §1) |
| 3 | employee-metrics 全量回填成功且复跑幂等 | ✅ | Task 7(本文 §1) |
| 4 | 跨夜串判别测试通过 | ✅ | Task 3(test_first_reply_facts.py 恰 12 例,Task 8 复跑全绿;含跨夜/多日串/同毫秒并列) |
| 5 | friend-only 口径判别测试通过 | ✅ | Task 6(test_all_friend_pairs_only_friend + 宽表 friend 过滤判别例) |
| 6 | 同源判别测试通过 + 全库 SUM 验证 | ✅ | Task 3/6;全库 68,476 ≡ 68,476(本文 §1 复验) |
| 7 | 幽灵员工过滤测试通过 | ✅ | Task 5/6;孤儿=0(本文 §1 复验) |
| 8 | 对账抽查 ①②一致、③差异可解释 | ✅ | 本文 §2/§3 |
| 9 | 全量测试套件绿 | ✅ | **653 passed in 62s**(Task 8 本机实跑,2026-09-09) |
| 10 | 核实文档完整 | ✅ | 本文补齐 |
| 11 | 写钩子零改动 | ✅ | 批次A全部 17 个 commits(27f91e5→0fce21a)逐一核查,无一触碰 storage/summary.py、storage/repos.py(repos.py 最近一次改动 d428953 属 customer_account_link 工作线,先于批次A) |
---
# 批次B冒烟(Task B5,2026-09-09)
经营管理后台六端点真机冒烟 + 性能初测。回测员工王云青 `369D257EEC9A4357935FDE19DDD99CAA`(#1 高负载+群运营),窗口 30 天 `20260810~20260908`。
## 0. 环境与启动方式
- **基线**:`pytest tests/ -q` → **721 passed in 60.9s**(批次B收尾后全绿)。
- **后端启动**:docker 无 `yunke-backend/frontend-1` 容器;冒烟前 8000 端口空闲(连接拒绝,无常驻进程)。本任务自起:`yunke-server/.venv/Scripts/python.exe -m uvicorn app.api.main:app --port 8000`(无 `--reload`),`GET /api/health` → `{"status":"ok"}`;`openapi.json` 含 management 路由(grep 命中)确认新代码已加载。
- **鉴权模式**:`.env` 中 `API_TOKEN=`(空值)→ 鉴权中间件 no-op,**免鉴权直接 curl**(无 Authorization 头)。
- **进程处置**:uvicorn 留跑供用户查看,Git Bash PID **2423**(`kill 2423` 关闭;或任务管理器结束对应 python.exe)。
## 1. 六端点结果(HTTP 码 × 耗时 × 大小)
首次访问含冷启动(连接池/查询编译/InnoDB 冷缓存),dashboard 另测暖态三次:
| 端点 | HTTP | 首次耗时 | 暖态耗时 | 大小 | 判定 |
|---|---|---|---|---|---|
| `/api/management/dashboard` | 200 | 12.03s | **2.92 / 2.39 / 2.18s** | 22,309B | ⚠️ 超 1.5s 目标(慢段定位见 §2) |
| `/api/management/employees?page_size=50` | 200 | 0.34s | 0.30 / 0.31s | 58,865B | ✅ ≤1.5s |
| `/api/management/employees/{王云青}` | 200 | 0.40s | — | 4,739B | ✅ |
| `…/metrics?section=service` | 200 | 0.40s | — | 4,655B | ✅ |
| `…/drilldown?metric_code=first_reply_p90` | 200 | 0.016s | — | 5,268B | ✅ |
| `/api/management/metric-definitions` | 200 | 0.005s | — | 4,081B | ✅ |
## 2. dashboard 慢段定位(分段实测,不做优化——决策归批次F)
用 app 同一 session 复放 dashboard 组装流水线逐段计时(合计 2.685s ≈ 暖态端点耗时):
| 段 | 耗时 |
|---|---|
| load_score_config | 0.044s |
| assemble_employees(评分+异常,2 窗聚合) | 0.262s |
| wide_daily_rows / call_daily_rows | 0.007s / 0.002s |
| service_daily_trend | 0.032s |
| data_as_of | 0.009s |
| **action_counts(unreplied+churn+sensitive)** | **2.328s** |
action_counts 内部:`unreplied(hours=2)` **2.38s**;`churn(real,30d)` 0.12s;sensitive count 0.005s。unreplied 再拆:候选窗口函数查询(row_number over 67,116 行 friend 分区 → 3,123 候选)仅 **0.10~0.16s**;>500 候选走「仅本页决赛圈」分支无 N+1,但**单条尾部消息点查耗时 1.7~1.9s**。EXPLAIN 实证:`channel=None`(全平台)时无法使用 channel 打头的全部索引(`ix_message_channel_owner_talker_time(channel,owner,talker,timestamp)` 等),优化器退化到 `ix_message_room`(`room_id IS NULL/''`)→ **扫 378,202 行 + filesort**(message 表 96.4 万行)。
优化方向(供批次F选择):① 查询改写按渠道 UNION/`channel IN (…)` 绑定前缀走现有索引;② 补 `(owner_account_id, talker_id, timestamp)` 无 channel 前缀索引;③ dashboard 对 action_counts 做短 TTL 缓存。除 unreplied 外全链路 <0.5s,达标无压力。
## 3. 合理性抽查
- **employees 列表**:`total=41`/rows=41(=本文① staff 总数 ✓)。**王云青行与宽表 SQL 聚合逐字段全等**:msg_sent=111 / msg_recv=1003 / cust_threads=89 / threads_answered=57 / fr_sum_ms=698,140,844、days_with_data=29。数量级说明:「千级」预期对应全期全口径(全期 162,740 条消息/12,055 串,群聊为主);本窗 friend 口径 recv=1003 千级 ✓,sent=111 为真实分布——同窗全口径 recv≈2.0 万(stat_daily_staff 实测) vs friend 1,003,差额几乎全是群消息;cust_threads=89 是串模型多日串折叠(本文 §3 已验证折叠比 57.4%)+本窗 friend 活动偏低。
- **dashboard kpis**:active_employees=**40**(~40 ✓,=本文① stat 有数员工数)、active_customers=2,195、msg_total=42,422、cust_threads=9,330、answered=8,460、call 263/接通 113;lifecycle new/cooling/dormant/reactivated=231/1,432/3,353/145。**anomalies=28(critical 3 / warning 25)**,首条 `reply_p90_worsened` critical:P90 324.98min vs 基线期 18.06min(+1700%,样本 62、涉及 51 客户)。dimension_summary:risk/action 全 41 pending(批次C接线,符合预期),service scored 27/insufficient 4/pending 10、均分 56.0。趋势 30 点,末日 20260908 全零——数据止于 20260907(本文快照范围),边界正确。
- **detail(王云青)**:`score.trial=true` ✓、grade=**E**(total=39,scored 非 insufficient)、dimensions:risk/action **pending** ✓(批次C)、service 47.8 / customer 0.0 / workload 100;含 accounts/prev/baseline/anomalies。
- **metrics section=service**:数值与宽表窗聚合一致 + trend 30 点 ✓。
- **drilldown first_reply_p90**:total=**57**=该员工账号窗内 fact 行数(=threads_answered ✓)。首行 fr_ms=**498,242,238ms≈5.8 天**——超「分钟~小时」预期,但与源表全等(fact MAX 同值);窗内全库更极端值达 1,617,406,247ms≈18.7 天(thread_end 08-14 → reply 09-02,沉睡客户复活串),属真实数据形态非计算缺陷。该员工 p50=97,153ms(1.6min)/p90=1,651,758ms(27.5min)分钟级 ✓。极值是否 winsorize/封顶归批次F口径决策。
- **metric-definitions**:19 项定义(msg_sent…call_followup_rate)+ score_config + caliber_notes,`config_source="db"`(评分配置 v1.0 种子生效 ✓)。
**结论:六端点全 200、数据与库逐字段可对;唯一性能缺口=dashboard 的 unreplied 行动计数(channel 无前缀索引可用),暖态 2.2~2.9s,其余端点全部达标。**
## 4. 批次B遗留观察项归档(终评补录,2026-09-09)
| # | 项 | 归档 |
|---|---|---|
| 1 | call_band 样本基数:band 类指标样本量取 active_friends(规格字面),WA 通话量上来后"通话多客户少"员工会被误标 insufficient——当前数据零影响(实测 calls≥1 且 af<10 仅 5 人且 calls≤5,怎么选都不足);归批次F回测前一行修正(sample_basis 字段或按源指标取样本) |
| 2 | 配置源名唯一性:两个 code 指向同一源指标会使 employees comparisons 键静默覆盖——已在 validate_config 加源名唯一校验+测试(批次B终评闭环),默认配置六个源名互异不受影响 |
| 3 | dashboard 性能(2.2~2.9s,unreplied channel=None 无前缀索引)与 fr_ms 天级极值 winsorize:见上文 §2/§3,决策归批次F |
---
# 批次D走查与验收(Task D4,2026-09-10)
批次D收尾:员工总览(b72e957)+ 员工详情(5776175)+ 驾驶舱环比/异常跳员工闭环(422a726,前置接口/时间预设 ddeda3f)。Playwright 真机七步全链路走查 + v1 §19.1 员工子集与批次D验收清单逐项核对。环境:后端 uvicorn 8000(免鉴权)+ 前端 vite dev 5173,视口 **1366px**。
## 0. 四门禁(全绿)
| 门禁 | 结果 |
|---|---|
| 后端 `pytest tests/ -q` | **723 passed** in 65.29s |
| 前端 `npm run build` | ✓ built in 1.03s(exit 0) |
| 前端 `npm run test`(vitest) | Test Files 2 passed / **Tests 23 passed** |
## 1. 七步动线走查记录(数字为真机实测,关键值已对 API 复核)
**① 驾驶舱默认近30天(20260812~20260910)**:三 KPI 卡环比箭头 ✓(客户端自 prev_kpis 按 (cur-prev)/prev 计算,API 复核数值吻合)——全渠道:活跃客户 **1,992人 ↓12.6%**(prev 2,278)/ 活跃员工 **40人 →0.0%**(40/40)/ 消息量 **37,742条 ↓21.0%**(prev 47,749);切微信:**979人 ↓25.3% / 40人 ↑2.6% / 27,949条 ↓29.6%**。方向与量级合理(等长紧邻前窗)。
**② 异常卡员工名 → 员工详情**:点微信异常榜员工名链接(落点须为 a.el-link;整行点击触发行下钻抽屉,属预期交互)→ 落地 `/management/employees/18B5EE6B1AD94B15A5CD1CDABCECAB96?channel=wechat&start=…&end=…`(驾驶舱当前筛选 query 随行 ✓)。
**③ 员工详情(朱晨曦,综合 21/E)**:五维条 **3 实 + 2 待数据**——合规风险/行动执行 pending 灰条占位,服务质量 0 分/客户经营 0 分/有效负载 100 分(API dimensions 逐项一致;行序随接口返回序,见遗留①)。四对比表(当前/上期/基线/中位)✓(cust_threads 62/114/107+中位)。服务质量页签双折线渲染(canvas 1074×300)→「查看超时明细」→ 首响P90 抽屉 **Total 11** → 点行进聊天:**17 条消息**,时间线与所点明细行吻合(客户 08-31 11:59:12 → 回复 17:24:11 ≈ 5小时24分)。
**④ 客户经营页签**:返回列表再进 → section=customer →「沉睡客户名单」抽屉 **Total 89** ✓。
**⑤ 员工总览对比**:勾选 2 人 → 对比抽屉并列两列指标 ✓(2-5 人为按钮启用门槛),关闭复原。
**⑥ 排序+翻页+CSV(微信渠道)**:综合分降序(服务端 sort)——第 1 页 **55→21**(林峰 55 领头),第 2 页 **11 / 1 / 其余「--」**(18 人 insufficient_sample **空值排尾** ✓)。导出 CSV:**3 行口径前言**(口径/窗口+渠道/试算版本)**+ 表头 + 41 行数据**,toast「已导出 41 条」。
**⑦ 控制台与持久化**:走查全程 console **0 errors**;修复遗留告警后(见 §3)全新加载 **0 warnings**。刷新保持 ✓——列表 view=service 刷新还原(Total 41)、详情返回后 section=service 还原。
**附**:9 档时间预设逐一真机核验:今日 0910~0910 / 昨日 0909 / 近7 0904~0910 / 近30 0812~0910 / 本周 0907~0910(周一周首 ✓)/ 上周 0831~0906 / 本月 0901~0910 / 上月 0801~0831 / 自定义 0901~0907。1366px 驾驶舱与员工总览双页截图目检:KPI 3×2 网阵、双趋势图、表格列均无塌陷/无横向溢出。
## 2. 验收清单核对
**v1 §19.1 员工子集(6 项)**
| # | 项 | 状态 | 依据 |
|---|---|---|---|
| 1 | 能查看所有员工多维状态 | ✅ | total=41(API=列表=CSV 行数三方一致);四视图(管理概览/工作负载/服务质量/客户经营)切换正常 |
| 2 | 任一分数可展开为指标/公式/样本/事实 | ✅ | 表头「?」→ 指标口径抽屉(19 项定义+公式+样本);行/详情下钻按钮 → 事实明细 → 聊天原文(§1 ③) |
| 3 | 四对比 | ✅ | 详情四对比表逐列可见(§1 ③:62/114/107/中位) |
| 4 | 样本不足不评分 | ✅ | 18 人 insufficient_sample 综合分显「--」(§1 ⑥),CSV 同口径 |
| 5 | 异常两次点击进事实 | ✅ | 异常卡员工名(第1击)→ 详情下钻按钮(第2击)→ 事实明细/聊天 |
| 6 | 默认排序非总分/消息量榜 | ✅ | 默认 sort_by=anomaly_severity(异常严重度优先) |
**批次D验收清单(5 项)**
| # | 项 | 状态 | 依据 |
|---|---|---|---|
| 1 | 员工总览:41 人四视图/服务端排序分页/keyword/对比 2-5 人/CSV 含口径注释 | ✅ | §1 ⑤⑥;CSV 前言 3 行 |
| 2 | 员工详情:五维条 3+2 占位/五页签懒加载/四对比/评分明细可展开/下钻进抽屉/账号跳聊天 | ✅ | §1 ③④;页签懒加载+loaded 缓存+section URL 还原 |
| 3 | 驾驶舱:三 KPI 环比箭头/异常卡可跳员工详情 | ✅ | §1 ①②(闭环打通) |
| 4 | 时间预设全套 9 档 | ✅ | §1 附 |
| 5 | URL 全参数保持;四门禁绿;后端仅 dashboard 一处小改且测试补齐 | ✅ | §1 ⑦ + §0;后端改动=dashboard prev_kpis(D3,随测试提交) |
## 3. 走查发现并修复(随本任务提交)
| 问题 | 修复 |
|---|---|
| `AnomalyList.vue` 员工链接 `:underline="false"` 触发 Element Plus 废弃 API 告警——驾驶舱每次加载刷 **31 条** `[el-link] underline boolean deprecated`(异常榜每行一条) | 改 `underline="never"`(string API);修复后全新加载 0 errors 0 warnings |
## 4. 遗留观察项(不阻塞)
| # | 项 | 归档 |
|---|---|---|
| 1 | 五维条行序随接口返回序 | 合规风险/行动执行(待数据)排在三实维之前——非固定语义序;批次E 接线 risk/action 后自然消解,如需固定序前端按 dim 顺序数组排即可 |
| 2 | 走查任务字面 Step6 措辞偏差 | 「切视图服务质量→点综合分列排序」按字面不可执行(综合分列仅管理概览视图有);实际动线=管理概览排序→切服务质量视图导出,已按实际动线完成走查 |
| 3 | 空值显示 `-` 与 `--` 混用 | format.ts 的 formatMsDuration/formatRatio 按各自注释约定 null 显 `-`,员工总览本地数值/比率格式化显 `--`——同表跨列观感不一;纯观感项,批次E 顺手统一即可 |
---
# 批次F-调度接入验证(Task F3,2026-09-10)
F1(`_analytics_tick` 段2 管理域滚动,0dc5993)/ F2(夜间链管理步,2ac63e3)已提交后,本地真机直调 tick 验证 + 本地 scheduler 镜像重建。方法:临时脚本(已删)按 tests/test_scheduler_analytics_tick.py 的构造方式 `SyncScheduler(engine=create_async_engine(get_settings().db_url), session_maker=...)`,不 start、只 `await scheduler._analytics_tick()`,对六个上游函数挂计时包装(与测试 monkeypatch 同一延迟 import 打点位),连跑两次。
## 1. tick 两段实测(本地真机库,连跑两次)
| 段 | 内容 | 第1次(冷) | 第2次(暖) |
|---|---|---|---|
| 总计 | `_analytics_tick` 全程 | **2.78s** | **0.74s** |
| 段1 | recompute_days(staff 滚动3天) | 0.86s | 0.15s |
| 段1 | scanner.run_incremental(敏感词增量) | 0.14s | 0.02s |
| 段2 | pairs_active_in_days(fact 活跃对发现) | 0.04s | 0.02s |
| 段2 | recompute_pairs(fact 重算) | 0.00s | 0.00s |
| 段2 | recompute_metric_days(宽表滚动3天) | 1.51s | 0.52s |
| 段2 | recompute_call_days(通话滚动3天) | 0.22s | 0.02s |
(段值不含各自 session.commit;日志输出:`staff_days=0 scanned=10 hits=0` + `mgmt fact_pairs=0 wide_rows=168 call_rows=0`,两次一致。)
- **远低于 <60s 预算**。本地镜像已停同步 7 天,滚动窗(0908~0910)内无消息/通话活动——`staff_days=0`、`fact_pairs=0`、`call_rows=0` 属停同步后的数据真空而非缺陷;`wide_rows=168`= 3 天 × 56 行/天(39 员工,wechat 38 + whatsapp 18 行/天,双渠道员工两行),宽表对零活跃员工照常出零值行。段2 主成本=宽表重算,随员工数×天数扩展而非会话数,服务器侧活跃会话数百的形态下同量级。
- **幂等 ✓**:三表行数跑前 fact=68,476 / wide=12,371 / calls=1,163 → 第1次后 68,476 / **12,539** / 1,163 → 第2次后 68,476 / 12,539 / 1,163。wide 的 +168 为首跑补齐:0908~0910 三天行在表内原本不存在(本地夜间链停摆 7 天没写过),第1次 DELETE+INSERT 补齐后第2次行数纹丝不动——滚动窗语义下幂等成立。
## 2. 本地 scheduler 容器重建(范围仅本地;服务器镜像 2026-09-09 已重建不碰)
- `docker compose up -d --build scheduler`:镜像 `yunke-scheduler:latest`(`ded96dfc9625`,2026-09-10 构建)重建成功,容器 recreate + 起来。
- 冒烟:`docker exec yunke-scheduler-1 python -c "import app.sync.scheduler"` → ok;容器日志确认 **4 个 job 全注册**——3×`_safe_page_run`(full/messages/full-history)+ **`SyncScheduler._analytics_tick`(F1 新 job 已进夜间/4h 链)**,"Scheduler started" 正常。
- **调度状态**:验证完即 `docker stop`——重建前容器本就 Exited 7 天(本地不跑调度,避免与服务器调度器双跑同刷云客触发风控),维持 stopped 现状;容器已绑新镜像,用户随时 `docker compose start scheduler` 即以新代码上线。mysql/redis 未动。
## 3. 已知边界(归档)
| # | 项 | 说明 |
|---|---|---|
| 1 | phone_call 未进页面同步链 | 通话小表(employee_call_daily)暂从既有静态源重生成——本地 call_rows=0 即此边界的体现;审计 P0-5 由并行会话领地,不在此处理 |
| 2 | 补同步历史日期 | tick 只滚动当日+前2天;更早日期的修正由夜间全量(full_sync 汇总/管理步)覆盖语义兜底 |
| 3 | 本地数据真空 | 本地镜像停摆 7 天,0908~0910 无消息/通话——fact/敏感词扫描的真实活跃量级须在服务器侧(夜间链已跑)观察 |
@@ -1627,6 +1627,7 @@ cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_employee_metric
```python
import math # (文件头补充)
from bisect import bisect_right
from itertools import accumulate
from app.analytics.lifecycle import classify
from app.db.models import Customer, EmployeeMetricDaily, MsgDailyStats
@@ -1657,8 +1658,11 @@ async def _lifecycle_daily(
) -> dict[int, dict[str, dict[str, int]]]:
"""逐日 {day: {user: {state: count}}}——customer 当前归属 + 互动日 sweep。
数据源:msg_daily_stats friend 行全历史(互动日/条数)。16k 客户 × 窗口
天数 bisect,夜间全量约秒级;4h tick 只算当日+3 天。
数据源:msg_daily_stats friend 行全历史(互动日/条数)。hist 为 ≤as_of
的历史消息数(时点语义:与 last 同一 bisect 位取按日累积数组 cum[idx],
as_of 之后的消息不虚增历史日的沉睡门槛——Task6 评审修正)。16k 客户 ×
窗口天数 bisect+classify,夜间全量实测见批次A核实文档;4h tick 只算
当日+3 天。
"""
rows = (
await session.execute(
@@ -1671,12 +1675,13 @@ async def _lifecycle_daily(
)
)
).all()
days_map: dict[str, set[int]] = {}
cnt: dict[str, int] = {}
# peer → {day: 消息条数};days_list 与累积数组 cum 对齐:
# cum[i] = 该客户 ≤days_list[i] 的历史消息数(hist 时点语义)
day_msgs: dict[str, dict[int, int]] = {}
for peer, day, snd, rcv in rows:
days_map.setdefault(peer, set()).add(day)
cnt[peer] = cnt.get(peer, 0) + int(snd or 0) + int(rcv or 0)
interaction = {p: sorted(ds) for p, ds in days_map.items()}
dm = day_msgs.setdefault(peer, {})
dm[day] = dm.get(day, 0) + int(snd or 0) + int(rcv or 0)
interaction = {p: sorted(dm) for p, dm in day_msgs.items()}
custs = (
await session.execute(
@@ -1699,14 +1704,14 @@ async def _lifecycle_daily(
if uid is None:
continue
first = days_list[0]
hist = cnt.get(peer, 0)
cum = list(accumulate(day_msgs[peer][d] for d in days_list))
for d in days:
idx = bisect_right(days_list, d) - 1
if idx < 0:
continue
last = days_list[idx]
prev = days_list[idx - 1] if idx > 0 else None
state = classify(first, last, prev, d, hist)
state = classify(first, last, prev, d, cum[idx])
if state:
out[d].setdefault(uid, {}).setdefault(state, 0)
out[d][uid][state] += 1
@@ -1832,6 +1837,7 @@ async def recompute_metric_days(
)
)
written += 1
await session.flush() # 防御性:防 autoflush=False 配置下 ORM 对象堆积(Task6 评审修正)
return written
```
@@ -0,0 +1,546 @@
# 云客经营管理后台·批次B(评分/异常/管理API)实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在批次A数据底座之上交付只读管理 API——期聚合查询、五维试算评分、确定性异常规则、指标元数据、6 个 `/api/management/*` 端点,OpenAPI 可验证;前端(批次C)紧随其后。
**Architecture:** 新建 `app/management/` 业务层(metric_definitions / employee_metrics 期聚合 / scoring / anomalies),`app/api/routers/management.py` 只做参数校验与编排(照 analytics 惯例:SessionDep + Annotated 参数 + dict 返回)。**本期只做 6 个只读端点**(dashboard / employees / employee detail / metrics sections / drilldown 白名单 / metric-definitions);reviews 与 followups 写端点属批次E。依据:[v2 计划](./2026-09-08-yunke-management-console-implementation-v2.md) §1(决策记录)/§6(API 设计)/§8(Task 3-5);批次A已交付全部数据表与回填。
**Tech Stack:** 同批次A(FastAPI + SQLAlchemy async;测试内存 SQLite;生产 MySQL 8.4)。
---
## 执行者必读:批次A交付物与既定口径
1. **数据表就绪**(迁移 b7c8d9e0f1a2,本地已回填):`first_reply_fact`(68,476 行,PK 含 reply_msg_id)、`employee_metric_daily`(12,371 行,metric_version='v1',串类列与 fact 同源)、`employee_call_daily`(1,163 行)、`employee_score_config`(空,本批次 B0 种子)。
2. **复用模块**:`app/storage/first_reply_facts.py`(fact 表直接查询)、`app/analytics/lifecycle.py`(阈值常量)、`app/analytics/metrics.py` 的 `action_counts`(dashboard 行动入口聚合)、`app/analytics/health.py` 的 `data_as_of`。
3. **周期口径**:start/end 为 yyyymmdd 上海自然日(与 analytics custom scope 同校验:缺一 422、日历非法 422、start>end 422、跨度 ≤366 天);上期=等长紧邻前窗;个人基线=紧邻当前窗之前的 28 天窗。
4. **评分默认(全部标「试算」,config 里可调)**:维度权重 客户经营30/服务25/行动执行20(pending_data)/有效负载15/合规风险10(pending_data);可用维度归一化 ×100/70;样本门槛 min=30 串/soft=10 串(周期内);等级 A≥90/B≥80/C≥70/D≥60/E。
5. **鉴权**:app 级 `require_auth` 全局生效,management 路由无需额外处理;`GET` only。
6. **测试命令**:`cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_management_api.py -v`;提交中文 conventional;master 直提(项目惯例);仓库有并行会话活动——`git status` 后只 add 自己的文件。
7. **前端不做**(批次C另出计划);`schemas_management.py` 留批次E写端点用(analytics 惯例 dict 返回,本期一致);错误响应沿用 analytics 的 `HTTPException(422/404, message)` 风格(v1 §12.4 的稳定 error_code 为有意偏差,前端按 HTTP 状态码分支)。
## 文件地图
| 文件 | 职责 | 动作 |
|---|---|---|
| `yunke-server/app/management/__init__.py` | 包标记(空) | 新建 |
| `yunke-server/app/management/metric_definitions.py` | 指标元数据注册表 + 默认评分配置 DEFAULT_SCORE_CONFIG | 新建 |
| `yunke-server/app/management/employee_metrics.py` | 期聚合查询(宽表 SUM/fact 窗口函数分位/期末生命周期/通话/distinct 活跃/四对比) | 新建 |
| `yunke-server/app/management/scoring.py` | 标准化/维度分/归一化试算/样本保护/等级 | 新建 |
| `yunke-server/app/management/anomalies.py` | 4 条确定性规则 → v1 §5.4 异常卡片 | 新建 |
| `yunke-server/app/sync/cli.py` | `score-config` 种子子命令 | 修改 |
| `yunke-server/app/api/routers/management.py` | 6 只读端点 | 新建 |
| `yunke-server/app/api/main.py` | include_router | 修改 |
| `yunke-server/tests/test_management_scoring.py` 等 4 个测试文件 | 判别测试 | 新建 |
| `docs/superpowers/plans/2026-09-08-management-batch-a-verification.md` | 批次B冒烟数据追加 | 修改(B5) |
---
### Task B0: 指标元数据 + 默认评分配置 + 种子 CLI
**Files:**
- Create: `yunke-server/app/management/__init__.py`(空文件)
- Create: `yunke-server/app/management/metric_definitions.py`
- Modify: `yunke-server/app/sync/cli.py`(`score-config` 子命令)
- Test: `yunke-server/tests/test_management_scoring.py`(本任务先写配置/种子部分)
- [ ] **Step 1: 写测试(先失败)**
`tests/test_management_scoring.py`:
```python
"""管理域评分配置/引擎判别测试(批次B)。"""
import json
import sqlalchemy as sa
from app.db.models import EmployeeScoreConfig
from app.management.metric_definitions import (
DEFAULT_SCORE_CONFIG,
METRIC_DEFINITIONS,
validate_config,
)
def test_config_weights_sum_and_structure():
dims = DEFAULT_SCORE_CONFIG["dimensions"]
assert sum(d["weight"] for d in dims.values()) == 100
# 一期 pending_data 的维度不参与试算
available = [d for d in dims.values() if d.get("status") != "pending_data"]
assert sum(d["weight"] for d in available) == 70 # 30+25+15
assert dims["action"]["status"] == "pending_data"
assert dims["risk"]["status"] == "pending_data"
def test_config_normalization_declared():
assert DEFAULT_SCORE_CONFIG["normalization"] == "available_only_rescale"
def test_validate_config_rejects_bad_weight_sum():
bad = json.loads(json.dumps(DEFAULT_SCORE_CONFIG)) # deep copy
bad["dimensions"]["service"]["weight"] = 26 # 总和 101
problems = validate_config(bad)
assert any("权重" in p for p in problems)
def test_metric_definitions_cover_scored_metrics():
# 每个参与评分的 metric_code 都有元数据(名称/单位/方向)
for dim in DEFAULT_SCORE_CONFIG["dimensions"].values():
for code in dim.get("metrics", {}):
md = METRIC_DEFINITIONS[code]
assert md["name"] and md["unit"] and md["direction"] in (1, -1)
async def test_seed_and_idempotent(make_session):
from app.sync.cli import _seed_score_config # 纯会话函数(见 Step 3)
s = make_session
v1 = await _seed_score_config(s)
await s.commit()
assert v1 == "v1.0"
rows = (await s.execute(sa.select(EmployeeScoreConfig))).scalars().all()
assert len(rows) == 1 and rows[0].status == "active"
# 幂等:已存在同版本返回 None 不新建
assert await _seed_score_config(s) is None
await s.commit()
assert len((await s.execute(sa.select(EmployeeScoreConfig))).scalars().all()) == 1
```
- [ ] **Step 2: 跑测试确认失败**(ModuleNotFoundError: app.management)
- [ ] **Step 3: 实现 metric_definitions.py**
```python
"""管理域指标元数据与默认评分配置(批次B,v2 §6/§10)。
METRIC_DEFINITIONS 是 /api/management/metric-definitions 的单一数据源:
名称/单位/方向/口径说明(含与现有看板的三方口径差异标注)。
DEFAULT_SCORE_CONFIG 为 v1.0 试算起点——所有阈值标「试算」,调整走
新配置版本(employee_score_config),不静默改数。
"""
from __future__ import annotations
# direction: +1 越大越好 / -1 越小越好;caliber 注明与看板差异
METRIC_DEFINITIONS: dict[str, dict] = {
"msg_sent": {"name": "发出消息(1:1)", "unit": "条", "direction": 1,
"caliber": "仅私聊;现有看板为私聊+群聊混合"},
"msg_recv": {"name": "收到消息(1:1)", "unit": "条", "direction": 1,
"caliber": "仅私聊"},
"cust_threads": {"name": "客户消息串", "unit": "串", "direction": 1,
"caliber": "跨日精确,按串末条归属日;看板为日切口径(多日串重复计)"},
"threads_answered": {"name": "被回串数", "unit": "串", "direction": 1,
"caliber": "≡first_reply_fact行数(同源)"},
"fr_avg_ms": {"name": "平均首响", "unit": "ms", "direction": -1,
"caliber": "fr_sum_ms/threads_answered"},
"fr_p50_ms": {"name": "首响中位数", "unit": "ms", "direction": -1,
"caliber": "周期内 fact 精确分位(窗口函数)"},
"fr_p90_ms": {"name": "首响P90", "unit": "ms", "direction": -1,
"caliber": "周期内 fact 精确分位;日切口径会丢跨夜长尾"},
"reply_2h_rate": {"name": "2h回复率", "unit": "0-1", "direction": 1,
"formula": "count(fr_ms<=2h)/cust_threads",
"caliber": "分子分母同源(fact/宽表同一算法)"},
"reply_24h_rate": {"name": "24h回复率", "unit": "0-1", "direction": 1,
"formula": "count(fr_ms<=24h)/cust_threads"},
"active_friends": {"name": "活跃客户(周期)", "unit": "人", "direction": 1,
"caliber": "期内distinct客户(msg_daily_stats直查),非宽表日值求和"},
"active_rooms": {"name": "活跃群(日均)", "unit": "个", "direction": 1},
"new_customer_count": {"name": "新客(期末)", "unit": "人", "direction": 1,
"caliber": "期末单日持有状态,非期内求和"},
"cooling_customer_count": {"name": "降温客户(期末)", "unit": "人", "direction": -1,
"caliber": "距今天数口径(7-29天);频次基线口径二期"},
"dormant_customer_count": {"name": "沉睡客户(期末)", "unit": "人", "direction": -1,
"caliber": "≥30天且历史≥5条"},
"reactivated_customer_count": {"name": "回流客户(期内)", "unit": "人", "direction": 1,
"caliber": "当日回流且前次间隔>30天,期内求和"},
"call_count": {"name": "通话数", "unit": "通", "direction": 1,
"caliber": "语音通话(微信+WA),无消息渠道维度"},
"answered_rate": {"name": "接通率", "unit": "0-1", "direction": 1,
"formula": "answered_call/call_count", "caliber": "接通=duration>0"},
"call_followup_rate": {"name": "通话后跟进率", "unit": "0-1", "direction": 1,
"formula": "call_followup_count/answered_call",
"caliber": "接通后24h内任一账号有1:1消息(含客户发来)"},
"active_share": {"name": "活跃客户占比", "unit": "0-1", "direction": 1,
"formula": "期末active_friends/(期末active+cooling+dormant)",
"caliber": "试算口径:期末单日存量"},
}
# 阈值全部为试算起点(v2 §0 决议:全默认+试算标记);type 三种:
# target : floor..target 线性 0..100(方向已含在 target>floor)
# reverse : ≤target_ms 100 分,≥floor_ms 0 分,中间线性降
# band : 区间内 80..100,低于下限不评分(None),超上限不再加分
DEFAULT_SCORE_CONFIG: dict = {
"version": "v1.0",
"name": "一期试算(3维可用)",
"normalization": "available_only_rescale", # 可用维度 100/Σweight 再 ×100
"trial": True,
"sample_guards": {"min_threads": 30, "soft_threads": 10, "min_days": 5},
"grade_bounds": {"A": 90, "B": 80, "C": 70, "D": 60}, # 低于 D 线为 E
"dimensions": {
"customer": {"weight": 30, "name": "客户经营", "metrics": {
"active_share": {"type": "target", "floor": 0.20, "target": 0.60},
}},
"service": {"weight": 25, "name": "服务质量", "metrics": {
"reply_2h_rate": {"type": "target", "floor": 0.40, "target": 0.85},
"reply_24h_rate": {"type": "target", "floor": 0.75, "target": 0.97},
"fr_p90_ms": {"type": "reverse", "target_ms": 1_800_000,
"floor_ms": 14_400_000}, # 30min..4h
}},
"action": {"weight": 20, "name": "行动执行",
"status": "pending_data"}, # 批次E 接 followups/任务
"workload": {"weight": 15, "name": "有效负载", "metrics": {
"active_friends_band": {"type": "band", "metric": "active_friends",
"low": 5, "high": 80,
"basis": "period_distinct"},
"call_band": {"type": "band", "metric": "call_count",
"low": 1, "high": 200, "basis": "period_total",
"note": "通话低不扣分仅提示;band 得分下限 80"},
}},
"risk": {"weight": 10, "name": "合规风险",
"status": "pending_data"}, # 批次E/三期接风险事件
},
}
def validate_config(config: dict) -> list[str]:
"""配置校验:权重总和/结构/引用的 metric_code 有元数据。返回问题清单。"""
problems: list[str] = []
dims = config.get("dimensions", {})
total = sum(d.get("weight", 0) for d in dims.values())
if total != 100:
problems.append(f"维度权重总和须=100,当前 {total}")
for key, dim in dims.items():
if dim.get("status") == "pending_data":
continue
for code, spec in dim.get("metrics", {}).items():
src = spec.get("metric", code)
if src not in METRIC_DEFINITIONS:
problems.append(f"{key}.{code} 引用未定义指标 {src}")
return problems
```
- [ ] **Step 4: cli.py 加 `score-config` 子命令**
parser(sensitive-scan 与 first-reply-facts 之间任意位置):
```python
p_score = sub.add_parser(
"score-config",
help="Seed the default score config (idempotent).",
description=(
"Insert DEFAULT_SCORE_CONFIG (v1.0, trial) into "
"employee_score_config as active. Skips if the version "
"already exists. Batch B."
),
)
```
命令函数与分发(照 _cmd_daily_stats 惯例):
```python
async def _seed_score_config(session) -> str | None:
"""种子默认评分配置;已存在同版本返回 None(幂等)。纯会话函数可测。"""
from app.db.models import EmployeeScoreConfig
from app.management.metric_definitions import (
DEFAULT_SCORE_CONFIG,
validate_config,
)
problems = validate_config(DEFAULT_SCORE_CONFIG)
if problems:
raise ValueError(f"score config invalid: {problems}")
exists = (
await session.execute(
sa.select(EmployeeScoreConfig.id).where(
EmployeeScoreConfig.version == DEFAULT_SCORE_CONFIG["version"]
)
)
).scalar_one_or_none()
if exists is not None:
return None
session.add(
EmployeeScoreConfig(
version=DEFAULT_SCORE_CONFIG["version"],
name=DEFAULT_SCORE_CONFIG["name"],
status="active",
config_json=DEFAULT_SCORE_CONFIG,
created_by="seed-cli",
)
)
await session.flush()
return DEFAULT_SCORE_CONFIG["version"]
async def _cmd_score_config(args: argparse.Namespace) -> int:
settings = get_settings()
engine, session_maker = await _init_db(settings.db_url)
try:
async with session_maker() as session:
version = await _seed_score_config(session)
await session.commit()
print(f"[score-config] {'seeded ' + version if version else 'already present, skipped'}")
return 0
finally:
await engine.dispose()
```
(cli.py 头部补 `import sqlalchemy as sa` 若无。)
- [ ] **Step 5: 测试通过 + 提交**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_management_scoring.py -v # 5 passed
cd yunke-server && .venv/Scripts/python.exe -m app.sync.cli score-config # 真机种子一次
git add yunke-server/app/management/ yunke-server/app/sync/cli.py yunke-server/tests/test_management_scoring.py
git commit -m "feat(management): 指标元数据+试算评分配置v1.0+种子CLI——权重校验/幂等落库"
```
---
### Task B1: 期聚合查询服务
**Files:**
- Create: `yunke-server/app/management/employee_metrics.py`
- Test: `yunke-server/tests/test_management_period_metrics.py`
核心函数(完整实现):
```python
"""管理域期聚合查询(批次B)——评分/异常/employees API 的单一数据源。
窗口:days=[start_day..end_day] 闭区间 yyyymmdd;上期=等长紧邻前窗;
个人基线=紧邻当前窗之前的 28 天。所有查询走 (day, ...) 索引,不扫消息表
(除 distinct 活跃客户走 msg_daily_stats friend 行)。
"""
from __future__ import annotations
import datetime as dt
from collections import namedtuple
from zoneinfo import ZoneInfo
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import (
EmployeeCallDaily,
EmployeeMetricDaily,
FirstReplyFact,
MsgDailyStats,
Staff,
)
from app.storage.staff_summary import account_map
from app.storage.summary import day_bounds_ms
_SH = ZoneInfo("Asia/Shanghai")
PeriodMetrics = namedtuple("PeriodMetrics", [
"user_id", "days_with_data", "msg_sent", "msg_recv", "cust_threads",
"threads_answered", "fr_sum_ms", "fr_p50_ms", "fr_p90_ms",
"reply_2h_count", "reply_24h_count",
"active_friends", # 期内 distinct(直查,非日值求和)
"active_rooms_avg", # 日均
"new_customer", "cooling_customer", "dormant_customer", # 期末单日存量
"reactivated_customer", # 期内求和
"call_count", "call_seconds", "answered_call", "call_followup",
])
def _day_range(start: int, end: int) -> list[int]:
"""闭区间日键列表(纯 Python,上海自然日语义由调用方保证)。"""
def _d(k): return dt.date(k // 10000, k // 100 % 100, k % 100)
def _k(d): return d.year * 10000 + d.month * 100 + d.day
out, cur = [], _d(start)
while cur <= _d(end):
out.append(_k(cur))
cur += dt.timedelta(days=1)
return out
def prev_window(start: int, end: int) -> tuple[int, int]:
"""等长紧邻前窗。"""
n = len(_day_range(start, end))
def _d(k): return dt.date(k // 10000, k // 100 % 100, k % 100)
def _k(d): return d.year * 10000 + d.month * 100 + d.day
pe = _d(start) - dt.timedelta(days=1)
ps = pe - dt.timedelta(days=n - 1)
return _k(ps), _k(pe)
def baseline_window(start: int) -> tuple[int, int]:
"""个人基线:紧邻当前窗之前的 28 天。"""
def _d(k): return dt.date(k // 10000, k // 100 % 100, k % 100)
def _k(d): return d.year * 10000 + d.month * 100 + d.day
be = _d(start) - dt.timedelta(days=1)
bs = be - dt.timedelta(days=27)
return _k(bs), _k(be)
```
`period_metrics(session, start, end, channel, user_ids=None) -> dict[str, PeriodMetrics]` 实现要点(写全代码进计划):
1. **宽表 SUM**(`WHERE day BETWEEN s AND e AND channel=? AND metric_version='v1' AND user_id IN (...)` GROUP BY user_id):msg_sent/msg_recv/cust_threads/threads_answered/fr_sum_ms/active_rooms(SUM→avg=SUM/天数)、reactivated(SUM)、`days_with_data=COUNT(*)`;**期末存量**:另查 `day=(SELECT MAX(day)≤e 同 user/channel)` 的 new/cooling/dormant 行( correlated 或两段查询——用 `sa.select(...).where(day<=end).order_by(day.desc())` per user 取首行,员工 41 人循环可接受;或一次查询取 (user, MAX(day)) 再 join)。
2. **fact 分位与回复计数**(单条 SQL,窗口函数,双方言):
```python
sub = (
sa.select(
FirstReplyFact.owner_account_id.label("owner"),
FirstReplyFact.fr_ms.label("fr"),
sa.func.row_number().over(
partition_by=FirstReplyFact.owner_account_id, # 列表达式,非别名——MySQL 窗口规范不认 SELECT 别名
order_by=FirstReplyFact.fr_ms,
).label("rn"),
sa.func.count().over(
partition_by=FirstReplyFact.owner_account_id
).label("n"),
).where(FirstReplyFact.day.between(start, end),
FirstReplyFact.channel == channel)
.subquery()
)
# 每 owner:rn=ceil(0.5*n) 行的 fr 即 p50;rn=ceil(0.9*n) 即 p90;
# 回复计数:SUM(fr<=2h)/SUM(fr<=24h) 另用条件聚合直查(不用窗口)。
```
owner→user 经 account_map(channel)。p50/p90 取最接近秩(`rn == (n+1)//2` 与 `rn == max(1, (9*n+9)//10)`——**用「最接近秩」公式并在测试里锁死**:ceil(q·n) 最近邻,与批次A `_percentile` 口径一致)。
3. **reply 计数**:`SELECT owner, SUM(fr_ms<=7200000), SUM(fr_ms<=86400000) FROM first_reply_fact WHERE day BETWEEN ... GROUP BY owner`(MySQL 布尔求和;SQLite 同义 `SUM(CASE WHEN ...)`——用 case 写法双方言安全)。
4. **distinct 活跃客户**:msg_daily_stats friend 行 in window → peer 集合按 account_map 归并到 user → len。
5. **通话**:employee_call_daily SUM(无渠道维;channel 参数只影响消息侧——**通话四列在 channel 查询里也返回全渠道值,响应里标注**)。
6. `team_median`:对每指标取全体 user 的中位数(scoring 层用)。
7. **channel=all**:router 层把 "all" 翻译成 None 传入(照 analytics 惯例,哨兵字符串不进 SQL);channel=None 时两条渠道的 account_map 合并、宽表/fact 查询去渠道过滤、分位与活跃跨渠道合并到同一 user。
**测试**(seed 宽表/fact/通话行 + Staff/account,构造已知分位数集合断言 p50/p90 精确值、期末存量取 ≤e 最近行、distinct 活跃跨日去重、prev/baseline 窗口日期算术含月末)——完整测试代码进计划(约 6 个用例)。
- [ ] Steps: 测试先失败 → 实现 → 通过 → 全量回归 → 提交
`feat(management): 期聚合查询——fact窗口函数精确分位/期末生命周期存量/distinct活跃/前窗基线窗口`
---
### Task B2: 评分引擎 scoring.py
**Files:**
- Create: `yunke-server/app/management/scoring.py`
- Test: 追加 `tests/test_management_scoring.py`
核心(完整代码进计划):
```python
def normalize(spec: dict, value: float | None) -> float | None:
"""单指标 0..100;样本不足/数据缺 → None(不评分,不默认 0/100)。"""
t = spec["type"]
if value is None:
return None
if t == "target":
if value <= spec["floor"]:
return 0.0
if value >= spec["target"]:
return 100.0
return (value - spec["floor"]) / (spec["target"] - spec["floor"]) * 100
if t == "reverse":
if value <= spec["target_ms"]:
return 100.0
if value >= spec["floor_ms"]:
return 0.0
return (spec["floor_ms"] - value) / (spec["floor_ms"] - spec["target_ms"]) * 100
if t == "band":
if value < spec["low"]:
return None # 负载不足→不评分(样本保护语义)
return 100.0 if value <= spec["high"] else 80.0 # 超上限 80 不再加
raise ValueError(t)
def score_employee(config, current: PeriodMetrics, baseline: PeriodMetrics | None,
team: dict[str, float]) -> dict:
"""返回 {total_score, grade, score_status, trial, dimensions{...},
metrics{code: {value, prev, baseline, median, sample_size,
sample_status, score, formula_note}}}。"""
```
规则(写死进测试):
- 周期值派生:`reply_2h_rate = reply_2h_count/cust_threads`(threads=0 → None);`fr_p90_ms` 直取;`active_share` **分子=期末该渠道最近一行(≤end)的 active_friends,分母=同一行的 active_friends+cooling_customer+dormant_customer**(分母 0 → None;宽表无生命周期 active 存量列,active 以 active_friends 列充当);band 用 active_friends 周期 distinct(B2 决议:抗单日噪声,与 B1 口径一致)与 call_count 总量。
- 样本保护:`cust_threads < min_threads(30)` → 该 service/customer 指标 sample_status="insufficient",不评分;soft(10)~min 之间 "low_confidence"(评分但标注)。
- 维度分 = 各指标得分的均值(等权);任一指标 None 时该指标剔除并按剩余归一;**全部 None → 维度 pending**。
- 总分 = Σ(可用维度 weight×dim_score)/Σ(可用维度 weight)×100;可用=pending_data 以外且维度分非 None;全 None → score_status="insufficient_sample"(total None)。
- grade 按 grade_bounds;insufficient → grade None + status。
- `trial=True` 恒返(一期 3 维)。
**测试**(≥8 用例,完整代码进计划):target 边界(floor/target/中点 50)、reverse 两端与中间、band 三段、threads=0 → None 非 0、insufficient_sample 整员工、pending 维度不参与(100/70 归一后 A 线仍可达)、试算标记、全部指标有明细含四对比位。
- [ ] Steps: TDD → 提交 `feat(management): 评分引擎——三类标准化/维度归一/样本保护/等级/完整明细`
---
### Task B3: 异常规则 anomalies.py
**Files:**
- Create: `yunke-server/app/management/anomalies.py`
- Test: `yunke-server/tests/test_management_anomalies.py`
4 条确定性规则(输入=period_metrics 当前+基线+团队中位数;输出=v1 §5.4 卡片列表,完整代码进计划):
| rule_code | 触发 | severity | drilldown |
|---|---|---|---|
| reply_p90_worsened | p90 > 基线p90×1.5 且样本≥soft;×2.5 升 critical | warning/critical | metric_code=first_reply_p90 |
| reply_rate_dropped | reply_2h_rate < 基线×0.7 且当前≥soft 样本 | warning | metric_code=unreplied |
| activity_drop | 日均 msg_total < 基线日均×0.5 且基线≥min_days | warning | 员工详情 |
| workload_overload | active_friends 周期 distinct > band 上限 80 | warning | 员工详情 |
卡片字段照 v1 §5.4 JSON(rule_code/severity/employee_user_id/title/current_value/baseline_value/unit/sample_size/**affected_count**/reason[纯数据事实]/drilldown/suggested_action——文案固定模板,不用 LLM)。`affected_count` 按规则定义:reply_p90_worsened 与 reply_rate_dropped=期内未回串数(cust_threads−threads_answered);activity_drop=当前期活跃客户数;workload_overload=active_friends 日均。
**测试**(≥6 用例):每规则触发/不触发边界、基线 None 不触发、severity 升级阈值、卡片结构字段齐全。
- [ ] Steps: TDD → 提交 `feat(management): 确定性异常规则×4——P90恶化/回复率骤降/活动骤降/过载`
---
### Task B4: 管理 API 6 端点
**Files:**
- Create: `yunke-server/app/api/routers/management.py`
- Modify: `yunke-server/app/api/main.py`(import + include_router(management.router))
- Test: `yunke-server/tests/test_management_api.py`
路由骨架(完整代码进计划,照 analytics 惯例):
```python
router = APIRouter(prefix="/api/management", tags=["management"])
SessionDep = Annotated[AsyncSession, Depends(get_session)]
AllChannelParam = Annotated[Literal["all", "wechat", "whatsapp"], Query()]
YmdParam = Annotated[int, Query(ge=20000101, le=29991231)]
```
| 端点 | 行为要点 |
|---|---|
| `GET /dashboard` | 参数 channel/start/end/compare(previous\|personal_baseline\|team_median,默认 previous)。组装:kpis(活跃客户/活跃员工/待回复[复用 analytics unreplied total]/通话量/生命周期期末分布)+ communication_trend(宽表按日 SUM msg+distinct friends 近 trend 天)+ service_trend(fr 日值 p50/p90 宽表列按日 + reply_2h_rate 派生)+ lifecycle(期末存量分布)+ dimension_summary(全员 3 维分布计数)+ anomalies(B3 全员)+ action_summary(复用 analytics action_counts)+ data_as_of(复用 health)。单次响应聚合,内部并发可用 asyncio.gather |
| `GET /employees` | 参数 channel/start/end/view/keyword/status/sort_by/sort_order/page/page_size(≤100 默认 20)。行=staff 身份(name/is_on_job) + PeriodMetrics + 四对比 + score_employee 结果摘要(total/grade/sample_status/trial) + anomaly_count + anomaly_weight。默认排序=**anomaly_severity**(权重和 critical=2/warning=1 desc,同分再比 count desc——对应 v2 §6「按异常严重度排序」,非单纯计数);keyword 匹配 staff.name/user_id |
| `GET /employees/{user_id}` | 404 校验(staff 无此 user_id);头部身份+账号清单(wechat/whatsapp account 表)+宽表总览+评分明细+异常+基线摘要 |
| `GET /employees/{user_id}/metrics?section=` | section ∈ overview\|service\|customer\|workload\|calls(一期 5 个,v2 §6);每 section 返回该域 PeriodMetrics 明细+四对比+trend(宽表按日序列) |
| `GET /employees/{user_id}/drilldown?metric_code=&start=&end=&channel=&page=&page_size=` | **白名单**:`first_reply_p90|first_reply_p50`→fact 逐串明细(含 thread_end/reply ts/fr_ms+跳聊天参数 owner/talker);`unreplied`→调 analytics unreplied(user 过滤);`cooling_customers|dormant_customers`→customer 表 owner 归属+lifecycle 判定列清单;`call_followup`→phone_call 行清单。白名单外 422 |
| `GET /metric-definitions` | METRIC_DEFINITIONS + DEFAULT_SCORE_CONFIG 摘要(权重/试算标记/样本门槛)+ 三方口径差异说明 |
**API 测试**(完整代码进计划,make_client fixture,≥12 用例):参数默认值/422(custom 缺参、跨度>366、metric_code 白名单外、page_size>100)/404(员工不存在)/响应结构关键字段(data_as_of、metric_version、sample_status、trial 标记)/employees 分页排序/dashboard 各段齐全/渠道过滤生效。
- [ ] Steps: TDD → main.py 注册 → OpenAPI 冒烟(`/docs` 可见 6 端点)→ 全量回归 → 提交
`feat(management): 只读管理API六端点——dashboard/employees/detail/sections/drilldown白名单/指标定义`
---
### Task B5: 真机冒烟 + 性能初测 + 记录
- [ ] 本地起后端(先 `docker stop yunke-backend-1 yunke-frontend-1` 若在跑;`cd yunke-server && .venv/Scripts/python.exe -m uvicorn app.api.main:app --port 8000` 后台);
- [ ] curl 六端点(带 token,从 .env API_TOKEN;无 token 模式则直接)真机各一次,记录响应体积与耗时(目标:dashboard p95≤1.5s、employees≤1.5s——v2 §9 性能线);
- [ ] 抽查:某回测员工(王云青 369D257E…)30 天窗 employees 行——P90/回复率/维度分人工合理性检查(对照核实文档对账数字);
- [ ] 结果(耗时表+抽查截图级数字)追加到批次A核实文档「批次B冒烟」小节;
- [ ] 提交 `docs(management): 批次B真机冒烟——六端点耗时/回测员工抽查/记录`
---
## 批次B验收清单
- [ ] `score-config` 种子幂等,配置过校验,v1.0 active;
- [ ] 期聚合:p50/p90 精确分位(窗口函数)与期末存量口径有判别测试;
- [ ] 评分:三类标准化边界/样本保护/100-70 归一/试算标记全锁测试;
- [ ] 异常:4 规则触发边界 + 卡片结构完整;
- [ ] 6 端点:OpenAPI 可见、参数校验 422/404 齐全、白名单生效、dict 返回含 data_as_of/metric_version/sample_status;
- [ ] 全量测试绿(653 + 新增 ~30);
- [ ] 真机冒烟耗时达标并记录;
- [ ] 未动批次A任何存储层代码(宽表/fact 生成零改动)。
## 批次C-F
批次C:前端(api/management.ts + useManagementFilters + components/management/* + ManagementDashboardView + 路由/导航「经营管理」分组)——批次B合入后出计划,消费真实 API 形态。D:员工总览/详情页。E:followups+快照(写端点+schemas)。F:调度接入(analytics_tick 第三步/full_sync 13-14 步/scheduler 镜像重建)+回测试运行。
@@ -0,0 +1,305 @@
# 云客经营管理后台·批次C(前端导航+管理驾驶舱)实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 交付经营管理后台的第一个可视化页面——管理驾驶舱(`/management/dashboard`)+「经营管理」导航分组;管理者 5 分钟看整体、异常两次点击进事实。
**Architecture:** 前端纯增量:`api/management.ts`(六端点 typed client,契约=批次B终评冻结面)+ `composables/useManagementFilters.ts`(URL query 为真源)+ `components/management/` 六个组件 + `ManagementDashboardView.vue`。复用 useChart/api client/format utils/seq 守卫惯例;不引新 UI 框架、不引雷达图(维度分布用条形图)。依据 [v2 计划](./2026-09-08-yunke-management-console-implementation-v2.md) §4/§5/§7/§13 与批次B契约清单;员工总览/详情页是批次D。
**Tech Stack:** Vue3 + TS + Element Plus + ECharts(useChart 按需已有 Line/Bar)+ vitest(若仓库已配)。
---
## 执行者必读
1. **后端就绪**(本地 8000 已跑或按 DEPLOY 起):六端点见 `/tmp` 之外的真实路径 `/api/management/*`;**契约以批次B终评清单为准**(None≠0;`scope.channel` 回显原值;drilldown 白名单六值;异常卡片 12 字段;`config_source=db`)。
2. **前端惯例**:路由扁平 + `meta.title` + 登录守卫(public 才放行);API 走 `getJson/requestJson`(client.ts 已带 Bearer+baseURL=/api);图表用 `useChart`(容器稳定,不 v-if);请求序号 seq 守卫防旧响应覆盖(DashboardView 现有模式照抄);格式化放 `utils/format.ts` 不重复实现;el-select 空串 value 显示英文占位用 `'all'` 哨兵。
3. **不做**:员工总览/详情页、辅导、快照(D/E 批次);`/analytics/dashboard` 不重定向(并存过渡);二期菜单不渲染不放空页。
4. **验收基准**:v2 §5——5 分钟判总体;异常两次点击到事实;全部卡片显示数据截至与对比窗口;`npm run build` 过。
5. 仓库有并行会话活动——`git status` 后只 add 自己文件;提交中文 conventional;master 直提。
## 文件地图
| 文件 | 职责 | 动作 |
|---|---|---|
| `yunkes/src/api/management.ts` | 六端点 typed client | 新建 |
| `yunkes/src/composables/useManagementFilters.ts` | URL query ↔ 筛选(channel/start/end/compare + 预设窗) | 新建 |
| `yunkes/src/components/management/DataAsOfBadge.vue` | 数据截至/版本/试算角标 | 新建 |
| `yunkes/src/components/management/ManagementKpiCard.vue` | KPI 卡(主值+辅助+点击跳转) | 新建 |
| `yunkes/src/components/management/ManagementFilterBar.vue` | 筛选栏(渠道/时间预设/自定义/口径入口按钮) | 新建 |
| `yunkes/src/components/management/AnomalyList.vue` | 异常榜(severity 标签+数字+下钻事件) | 新建 |
| `yunkes/src/components/management/MetricDrilldownDrawer.vue` | 下钻抽屉(drilldown 端点行清单+聊天跳转) | 新建 |
| `yunkes/src/components/management/MetricDefsDrawer.vue` | 指标口径抽屉(metric-definitions 渲染) | 新建 |
| `yunkes/src/views/management/ManagementDashboardView.vue` | 驾驶舱页 | 新建 |
| `yunkes/src/router/index.ts` | /management/dashboard 路由 | 修改 |
| `yunkes/src/layouts/MainLayout.vue` | 「经营管理」分组 | 修改 |
---
### Task C0: 后端契约补全——drilldown 行补 channel 字段
**Files:** Modify `yunke-server/app/management/api_assembly.py` + `yunke-server/tests/test_management_api.py`
批次B已提交但未部署,前端冻结前补齐(评审发现:fr_fact_rows/lifecycle_customers 的行无 channel 键,前端 channel=all 时 WA 行会跳错聊天路由):
1. `fr_fact_rows` 每行加 `channel`(SELECT 已有该列,透传即可);
2. `lifecycle_customers` 每行加 `channel`(c.channel 透传);
3. 测试断言补两行(drilldown p90 与 cooling/dormant 的响应行含 channel)。
- [ ] `cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_management_api.py -q`(23 passed)+ 全量 -q
- [ ] commit `fix(management): drilldown行补channel字段——前端聊天路由判渠道用(契约冻结前补齐)`
### Task C1: api/management.ts(typed client)
**Files:** Create `yunkes/src/api/management.ts`
完整代码(契约=批次B as-built;先对照后端 router 响应键核对再落笔):
```typescript
// 经营管理板块 API(批次C)。契约以批次B终评冻结面为准:
// None≠0(无样本分位/率=null);scope.channel 回显原值;drilldown 白名单六值。
import { getJson } from './client'
import type { Channel } from './types'
export type MgmtChannel = 'all' | Channel
export type CompareMode = 'previous' | 'personal_baseline' | 'team_median'
export type DrilldownCode =
| 'first_reply_p90' | 'first_reply_p50' | 'unreplied'
| 'cooling_customers' | 'dormant_customers' | 'call_followup'
export interface MgmtScope { channel: MgmtChannel; start: number; end: number; compare?: CompareMode; view?: string; user_id?: string; metric_code?: string }
export interface Kpis {
active_customers: number; active_employees: number
msg_total: number; cust_threads: number; threads_answered: number
call_count: number; call_seconds: number; answered_call: number
lifecycle: { new: number; cooling: number; dormant: number; reactivated: number }
}
export interface CommTrendPoint { day: number; msg_total: number; active_customers: number; call_cnt: number }
export interface SvcTrendPoint { day: number; fr_p50_ms: number | null; fr_p90_ms: number | null; reply_2h_rate: number | null }
export interface DimSummary { [dim: string]: { name: string; weight: number; scored_count: number; pending_count: number; insufficient_count: number; avg_score: number | null } }
export interface AnomalyCard {
rule_code: string; severity: 'critical' | 'warning'; employee_user_id: string
title: string; current_value: number; baseline_value: number | null; unit: string
sample_size: number; affected_count: number; reason: string
drilldown: { type: string; query: { metric_code?: string; user_id?: string } }
suggested_action: string
}
export interface DashboardResp {
scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default'
kpis: Kpis
communication_trend: CommTrendPoint[]
service_trend: SvcTrendPoint[]
lifecycle: Kpis['lifecycle']
dimension_summary: DimSummary
anomalies: AnomalyCard[]
action_summary: { unreplied_2h: number; churn_real_30d: number; sensitive_pending: number }
}
export interface EmployeeRow {
user_id: string; name: string | null; is_on_job: boolean | null
metrics: Record<string, number | null>
comparisons: Record<string, { current: number | null; prev: number | null; baseline: number | null; median: number | null }>
score: { total_score: number | null; grade: string | null; score_status: string; trial: boolean; dimensions: Record<string, { score: number | null; status: string }> }
anomaly_count: number; anomaly_weight: number
}
export interface EmployeesResp { total: number; page: number; page_size: number; rows: EmployeeRow[]; scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default' }
export interface FactDrillRow { day: number; channel: string; owner_account_id: string; peer_id: string; thread_end_ts: number; reply_ts: number; fr_ms: number }
export interface LifecycleDrillRow { friend_id: string; channel: string; nickname: string | null; last_day: number; state: string }
export interface CallDrillRow { call_id: string; channel: string; call_number: string | null; start_call_time: number; call_duration: number }
export interface DrilldownResp { total: number; items: Record<string, unknown>[]; note?: string; truncated?: boolean; summary?: Record<string, number> }
export interface MetricDefsResp {
definitions: Record<string, { name: string; unit: string; direction: number; caliber?: string; formula?: string }>
score_config: { version: string; name: string; trial: boolean; normalization: string; sample_guards: Record<string, number>; grade_bounds: Record<string, number>; dimensions: Record<string, { name: string; weight: number; status?: string }> }
caliber_notes: string
config_source: 'db' | 'default'
}
export const getDashboard = (channel: MgmtChannel, start: number, end: number, compare: CompareMode = 'previous') =>
getJson<DashboardResp>('/management/dashboard', { channel, start, end, compare })
export const getEmployees = (params: Record<string, unknown>) =>
getJson<EmployeesResp>('/management/employees', params)
export const getEmployeeDrilldown = (userId: string, metricCode: DrilldownCode, start: number, end: number, channel: MgmtChannel, page = 1, pageSize = 20) =>
getJson<DrilldownResp>(`/management/employees/${userId}/drilldown`, { metric_code: metricCode, start, end, channel, page, page_size: pageSize })
export const getMetricDefs = () => getJson<MetricDefsResp>('/management/metric-definitions')
```
(员工详情/sections 两个端点的 client 函数批次D 再加——本期页面不消费,YAGNI;本期 client 为四函数。)
- [ ] 步骤:写文件 → `cd yunkes && npx vue-tsc --noEmit`(或 build)类型过 → 后端(8000,dev 5173 的 /api 代理已指向它)冒烟一次 getDashboard 类型与真实响应吻合(console 对比关键字段)→ commit `feat(management): 前端API client——管理端点typed契约(批次B冻结面)`
### Task C2: useManagementFilters composable
**Files:** Create `yunkes/src/composables/useManagementFilters.ts`(+ vitest 若仓库已配测试脚本,先 `grep '"test"' yunkes/package.json` 确认)
完整代码:
```typescript
// 管理页统一筛选(URL query 为真源——刷新/分享保持)。批次C:驾驶舱用;
// 批次D 员工页复用(view 等额外参数由页面自行挂 query)。
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
export type Preset = 'last7' | 'last30' | 'thisMonth' | 'lastMonth' | 'custom'
export const CHANNELS = [
{ value: 'all', label: '全部渠道' },
{ value: 'wechat', label: '微信' },
{ value: 'whatsapp', label: 'WhatsApp' },
] as const
function ymd(d: Date): number { return d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate() }
function addDays(d: Date, n: number): Date { const x = new Date(d); x.setDate(x.getDate() + n); return x }
/** 预设 → [start,end] yyyymmdd(上海自然日由本地时区近似——与后端一致的公历日)。 */
export function presetRange(p: Preset, today = new Date()): { start: number; end: number } {
const t0 = new Date(today.getFullYear(), today.getMonth(), today.getDate())
switch (p) {
case 'last7': return { start: ymd(addDays(t0, -6)), end: ymd(t0) }
case 'last30': return { start: ymd(addDays(t0, -29)), end: ymd(t0) }
case 'thisMonth': return { start: ymd(new Date(t0.getFullYear(), t0.getMonth(), 1)), end: ymd(t0) }
case 'lastMonth': {
const first = new Date(t0.getFullYear(), t0.getMonth() - 1, 1)
const last = new Date(t0.getFullYear(), t0.getMonth(), 0)
return { start: ymd(first), end: ymd(last) }
}
}
// custom 由调用方直传
return { start: ymd(t0), end: ymd(t0) }
}
export function useManagementFilters() {
const route = useRoute()
const router = useRouter()
const channel = computed<'all' | 'wechat' | 'whatsapp'>(() => {
const v = route.query.channel
return v === 'wechat' || v === 'whatsapp' ? v : 'all'
})
const start = computed(() => {
const v = Number(route.query.start)
return Number.isFinite(v) && v >= 20000101 ? v : presetRange('last30').start
})
const end = computed(() => {
const v = Number(route.query.end)
return Number.isFinite(v) && v >= 20000101 ? v : presetRange('last30').end
})
const compare = computed(() => {
const v = route.query.compare
return v === 'personal_baseline' || v === 'team_median' ? v : 'previous'
})
function apply(patch: Record<string, string | number | undefined>) {
const q: Record<string, string> = {}
for (const [k, v] of Object.entries({ ...route.query, ...patch })) {
if (v !== undefined && v !== null && v !== '') q[k] = String(v)
}
router.replace({ query: q })
}
// 预设切换:清掉 start/end 让默认值生效,custom 则带具体日期
function applyPreset(p: Preset, custom?: { start: number; end: number }) {
if (p === 'custom' && custom) apply({ start: custom.start, end: custom.end })
else apply({ start: undefined, end: undefined })
}
return { channel, start, end, compare, apply, applyPreset, presetRange }
}
```
- [ ] vitest(若已配):presetRange 五预设的日期算术(含月末/跨月)+ query 缺省回退 last30 + apply 写回 query。未配则手测并在报告注明。
- [ ] commit `feat(management): useManagementFilters——URL query 真源/预设窗/渠道哨兵`
### Task C3: 六个基础组件
**Files:** Create `components/management/` 下六件。规格(完整 SFC 代码进实施,要点钉死):
1. **DataAsOfBadge**:props `{ dataAsOf: number | null; metricVersion?: string; trial?: boolean; configSource?: string }`;渲染 el-tag 组:数据截至(`data_as_of` ms→`YYYY-MM-DD HH:mm`,null 显示「无数据」灰 tag)+ 指标版本 + 试算(warning 色,trial=true 才显示)。格式化用 utils/format.ts 现有函数,无则组件内小 helper。
2. **ManagementKpiCard**:props `{ label: string; value: number | null; unit?: string; hint?: string; to?: string }`;主值大字(null 显示 `--`),hint 小字,有 to 则整卡 clickable `router.push`。不做趋势箭头(对比值批次D 表格里呈现)。
3. **ManagementFilterBar**:props modelValue 透传 filters composable 的值 + `@apply`;内容:el-radio-group 渠道(CHANNELS)+ el-select 预设(四预设+自定义)+ 自定义时 el-date-picker type=daterange(value-format YYYYMMDD 字符串转 number)+「指标口径」文字按钮(emit open-defs)+ 插槽放 DataAsOfBadge。改即 apply(防抖 300ms 不需要——change 事件天然低频)。
4. **AnomalyList**:props `{ anomalies: AnomalyCard[] }`;el-table 或列表卡片:severity(critical=红 danger tag「严重」/warning=橙 warning tag「关注」)、title、current vs baseline(unit 感知格式化:ms→时长/0-1→%/其他千分位)、sample_size、affected_count、suggested_action(灰小字);行 click → emit `drill(card)`。空态「暂无异常」。
5. **MetricDrilldownDrawer**:props `{ userId: string; card: AnomalyCard | null; start: number; end: number; channel: MgmtChannel }`(v-model:visible);打开时按 card.drilldown.query.metric_code 调 getEmployeeDrilldown:
- `first_reply_p90/p50` → 表格:客户(peer_id)/串末条时间/回复时间/首响时长(fr_ms 降序已由后端);行点击跳聊天:`/staff-chat/{owner_account_id}/single/{peer_id}`(channel=whatsapp 时 `/chat/whatsapp/{owner}/{peer}`)。
- `unreplied` → 表格 peer/等待时长/预览;note/truncated 提示。
- `cooling/dormant_customers` → 表格 客户/最后互动日/状态;行点击跳该客户聊天(同上映射)。
- `call_followup` → 表格 通话(时间/号码/时长)+ summary 卡。
分页 el-pagination(后端分页);员工姓名/标题显示 card.employee_user_id 对应名字由父传(dashboard anomalies 无 name——显示 user_id 短版即可,批次D 带名字)。
6. **MetricDefsDrawer**:v-model:visible;打开调 getMetricDefs;el-collapse 按 维度/单指标 两组渲染:每指标 name+unit+direction(越大越好/越小越好)+formula+caliber;顶部 score_config 摘要(版本/试算标记/样本门槛/各维度权重与 pending 状态)+ caliber_notes 段落。
- [ ] 步骤:六组件逐个写 + vue-tsc 过 → 冒烟渲染(C5 页面里真用)→ commit `feat(management): 驾驶舱六组件——KPI卡/筛选栏/异常榜/下钻抽屉/口径抽屉/数据截至角标`
### Task C4: 路由 + 导航
- router/index.ts 在 analytics 四页之后加:
```typescript
{
// 经营管理板块(批次C):驾驶舱;员工分析/考核快照批次D/E再加
path: '/management/dashboard',
name: 'management-dashboard',
component: () => import('@/views/management/ManagementDashboardView.vue'),
meta: { title: '管理驾驶舱' },
},
```
- MainLayout「数据分析」el-sub-menu 之前加「经营管理」分组(单项:管理驾驶舱 → goPlain('/management/dashboard'))——分组在最上(管理优先级高于查看)。
- 验证:登录态点菜单进页面、刷新保持、activeMenu 高亮正确。
- [ ] commit `feat(management): 经营管理导航分组+驾驶舱路由`
### Task C5: ManagementDashboardView
**Files:** Create `yunkes/src/views/management/ManagementDashboardView.vue`
结构(完整 SFC 进实施,要点钉死):
```text
<ManagementFilterBar>(含 DataAsOfBadge + 口径按钮)
六个 KPI 卡(el-row/gutter,1366px 两行三卡):
活跃客户(kpis.active_customers,hint=串/被回)→无跳转(批次D员工页)
活跃员工(kpis.active_employees,hint=期内有数据天数>0 口径)
消息量(msg_total,hint=1:1口径·与数据看板口径不同)
待回复(action_summary.unreplied_2h,红点>0)→/analytics/unreplied
即将流失(action_summary.churn_real_30d)→/analytics/churn
风险待处理(action_summary.sensitive_pending)→/analytics/sensitive
双图表(el-row 两列,useChart×2):
客户沟通趋势:Line msg_total + Bar call_cnt 双 y 轴(x=day)
服务质量趋势:双 y 轴——左轴 ms(Line fr_p50_ms/fr_p90_ms,tooltip 时长格式化)、
右轴 0-1(Line reply_2h_rate,%)——不注明双轴则率线贴底
——图内空数据/None 点处理:reply_2h_rate null 断点(connectNulls=false)
双条形图(el-row 两列):
生命周期分布:Bar new/cooling/dormant/reactivated 四类
员工维度分布:Bar dimension_summary 三维 avg_score(0-100)+pending 数 tooltip
异常榜(AnomalyList @drill → MetricDrilldownDrawer)
seq 守卫:filters 变化 → seq++ → 请求 → 回来校验 seq 才渲染(照 DashboardView 模式)
loading:el-skeleton 首屏;错误:el-alert + 重试按钮(保留上次数据)
```
要点:
- `watch([channel, start, end, compare], load)`;`onMounted(load)`;
- day 轴 `20260810`→`08-10` 短格式;
- **共享格式化**:把 DashboardView 里的 `fmtFr`(ms→时长)/`fmtPct`(0-1→%) 提升为 `utils/format.ts` 的 `formatMsDuration`/`formatRatio`(AnomalyList/抽屉/图表 tooltip 三处消费,不复制第三份);
- 异常榜点卡片 → drawer(metric_code 白名单外 type=employee 的卡片——activity_drop/overload 无 drilldown 端点,drawer 显示「员工分析批次D上线」占位说明——**两类 drilldown.type=employee 的卡片点击行为:显示 el-empty 占位**);
- 试算角标常显(config_source 透传)。
- [ ] 步骤:写页面 → `npm run build` 过 → 本地前后端真机走查(起 8000 后端 + `npm run dev` 5173,Playwright 或手动):六卡有数、双图渲染、异常卡点开抽屉、抽屉行跳聊天、口径抽屉渲染、刷新 URL 保持、1366px 布局不塌 → commit `feat(management): 管理驾驶舱页——KPI/双趋势/生命周期/维度分布/异常两次点击到事实`
### Task C6: 收尾验收
- [ ] `cd yunkes && npm run build`(0 error);
- [ ] `npm run test --silent`(若配了 vitest)或记录未配;
- [ ] 验收清单核对(v2 §5.3/§19.1 驾驶舱子集):5 分钟判总体 ✓/异常两次点击进事实 ✓/口径入口 ✓/数据截至+对比窗口显示 ✓/渠道筛选 ✓/URL 保持 ✓/试算标记 ✓/空态与错误态 ✓;
- [ ] 全量后端测试仍绿(前端不动后端,确认无意外);
- [ ] commit(若剩零散修正)。
---
## 批次C验收清单
- [ ] 「经营管理」分组出现且仅含管理驾驶舱;
- [ ] 六 KPI 卡数字与后端响应一致(抽查待回复/风险与 analytics 页对得上);
- [ ] 双趋势图渲染 30 点、None 断点正确;
- [ ] 异常卡 critical 红标在前;点击 metric 类卡片→抽屉→行→聊天定位(两次点击到事实);
- [ ] employee 类异常卡占位不报错;
- [ ] 口径抽屉含试算标记与三方口径差异说明;
- [ ] URL query 刷新/分享保持筛选;
- [ ] `npm run build` 过;后端 722 测试不受影响。
## 批次D-F
D:员工总览+详情页(消费 employees/detail/sections 端点+指标解释抽屉);E:辅导闭环+考核快照(写端点);F:调度接入+dashboard 性能修复(unreplied 索引)+部署上线。
@@ -0,0 +1,172 @@
# 云客经营管理后台·批次D(员工总览+员工详情)实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 补上管理闭环最大断点——员工总览(41 人多维一张表)与员工详情(五维/页签/四对比/事实下钻),打通「异常→员工全景→事实」链路;附带驾驶舱 KPI 环比与时间预设补全(体验级小项)。
**Architecture:** 纯前端为主(消费批次B已就绪的 employees/detail/metrics sections/drilldown 端点)+ 两处小后端增强(dashboard kpis 加 prev 块;无新端点)。新页 `EmployeeAnalyticsView`/`EmployeeAnalyticsDetailView` + 对比抽屉;复用 C3 六组件(FilterBar/DataAsOfBadge/AnomalyList/MetricDrilldownDrawer/MetricDefsDrawer)与 useManagementFilters。依据 [v2 计划](./2026-09-08-yunke-management-console-implementation-v2.md) §6/§7/§13 + 批次B契约清单。
**Tech Stack:** 同批次C(Vue3+TS+Element Plus+useChart+vitest);后端 FastAPI 小改。
---
## 执行者必读
1. **消费的端点 as-built 形态**(写 client 前已核对 management.py:321-443):
- `GET /employees`(行结构见批次C client 的 EmployeeRow——已含 metrics/comparisons/score 摘要/anomaly_count/weight);
- `GET /employees/{id}`:`{user_id,name,is_on_job,accounts[{channel,account_id,nick_name,user_name}],metrics(22字段dict 含 user_id 键),score{total_score,grade,score_status,trial,dimensions{name,weight,score,status,metrics{code:{value,score,sample_size,sample_status,formula_note}}},metrics_detail{code:{value,prev,baseline,median,sample_size,sample_status,score,unit,name}}},anomalies[],prev(dict|null),baseline(dict|null),scope,data_as_of,metric_version,config_source}`;
- `GET /employees/{id}/metrics?section=`:`{section,metrics(该域字段dict),trend[{day,cols...}](无数据日计数0/分位None),comparisons(源指标名键),scope,data_as_of,metric_version}`;
- drilldown/metric-definitions 已有 client。
2. **前端惯例**同批次C必读(seq 守卫/useChart/URL query/csvExport.ts 全量翻页+中文 BOM/命名路由跳转)。
3. **不做**:辅导/快照(E);员工对比不新开 API(勾选 2-5 行客户端拼数据);「指标解释抽屉」= 复用 MetricDefsDrawer(点击列头 ? 图标打开)——不做每指标单独解释面板(v1 §6.5 的完整版二期)。
4. 仓库并行会话照旧;中文 conventional 提交;master 直提。
## 文件地图
| 文件 | 动作 |
|---|---|
| `yunkes/src/api/management.ts` | 补 getEmployeeDetail/getEmployeeMetrics + types(D0) |
| `yunkes/src/composables/useManagementFilters.ts` | 预设补 今日/昨日/本周/上周(D0) |
| `yunkes/src/views/management/EmployeeAnalyticsView.vue` | 新建(D1) |
| `yunkes/src/components/management/EmployeeCompareDrawer.vue` | 新建(D1,对比抽屉) |
| `yunkes/src/views/management/EmployeeAnalyticsDetailView.vue` | 新建(D2) |
| `yunkes/src/router/index.ts` + `MainLayout.vue` | 两路由+两菜单(D1/D2 各自) |
| `yunke-server/app/api/routers/management.py` + `api_assembly.py` | dashboard kpis 加 prev 块(D3) |
| `yunkes/src/components/management/ManagementKpiCard.vue` + `AnomalyList.vue` + 驾驶舱页 | delta 显示 + 员工跳详情(D3) |
---
### Task D0: client 补全 + 预设扩展
- [ ] `api/management.ts` 追加:
```typescript
export interface EmployeeAccount { channel: string; account_id: string; nick_name: string | null; user_name: string | null }
export interface ScoreMetricDetail { value: number | null; prev: number | null; baseline: number | null; median: number | null; sample_size: number; sample_status: string; score: number | null; unit: string; name: string }
// formula_note 在 dimensions[dim].metrics[code] 上(scoring.py 维度级明细),不在 metrics_detail
export interface EmployeeDetailResp {
user_id: string; name: string | null; is_on_job: boolean | null
accounts: EmployeeAccount[]
metrics: Record<string, number | string | null>
score: {
total_score: number | null; grade: string | null; score_status: string; trial: boolean
dimensions: Record<string, { name: string; weight: number; score: number | null; status: string; metrics?: Record<string, { value: number | null; score: number | null; sample_size: number; sample_status: string; formula_note?: string }> }>
metrics_detail: Record<string, ScoreMetricDetail>
}
anomalies: AnomalyCard[]
prev: Record<string, number | string | null> | null
baseline: Record<string, number | string | null> | null
scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default'
}
export interface SectionResp {
section: string; metrics: Record<string, number | null>
trend: Record<string, number | null>[]
comparisons: Record<string, { current: number | null; prev: number | null; baseline: number | null; median: number | null }>
scope: MgmtScope; data_as_of: number | null; metric_version: string
}
export type MetricsSection = 'overview' | 'service' | 'customer' | 'workload' | 'calls'
export const getEmployeeDetail = (userId: string, channel: MgmtChannel, start: number, end: number) =>
getJson<EmployeeDetailResp>(`/management/employees/${userId}`, { channel, start, end })
export const getEmployeeMetrics = (userId: string, section: MetricsSection, channel: MgmtChannel, start: number, end: number) =>
getJson<SectionResp>(`/management/employees/${userId}/metrics`, { section, channel, start, end })
```
(写入前 curl 真机抽查 detail 响应键核对——同批次C C1 惯例,以真实响应为准修。)
- [ ] `useManagementFilters.ts` 预设补:`Preset` 加 `'today' | 'yesterday' | 'thisWeek' | 'lastWeek'`;`presetRange` 四分支(today=当天单日窗;yesterday=昨日单日;thisWeek=本周一..今天;lastWeek=上周一..周日,**周一为一周起点**);vitest 补 5 例(today/yesterday/thisWeek 周中+周一/lastWeek 跨月)。FilterBar 的预设选项组同步加(el-select options),分组显示:常用(今日/昨日/近7/近30)/日历(本周/上周/本月/上月)/自定义。
- [ ] vue-tsc + `npm run test`(23+ 过)+ commit `feat(management): client补员工详情/分段接口+时间预设补今日昨日本周上周`
### Task D1: 员工总览页
**Files:** Create `EmployeeAnalyticsView.vue` + `EmployeeCompareDrawer.vue`;改 router(`/management/employees`)+ MainLayout(管理驾驶舱下加「员工分析」菜单项)。
**页面结构**:
```text
ManagementFilterBar(复用;compare 选择器在此页透出——el-select 对比方式:上期/个人基线/团队中位数→apply({compare}))
工具行:keyword el-input(placeholder 姓名/ID搜索) | status el-select(全部/在职/离线) | 视图 el-radio-group×4
(management=概览/workload=负载/service=服务/customer=客户经营——切换列组)| 指标口径按钮 | 导出 CSV | DataAsOfBadge
主表 el-table(服务端排序 sort-change→query;行选择框多选 2-5 人→「对比」按钮开抽屉):
固定列:姓名(链接进详情)+ID 小字、在职 tag
management 视图列:综合分(total_score+grade tag+trial*)、异常(anomaly_count 红数字+weight)、活跃客户、消息量、串/被回、维度三列(customer/service/workload score 各 0-100 或「--」)
workload 视图列:活跃客户、活跃群(日均)、消息收/发、通话量/秒、串
service 视图列:首响P50/P90(formatMsDuration)、2h回复率、24h回复率(formatRatio)、被回/串
customer 视图列:新客/降温/沉睡/回流(期末+期内注)、活跃占比
列头 ? 图标→打开 MetricDefsDrawer(复用)
空数据/样本不足单元格显示「--」灰字(sample_status=insufficient 时 total 亦然)
分页 el-pagination(服务端 page/page_size 20;total)
```
**行为**:query 真源(view/keyword/status/sort_by/sort_order/page 全进 URL);seq 守卫;**服务端排序仅四列**(后端 sort_by 白名单:anomaly_severity[默认]/total_score/active_friends/cust_threads——其余列头不显示排序箭头,tooltip「该列不支持服务端排序」);`sort_by=total_score` 时 null 排最后由后端保证;compare 参数仅客户端显示态(employees 端点无此参数,行恒带全部四对比值);**CSV**:扩展 `utils/csvExport.ts` 支持可选前置注释行(`exportListToCsv(..., preamble?: string[])`——含口径说明如「1:1口径·窗口 20260810-20260908·试算」)+ fetchPage 适配器(/employees 返回 `{rows,total}`,适配为 csvExport 期望的 `{items,total}`),导出当前视图列全量翻页。
**EmployeeCompareDrawer**:props `{ rows: EmployeeRow[] }`(勾选行已有数据,不另发请求);内容:五维分对比条(3 可用维度每人一行,横向 bar 0-100)+ 关键指标表(活跃客户/P90/2h率/异常数 每人一列)+ 顶部说明「同口径同时窗对比,不产生优劣结论」;>5 人禁开。
**路由/菜单**:
```typescript
{ path: '/management/employees', name: 'management-employees',
component: () => import('@/views/management/EmployeeAnalyticsView.vue'),
meta: { title: '员工分析' } },
```
- [ ] vue-tsc/test/build + 真机初查(表格渲染 41 人/切视图列变/排序翻页/keyword/CSV 打开内容对)+ commit `feat(management): 员工总览页——四视图/服务端排序分页/对比抽屉/CSV导出`
### Task D2: 员工详情页
**Files:** Create `EmployeeAnalyticsDetailView.vue`;router 加 `/management/employees/:userId`(props:true;**activeMenu 需高亮父菜单「员工分析」**——MainLayout 的 el-menu :default-active 绑定的是路径,嵌套路由时用 route.meta 或 watch 设 index——看 MainLayout 现有 active 处理方式,最简方案:菜单 index 用路径匹配 + 详情页 route path 前缀匹配高亮,若现结构不支持则 meta.activeMenu='/management/employees' 并在 el-menu :default-active 计算)。
**页面结构**:
```text
头部卡:姓名(+在职 tag)+ID | 账号清单(渠道 tag+account_id+昵称,点击跳 staff-chat/通讯录按渠道)
| 本期试算分大数字+grade tag+score_status(insufficient 时灰注「样本不足·不参与考核」)+trial*
| DataAsOfBadge | 返回列表链接
五维条(v1 §7.2 概览的核心):横向五条 bar——3 可用维度实分数值,action/risk 灰「待数据」占位
(ScoreBreakdown 简版:直接在页内画,不新建组件,el-progress/自定义 div)
el-tabs 五页签(懒加载:激活才调 getEmployeeMetrics):
概览: 六数卡取自**detail.metrics**(msg/活跃/串/通话——section 端点字段有限,期值以 detail 为准)
+ 四对比小表(comparisons 直渲染,源指标名行)
服务质量: 数卡 P50/P90 + 2h/24h回复率(**客户端从 metrics.reply_2h_count/cust_threads 派生,
与 derive_metrics 同式**——section 趋势无每日率序列) + 趋势图=**首响 P50/P90 双线(ms 轴)
±串数(次轴可选)**——不做率右轴(员工级无每日率数据,率的团队级序列在驾驶舱)
客户经营: 期末四态数卡(取自 detail.metrics) + 降温/沉睡下钻按钮(→DrilldownDrawer lifecycle 分支)
工作负载: 数卡取 detail.metrics(收发/活跃客户/群/通话量) + 趋势图用 section=workload 的
active_friends/active_rooms 双线(trend 列为日值 active_rooms)
通话: 通话四数(detail.metrics) + call_followup 下钻按钮
**页签下钻按钮与 MetricDrilldownDrawer 的衔接**:抽屉是 card 驱动——按钮合成最小伪卡片
`{title:'首响P90明细', drilldown:{type:'metric',query:{metric_code:'first_reply_p90',user_id}}}` 传入
异常区:AnomalyList(复用,@drill→MetricDrilldownDrawer 复用)
评分明细折叠面板(el-collapse「评分怎么算出来的」): metrics_detail 表(code/名称/当前/上期/基线/中位/样本/得分)
+ prev/baseline 原始 PeriodMetrics 两个 JSON 折叠(调试用,灰字注明)
```
**行为**:query 真源(channel/start/end/compare/section 进 URL,页签切换 replace);seq 守卫每页签独立(切页签取消旧渲染);404 员工→el-result 错误页+返回链接;「创建辅导记录」按钮**占位禁用**(tooltip「批次E上线」)。
- [ ] vue-tsc/test/build + 真机初查(王云青详情:五维条/五页签懒加载/异常/评分明细展开/账号跳聊天)+ commit `feat(management): 员工详情页——五维条/五页签懒加载/四对比/评分明细/事实下钻复用`
### Task D3: 驾驶舱 KPI 环比 + 异常跳员工
**后端**(`management.py` dashboard + `api_assembly.py`):①dashboard 端点 kpis 加 `prev_kpis` 块(active_customers/active_employees/msg_total/cust_threads——dashboard 走 assemble_employees(with_prev=False),需加一次 prev 窗 period_metrics 全员调用,B5 实测三窗合计 ~0.3s,加一窗 +0.1s 量级)+ `msg_delta`((cur-prev)/prev,prev=0→null)。lifecycle/通话不加环比(期末/全渠道口径环比意义弱,注明)。②**异常卡补 `employee_name`**(assemble_employees 手里已有全员 name——组装 anomalies 时带上,一行);同步 AnomalyCard type。测试补:dashboard 响应含 prev_kpis 且数字=prev 窗聚合 + anomalies[0].employee_name 非空。
**前端**:KpiCard 加 `delta?: number | null` prop(↑绿↓红,方向语义由调用方定——涨=好传正绿;不传不显示);驾驶舱三卡接 delta(活跃客户/活跃员工/消息量);AnomalyList 的 title 下加员工行(`employee_name` 链接→`/management/employees/{user_id}`)+ 新增 `showEmployee?: boolean` prop(默认 true;D2 员工详情页复用时传 false 免自链接)。
- [ ] 后端 pytest(test_management_api.py 补 prev_kpis 断言)+ 前端走查三卡箭头/异常跳员工 + commit `feat(management): 驾驶舱KPI环比(prev_kpis+delta箭头)+异常卡跳员工详情`
### Task D4: 真机走查 + 验收
- [ ] 全链路走查(Playwright):驾驶舱→异常卡→**员工详情**(新链路!)→页签→下钻→聊天;员工总览→对比抽屉→CSV;1366px;刷新保持;
- [ ] 验收对照(v1 §19.1 员工子集):能查看所有员工多维状态 ✓/任一分数展开为指标公式样本事实 ✓(评分明细面板)/四对比 ✓/样本不足不评分显示 ✓/异常两次点击进事实 ✓/默认排序非总分榜 ✓;
- [ ] 四门禁(vue-tsc/vitest/build/后端 pytest);
- [ ] commit 收尾零散 + 走查记录追加核实文档「批次D走查」小节。
---
## 批次D验收清单
- [ ] 员工总览:41 人四视图/服务端排序分页/keyword/对比 2-5 人/CSV 含口径注释;
- [ ] 员工详情:五维条 3+2 占位/五页签懒加载/四对比/评分明细可展开/三处下钻按钮进抽屉/账号跳聊天;
- [ ] 驾驶舱:三 KPI 有环比箭头/异常卡可跳员工详情(闭环打通);
- [ ] 时间预设全套(今日/昨日/近7/近30/本周/上周/本月/上月/自定义);
- [ ] URL 全参数保持;四门禁绿;后端仅 dashboard 一处小改且测试补齐。
## 批次E-F
E:followups 辅导闭环(写端点+schemas_management+详情页按钮激活)+ 考核快照(生成/冻结/列表/导出)。F:调度接入(analytics_tick 第三步+full_sync 13/14 步+scheduler 镜像重建还 9d3cc8e 旧账)+ dashboard 性能修复(unreplied 索引三选一)+ 服务器部署+回测试运行。
@@ -0,0 +1,142 @@
# 云客经营管理后台·批次F-调度子批(管理指标接入定时链)实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 管理域三张派生表(first_reply_fact / employee_metric_daily / employee_call_daily)进入生产定时链——4h 滚动 + 夜间全量自愈,数据每天活起来(同步审计 2026-09-10 结论 #5 的管理域部分)。
**Architecture:** 两处接线:`scheduler.py _analytics_tick` 加**独立事务**的管理滚动段(近 3 天,失败不回滚既有 staff/敏感词段);`yunke-server/scripts/full_sync_all.sh` 在 11/12 步后加 13/14 步(fact 全量 → 宽表+通话全量)+ 终局快照补三表行数。CLI 已有(first-reply-facts / employee-metrics),无新端点。**边界:不碰审计文档 P0 的脚本加固/实体补抓(并行会话领地)**。
**Tech Stack:** 同批次A(APScheduler + SQLAlchemy async + bash)。
---
## 执行者必读
1. **上游函数签名(as-built)**:`first_reply_facts.pairs_active_in_days(session, days) / recompute_pairs(session, pairs)`;`storage/employee_metrics.recompute_metric_days(session, days) / recompute_call_days(session, days)`;`staff_summary.rolling_days(today, 3)`(返回当日+前2天列表)。全部幂等 delete+insert。
2. **隔离语义(v2 Task 12:指标失败不中断主流程)**:tick 内管理段用**独立 session+commit**——失败只回滚管理段自身,staff/敏感词段已提交不受影响(当前实现是单 try 单事务,改造为两段)。
3. **锁竞争既有结论**(tick docstring 已记录):与夜间 full_sync 重叠时 DELETE+INSERT 可能锁等待,非致命,幂等下轮自愈——管理段沿用同一语义,docstring 注明。
4. 脚本在 `yunke-server/scripts/full_sync_all.sh`(注意路径已从根 scripts/ 迁移);测试先例 `tests/test_scheduler_analytics_tick.py`。
5. 并行会话活动照旧;中文 conventional 提交。
## 任务
### Task F1: _analytics_tick 管理段 + 测试
**Files:** Modify `yunke-server/app/sync/scheduler.py` + `tests/test_scheduler_analytics_tick.py`
改造 `_analytics_tick`(:703)为两段:
```python
async def _analytics_tick(self) -> None:
"""4h 分析链(staff 滚动3天 + 敏感词增量 + 管理域滚动)——异常只记日志不断链。
两段独立事务:段1 staff+敏感词;段2 管理域(fact 近3天活跃会话 + 宽表/
通话 滚动3天,批次F 2026-09-10)。段2 失败只回滚自身,不牵连段1 已
提交结果(v2 Task12:指标失败不中断主分析链)。纯 DB 无浏览器,不抢
``_page_lock``;与夜间 full_sync 13/14 步重叠时的锁竞争语义同段1
(非致命,幂等下轮自愈)。
"""
# ── 段1:staff 滚动 + 敏感词增量(既有,原样保留) ──
try:
async with self._session_maker() as session:
from app.analytics.sensitive import scanner
from app.storage.staff_summary import recompute_days, rolling_days
from app.storage.summary import day_key
today = day_key(int(time.time() * 1000))
n = await recompute_days(session, rolling_days(today, 3))
scan = await scanner.run_incremental(session)
await session.commit()
logger.info(
"[analytics-tick] staff_days=%s scanned=%s hits=%s",
n, scan.get("scanned"), scan.get("hits"),
)
except Exception: # noqa: BLE001 — scheduler MUST NOT crash
logger.exception("analytics tick failed")
# ── 段2:管理域滚动(fact 活跃会话 + 宽表/通话,当日+滚动3天) ──
try:
async with self._session_maker() as session:
from app.storage.employee_metrics import (
recompute_call_days,
recompute_metric_days,
)
from app.storage.first_reply_facts import (
pairs_active_in_days,
recompute_pairs,
)
from app.storage.staff_summary import rolling_days
from app.storage.summary import day_key
rd = rolling_days(day_key(int(time.time() * 1000)), 3)
pairs = await pairs_active_in_days(session, rd)
f = await recompute_pairs(session, pairs)
w = await recompute_metric_days(session, rd)
c = await recompute_call_days(session, rd)
await session.commit()
logger.info(
"[analytics-tick] mgmt fact_pairs=%s wide_rows=%s call_rows=%s",
f, w, c,
)
except Exception: # noqa: BLE001
logger.exception("management metrics tick failed")
```
(导入合并/去重按文件现状微调;today 变量两段各自取或共享只读。注:`recompute_metric_days` 内部会再查一次 `pairs_active_in_days`(与本段显式调用重复但廉价)——非冗余待修,fact 段需要显式拿 pairs 计数记日志。)
**测试追加**(照既有文件 mock 模式):
1. tick 调用顺序:staff recompute_days → 敏感 scan → pairs_active_in_days(rolling3) → recompute_pairs → recompute_metric_days → recompute_call_days;
2. **段2 失败隔离**:recompute_metric_days 抛异常 → staff recompute_days 与敏感 scan 仍被调用且各自 commit 成功、tick 不抛、异常被记(caplog);
3. rolling 窗口传参 = rolling_days(today,3)。
- [ ] `pytest tests/test_scheduler_analytics_tick.py -v` + 全量 -q → commit `feat(management): 4h分析链接入管理域滚动段——独立事务隔离/fact活跃会话+宽表+通话滚动3天`
### Task F2: full_sync_all.sh 13/14 步 + 快照行数
**Files:** Modify `yunke-server/scripts/full_sync_all.sh`
第 12 步之后(COMPLETE 横幅之前)加:
```bash
# 13:首响事实表全量重建(管理域,跨日精确串;批次A)——会话发现读
# msg_daily_stats(fact 行直算读 message),故须在 11 步后;与 11/12
# 同为夜间自愈链(绕过钩子的写入由全量重算纠正)
echo "$(date) --- 13/14 First-reply-facts rebuild ---"
$PY -m app.sync.cli first-reply-facts
# 14:管理宽表+通话小表全量重建(驾驶舱/员工页读路径;串类列与 fact 同
# 算法各自直算,放 fact 后保持派生链顺序语义)
echo "$(date) --- 14/14 Employee-metrics rebuild ---"
$PY -m app.sync.cli employee-metrics
```
终局快照 TABLES 追加三行(放 daily stats rows 后):
```python
("first reply facts", "SELECT COUNT(*) FROM first_reply_fact"),
("employee metric rows", "SELECT COUNT(*) FROM employee_metric_daily"),
("employee call rows", "SELECT COUNT(*) FROM employee_call_daily"),
```
- [ ] `bash -n yunke-server/scripts/full_sync_all.sh` 语法过 + commit `feat(management): 夜间全量脚本13/14步——fact与宽表全量自愈+终局快照三表行数`
### Task F3: 本地 scheduler 容器重建 + 真机验证 + 归档
1. **真机验证滚动段**(直接跑 tick 逻辑,不等 4h):临时脚本或 `python -c` 用 scheduler 的 session_maker 调 `_analytics_tick()`(或手动等价:按 rolling 3 天调三个 recompute + commit)——记录耗时(预期 fact 活跃会话数十秒级内)+ 三表当日行数变化(recompute 后 updated 行 freshness);
2. **幂等复跑**:连跑两次行数一致;
3. **本地 scheduler 容器重建**(新代码进夜间链):`docker ps` 确认 scheduler 容器名 → `docker compose -f docker-compose.yml up -d --build scheduler`——**范围仅本地**(服务器 scheduler 镜像 2026-09-09 已重建过,服务器侧等部署时随 DEPLOY.md 流程走,本任务不碰);重建前后 `docker exec <scheduler> python -c "import app.sync.scheduler"` 冒烟;(已知后果不阻塞:phone_call 尚未进 4h/每日页面同步链[审计P0-5,并行会话领地]——通话小表滚动重算在补齐前只是从静态源幂等重生成);
4. 归档:耗时/行数/容器重建记录 → 核实文档「批次F-调度」小节 + commit。
- [ ] commit `docs(management): 批次F调度接入验证——滚动段耗时/幂等/容器重建(清9d3cc8e旧账)`
## 验收清单
- [ ] tick 两段隔离测试过(段2 异常不牵连段1);
- [ ] 夜间脚本 13/14 步 + 快照三表行数;
- [ ] 滚动段真机耗时记录(预期 <60s)+ 幂等;
- [ ] 本地 scheduler 容器已重建(新代码),夜间 02:00 链将含管理域;
- [ ] 全量测试绿;
- [ ] 未触碰审计 P0 领地(脚本加固/实体补抓——并行会话负责)。
## 后续(不在本子批)
E:辅导+快照;F 余项:dashboard 性能修复(unreplied 索引)、winsorize 决策、服务器部署(届时服务器 scheduler 镜像重建走 DEPLOY.md)。
+2
View File
@@ -49,6 +49,7 @@ from app.api.routers import (
export,
groups,
login,
management,
media,
messages,
moments,
@@ -101,6 +102,7 @@ app.include_router(sessions.router)
app.include_router(export.router)
app.include_router(voice_clips.router)
app.include_router(analytics.router)
app.include_router(management.router)
@app.get("/api/health")
+140 -9
View File
@@ -5,6 +5,10 @@
``owner_user_id`` column) and ``q`` (substring match on ``room_name``,
``%``/``_`` escaped as literals — the 通讯录群 tab search box), ordered by
``last_chat_time desc`` (nulls last) so recently-active groups surface first.
微信群统计扩展(2026-09-09,云客「微信群统计」同款):``msg_start``/``msg_end``
统计时段(窗内输出沟通消息数/新增/退出成员数聚合)、``last_start``/
``last_end`` 最新消息时间段、``group_remark`` 群备注子串;行补员工微信/
员工信息列(wechat_account/staff 关联)。
* ``GET /api/groups/{room_id}/members`` -> ``list[GroupMemberOut]`` — the
members of a single group (small set, no pagination needed).
"""
@@ -19,7 +23,15 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.api.schemas import GroupMemberOut, GroupOut, Page
from app.db.models import ChatGroup, GroupMember
from app.db.models import (
ChatGroup,
ChatGroupMemberStats,
GroupMember,
MsgDailyStats,
Staff,
WechatAccount,
)
from app.storage.summary import day_key
router = APIRouter(prefix="/api", tags=["groups"])
@@ -38,6 +50,11 @@ async def list_groups(
user_id: Annotated[str | None, Query()] = None,
room_id: Annotated[str | None, Query()] = None,
q: Annotated[str | None, Query()] = None,
group_remark: Annotated[str | None, Query()] = None,
last_start: Annotated[int | None, Query(ge=0)] = None,
last_end: Annotated[int | None, Query(ge=0)] = None,
msg_start: Annotated[int | None, Query(ge=0)] = None,
msg_end: Annotated[int | None, Query(ge=0)] = None,
) -> Page[GroupOut]:
"""List chat groups with optional ``channel`` / ``owner_account_id`` /
``user_id`` / ``room_id`` / ``q`` filters.
@@ -47,6 +64,13 @@ async def list_groups(
filter on ``GET /api/customers``. ``room_id`` is an exact-match lookup
(composite PK: the same room may return one row per owner; chat page
group-name resolution). ``q`` is a substring match on ``room_name``.
微信群统计参数(2026-09-09):``last_start``/``last_end`` 过滤
``last_chat_time`` 范围(最新消息时间段);``msg_start``/``msg_end``
统计时段——窗内聚合三列(``window_msg_count`` 沟通消息数=
msg_daily_stats 按天求和、``window_added``/``window_lost`` 成员进出=
chat_group_member_stats 按天求和,上线日起有数据);``group_remark``
群备注子串。员工微信/员工信息列经 wechat_account/staff 关联补齐。
"""
conditions = []
if channel is not None:
@@ -57,10 +81,17 @@ async def list_groups(
# LIKE 通配符转义: %/_ 按字面匹配(镜像 customers/messages 的 q 处理)
escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
conditions.append(ChatGroup.room_name.like(f"%{escaped}%", escape="\\"))
if group_remark:
escaped_r = group_remark.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
conditions.append(ChatGroup.group_remark.like(f"%{escaped_r}%", escape="\\"))
if room_id is not None:
conditions.append(ChatGroup.room_id == room_id)
if user_id is not None:
conditions.append(ChatGroup.owner_user_id == user_id)
if last_start is not None:
conditions.append(ChatGroup.last_chat_time >= last_start)
if last_end is not None:
conditions.append(ChatGroup.last_chat_time <= last_end)
total_stmt = sa.select(sa.func.count()).select_from(ChatGroup)
if conditions:
@@ -70,16 +101,116 @@ async def list_groups(
list_stmt = sa.select(ChatGroup)
if conditions:
list_stmt = list_stmt.where(*conditions)
list_stmt = (
list_stmt.order_by(
sa.nullslast(ChatGroup.last_chat_time.desc()),
ChatGroup.room_id.asc(),
if (channel or "wechat") == "wechat":
# 云客「微信群统计」同款排序(2026-09-10 真机终判):owner_account_id
# (=微信号/wxid 混存,如 Nancyjade < csf7169708 < wxid_01gv…——大写
# 在小写前=区分大小写)**二进制字节序升序**分组,组内 room_id 字符串
# 升序。字节序按方言:MySQL 用 CAST(col AS BINARY)(collate("BINARY")
# 会被 SQLAlchemy 反引号成标识符);SQLite 用 COLLATE BINARY(其 CAST
# AS BINARY 语义不符)。WA 保持最近活跃优先。
dialect = session.bind.dialect.name if session.bind is not None else ""
if dialect == "mysql":
owner_order = sa.literal_column(
"CAST(chat_group.owner_account_id AS BINARY)"
).asc()
else:
owner_order = ChatGroup.owner_account_id.collate("BINARY").asc()
list_stmt = (
list_stmt.order_by(
owner_order,
ChatGroup.room_id.asc(),
)
.limit(page_size)
.offset((page - 1) * page_size)
)
else:
list_stmt = (
list_stmt.order_by(
sa.nullslast(ChatGroup.last_chat_time.desc()),
ChatGroup.room_id.asc(),
)
.limit(page_size)
.offset((page - 1) * page_size)
)
.limit(page_size)
.offset((page - 1) * page_size)
)
rows = (await session.execute(list_stmt)).scalars().all()
items = [GroupOut.model_validate(row) for row in rows]
# ---- 员工微信/员工信息列(wechat_account/staff 关联,本页行集) ----
acc_map: dict[str, tuple[str | None, str | None, str | None]] = {}
owners = {r.owner_account_id for r in rows if r.owner_account_id}
if owners:
for wx, nick, alias, raw in (await session.execute(
sa.select(
WechatAccount.wx_id, WechatAccount.nick_name,
WechatAccount.alias, WechatAccount.raw_json,
).where(WechatAccount.wx_id.in_(owners))
)).all():
avatar = None
if isinstance(raw, dict):
h = raw.get("headImageUrl")
avatar = h if isinstance(h, str) else None
acc_map[wx] = (nick, alias, avatar)
staff_map: dict[str, tuple[str | None, str | None]] = {}
uids = {r.owner_user_id for r in rows if r.owner_user_id}
if uids:
for uid, name, phone in (await session.execute(
sa.select(Staff.user_id, Staff.name, Staff.phone)
.where(Staff.user_id.in_(uids))
)).all():
staff_map[uid] = (name, phone)
# ---- 统计时段窗内聚合(msg_daily_stats 沟通消息数 + member_stats 进出) ----
msg_cnt: dict[tuple[str, str], int] = {}
added_map: dict[tuple[str, str], int] = {}
lost_map: dict[tuple[str, str], int] = {}
if msg_start is not None and msg_end is not None and rows:
d0, d1 = day_key(msg_start), day_key(msg_end)
pairs = [(r.owner_account_id, r.room_id) for r in rows]
owners_in = {p[0] for p in pairs}
rooms_in = {p[1] for p in pairs}
for owner, room, cnt in (await session.execute(
sa.select(
MsgDailyStats.owner_account_id, MsgDailyStats.peer_id,
sa.func.coalesce(sa.func.sum(
MsgDailyStats.send_cnt + MsgDailyStats.recv_cnt), 0),
).where(
MsgDailyStats.channel == (channel or "wechat"),
MsgDailyStats.peer_kind == "room",
MsgDailyStats.owner_account_id.in_(owners_in),
MsgDailyStats.peer_id.in_(rooms_in),
MsgDailyStats.day >= d0, MsgDailyStats.day <= d1,
).group_by(MsgDailyStats.owner_account_id, MsgDailyStats.peer_id)
)).all():
msg_cnt[(owner, room)] = int(cnt)
for owner, room, a, l in (await session.execute(
sa.select(
ChatGroupMemberStats.owner_account_id, ChatGroupMemberStats.room_id,
sa.func.coalesce(sa.func.sum(ChatGroupMemberStats.added), 0),
sa.func.coalesce(sa.func.sum(ChatGroupMemberStats.lost), 0),
).where(
ChatGroupMemberStats.channel == (channel or "wechat"),
ChatGroupMemberStats.owner_account_id.in_(owners_in),
ChatGroupMemberStats.room_id.in_(rooms_in),
ChatGroupMemberStats.day >= d0, ChatGroupMemberStats.day <= d1,
).group_by(ChatGroupMemberStats.owner_account_id, ChatGroupMemberStats.room_id)
)).all():
added_map[(owner, room)] = int(a)
lost_map[(owner, room)] = int(l)
items = []
for r in rows:
out = GroupOut.model_validate(r)
acc = acc_map.get(r.owner_account_id or "")
if acc:
out.staff_nickname, out.staff_wx_alias, out.staff_avatar = acc
st = staff_map.get(r.owner_user_id or "")
if st:
out.staff_name, out.staff_phone = st
if msg_start is not None and msg_end is not None:
key = (r.owner_account_id or "", r.room_id)
out.window_msg_count = msg_cnt.get(key, 0)
out.window_added = added_map.get(key, 0)
out.window_lost = lost_map.get(key, 0)
items.append(out)
return Page(items=items, total=total, page=page, page_size=page_size)
+571
View File
@@ -0,0 +1,571 @@
"""GET /api/management/* —— 经营管理后台只读端组(批次B Task B4)。
六个端点:dashboard(总览)/ employees(列表,一期四视图共用行集)/
employees/{user_id}(详情)/ employees/{user_id}/metrics(分段指标+趋势)/
employees/{user_id}/drilldown(白名单下钻)/ metric-definitions(口径字典)。
惯例同 analytics.py:channel=all 在 router 层翻译为 None 传服务层;
start/end yyyymmdd 缺一/日历非法/start>end/跨度>366 天皆 422(_window);
重查询与行构造在 app.management.api_assembly,本文件只做薄编排。
所有查询共用单 AsyncSession——**顺序 await,不并发**(见 assembly 模块)。
"""
from __future__ import annotations
import datetime as dt
from typing import Annotated, Any, Literal
import sqlalchemy as sa
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.analytics import health as health_svc
from app.analytics import metrics as metrics_svc
from app.api.deps import get_session
from app.db.models import Staff, WechatAccount, WhatsappAccount
from app.management import api_assembly as asm
from app.management.anomalies import detect_anomalies
from app.management.employee_metrics import (
baseline_window,
day_range,
period_metrics,
prev_window,
)
from app.management.metric_definitions import METRIC_DEFINITIONS
from app.storage.employee_metrics import METRIC_VERSION
from app.storage.staff_summary import account_map
router = APIRouter(prefix="/api/management", tags=["management"])
SessionDep = Annotated[AsyncSession, Depends(get_session)]
AllChannelParam = Annotated[Literal["all", "wechat", "whatsapp"], Query()]
YmdParam = Annotated[int, Query(ge=20000101, le=29991231)]
CompareParam = Annotated[
Literal["previous", "personal_baseline", "team_median"], Query()
]
ViewParam = Annotated[
Literal["management", "workload", "service", "customer"], Query()
]
StatusParam = Annotated[Literal["all", "on_job", "offline"], Query()]
SortByParam = Annotated[
Literal["anomaly_severity", "total_score", "active_friends", "cust_threads"],
Query(),
]
SortOrderParam = Annotated[Literal["asc", "desc"], Query()]
SectionParam = Annotated[
Literal["overview", "service", "customer", "workload", "calls"], Query()
]
# drilldown 白名单(外 422 "unknown metric_code")
_DRILLDOWN_CODES = frozenset({
"first_reply_p50", "first_reply_p90", "unreplied",
"cooling_customers", "dormant_customers", "call_followup",
})
# unreplied 适配:上游无私有 owner 过滤,取全量决赛圈结果再按 owner 过滤。
# 500 = analytics.metrics._FINALIZE_ALL_MAX 同值(候选 ≤500 时单次调用即
# 拿到全部已过滤 items;超过则上游本就按候选分页,响应标 truncated)。
_UNREPLIED_FETCH = 500
# 三方口径差异说明(硬编码,与 METRIC_DEFINITIONS.caliber 互补的总述)
CALIBER_NOTES = (
"管理域指标为 1:1 私聊口径且跨日精确(消息串按会话全历史计算、"
"归属串末条所在自然日);现有看板为私聊+群聊混合、日切口径"
"(跨夜串重复计/丢失,回复率分子分母同源近似)。"
"通话指标(通话数/时长/接通/跟进)无消息渠道维度——"
"voice_call 与 wa_voice_call 合并计数,不随 channel 参数过滤。"
)
# 各 section 的期指标字段(自 PeriodMetrics 挑)与宽表/通话表趋势列
_SECTION_FIELDS: dict[str, tuple[str, ...]] = {
"overview": ("days_with_data", "msg_sent", "msg_recv", "active_friends",
"active_rooms_avg"),
"service": ("cust_threads", "threads_answered", "fr_sum_ms",
"fr_p50_ms", "fr_p90_ms", "reply_2h_count", "reply_24h_count"),
"customer": ("new_customer", "cooling_customer", "dormant_customer",
"reactivated_customer", "end_active_friends"),
"workload": ("active_friends", "active_rooms_avg", "cust_threads"),
"calls": ("call_count", "call_seconds", "answered_call", "call_followup"),
}
_SECTION_TREND_COLS: dict[str, tuple[str, ...]] = {
"overview": ("msg_sent", "msg_recv", "active_friends"),
"service": ("cust_threads", "threads_answered", "fr_sum_ms",
"first_reply_p50_ms", "first_reply_p90_ms"),
"customer": ("new_customer_count", "cooling_customer_count",
"dormant_customer_count", "reactivated_customer_count"),
"workload": ("active_friends", "active_rooms"),
"calls": ("call_count", "call_seconds", "answered_call_count",
"call_followup_count"),
}
def _window(start: int | None, end: int | None) -> tuple[int, int]:
"""管理端点公共窗口校验(照 analytics custom scope 惯例)。"""
if start is None or end is None:
raise HTTPException(422, "start and end are required")
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, "span must be <= 366 days")
return start, end
def _translate(channel: str) -> str | None:
return None if channel == "all" else channel
async def _get_staff(session: AsyncSession, user_id: str) -> Staff:
staff = (await session.execute(
sa.select(Staff).where(Staff.user_id == user_id)
)).scalar_one_or_none()
if staff is None:
raise HTTPException(404, f"employee {user_id} not found")
return staff
@router.get("/dashboard")
async def management_dashboard(
session: SessionDep,
channel: AllChannelParam = "all",
start: YmdParam | None = None,
end: YmdParam | None = None,
compare: CompareParam = "previous",
) -> dict[str, Any]:
"""经营管理总览:KPI + 双趋势 + 生命周期 + 维度分布 + 异常汇总 +
行动入口计数,单往返。compare 仅回显(对比数值在 employees 行内)。"""
start, end = _window(start, end)
ch = _translate(channel)
config, config_source = await asm.load_score_config(session)
assembled = await asm.assemble_employees(
session, config, start, end, ch, with_prev=False
)
pms = [a["cur"] for a in assembled.values()]
# ---- 趋势两查:宽表全员逐日 + 通话小表逐日 + fact 逐日 ----
wide = await asm.wide_daily_rows(session, start, end, ch)
wide_by_day = {d["day"]: d for d in wide}
threads_by_day = {d["day"]: d["cust_threads"] for d in wide}
calls = await asm.call_daily_rows(session, start, end)
call_by_day = {d["day"]: d["call_count"] for d in calls}
comm_trend = []
for day in day_range(start, end):
d = wide_by_day.get(day)
comm_trend.append({
"day": day,
"msg_total": (d["msg_sent"] + d["msg_recv"]) if d else 0,
# 团队合计口径:当日各员工宽表 active_friends 求和
"active_customers": d["active_friends"] if d else 0,
"call_cnt": call_by_day.get(day, 0),
})
service_trend = await asm.service_daily_trend(
session, start, end, ch, threads_by_day
)
lifecycle = {
"new": sum(p.new_customer for p in pms),
"cooling": sum(p.cooling_customer for p in pms),
"dormant": sum(p.dormant_customer for p in pms),
"reactivated": sum(p.reactivated_customer for p in pms),
}
kpis = {
# 全员 active_friends 求和:跨员工可能重复计客户(共享客户)
"active_customers": sum(p.active_friends for p in pms),
"active_employees": sum(1 for p in pms if p.days_with_data > 0),
"msg_total": sum(p.msg_sent + p.msg_recv for p in pms),
"cust_threads": sum(p.cust_threads for p in pms),
"threads_answered": sum(p.threads_answered for p in pms),
"call_count": sum(p.call_count for p in pms),
"call_seconds": sum(p.call_seconds for p in pms),
"answered_call": sum(p.answered_call for p in pms),
"lifecycle": lifecycle,
}
# ---- 前窗环比基线(批次D):prev 窗全员一次 period_metrics,聚合式与
# kpis 完全同式(活跃客户=Σactive_friends、活跃员工=天数>0 计数、
# msg_total=Σ(sent+recv)、cust_threads=Σ)。lifecycle(期末存量)与
# 通话(全渠道口径,表无渠道维)不加环比——期末/混合口径的环比意义弱。
ps, pe = prev_window(start, end)
prev_pms = list((await period_metrics(session, ps, pe, ch)).values())
prev_kpis = {
# 全员求和口径同 kpis(跨员工共享客户可能重复计)
"active_customers": sum(p.active_friends for p in prev_pms),
"active_employees": sum(1 for p in prev_pms if p.days_with_data > 0),
"msg_total": sum(p.msg_sent + p.msg_recv for p in prev_pms),
"cust_threads": sum(p.cust_threads for p in prev_pms),
}
# 前窗无消息(prev=0)不算环比——None 而非 0/负∞
prev_msg = prev_kpis["msg_total"]
msg_delta = (
round((kpis["msg_total"] - prev_msg) / prev_msg, 3)
if prev_msg > 0 else None
)
# ---- 维度分布(全员 score_employee 的维度状态计数) ----
dim_summary: dict[str, dict] = {}
for a in assembled.values():
for key, dim in a["score"]["dimensions"].items():
e = dim_summary.setdefault(key, {
"name": dim.get("name"), "weight": dim.get("weight", 0),
"scored_count": 0, "insufficient_count": 0,
"pending_count": 0, "_scores": [],
})
if dim["status"] == "scored":
e["scored_count"] += 1
e["_scores"].append(dim["score"])
elif dim["status"] == "insufficient":
e["insufficient_count"] += 1
else: # pending / pending_data
e["pending_count"] += 1
for e in dim_summary.values():
scores = e.pop("_scores")
e["avg_score"] = (
sum(scores) / len(scores) if scores else None
)
# ---- 异常汇总:critical 先、warning 后,同 severity 按 affected desc ----
# dashboard 卡片 enrich employee_name(staff 名,assemble 已在手)供前端
# 跳员工详情;员工详情端点的 anomalies 不带(自指无意义)
anomalies = [
{**a, "employee_name": data["name"]}
for data in assembled.values()
for a in data["anomalies"]
]
sev_rank = {"critical": 0, "warning": 1}
anomalies.sort(key=lambda a: (
sev_rank.get(a["severity"], 9),
-a.get("affected_count", 0),
a["employee_user_id"],
))
return {
"scope": {"channel": channel, "start": start, "end": end,
"compare": compare},
"data_as_of": await health_svc.data_as_of(session),
"metric_version": METRIC_VERSION,
"config_source": config_source,
"kpis": kpis,
"prev_kpis": prev_kpis,
"msg_delta": msg_delta,
"communication_trend": comm_trend,
"service_trend": service_trend,
"lifecycle": lifecycle,
"dimension_summary": dim_summary,
"anomalies": anomalies,
"action_summary": await metrics_svc.action_counts(session, channel=ch),
"caliber_notes": {
"active_customers": "kpis.active_customers 为全员周期 distinct "
"求和,跨员工共享客户可能重复计",
"trend_active_customers": "communication_trend.active_customers "
"为宽表日值团队合计(日口径求和)",
"reply_2h_rate": "分子=fact 当日 ≤2h 计数,分母=宽表当日串数",
},
}
@router.get("/employees")
async def management_employees(
session: SessionDep,
channel: AllChannelParam = "all",
start: YmdParam | None = None,
end: YmdParam | None = None,
view: ViewParam = "management",
keyword: str | None = None,
status: StatusParam = "all",
sort_by: SortByParam = "anomaly_severity",
sort_order: SortOrderParam = "desc",
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
"""员工列表(一期 management/workload/service/customer 四视图共用
行集,view 仅回显)。默认 anomaly_severity 排序:含 critical 者 →
权重和 → 卡片数;total_score 排序 None 恒排最后。"""
start, end = _window(start, end)
ch = _translate(channel)
config, config_source = await asm.load_score_config(session)
assembled = await asm.assemble_employees(
session, config, start, end, ch, with_prev=True
)
items = list(assembled.items())
if ch is not None:
# 渠道过滤=账号归属(该渠道无账号的员工不进列表;有账号无数据仍
# 展示零值行)
owners_uids = set((await account_map(session, ch)).values())
items = [(u, d) for u, d in items if u in owners_uids]
if keyword:
kw = keyword.strip().lower()
items = [
(u, d) for u, d in items
if kw in (d["name"] or "").lower() or kw in u.lower()
]
if status == "on_job":
items = [(u, d) for u, d in items if d["is_on_job"] is True]
elif status == "offline":
items = [(u, d) for u, d in items if d["is_on_job"] is not True]
items.sort(key=lambda kv: kv[0]) # 决胜底座:user_id asc(稳定排序)
if sort_by == "anomaly_severity":
items.sort(key=lambda kv: (
any(a["severity"] == "critical" for a in kv[1]["anomalies"]),
kv[1]["anomaly_weight"], kv[1]["anomaly_count"],
), reverse=(sort_order == "desc"))
elif sort_by == "total_score":
# None(样本不足等)恒排最后,与升降序无关
if sort_order == "desc":
items.sort(key=lambda kv: (
kv[1]["score"]["total_score"] is None,
-(kv[1]["score"]["total_score"] or 0),
))
else:
items.sort(key=lambda kv: (
kv[1]["score"]["total_score"] is None,
kv[1]["score"]["total_score"] or 0,
))
else: # active_friends / cust_threads(PeriodMetrics 数值列)
if sort_order == "desc":
items.sort(key=lambda kv: -getattr(kv[1]["cur"], sort_by))
else:
items.sort(key=lambda kv: getattr(kv[1]["cur"], sort_by))
total = len(items)
page_rows = items[(page - 1) * page_size: page * page_size]
return {
"total": total,
"page": page,
"page_size": page_size,
"rows": [asm.build_employee_row(u, d, config) for u, d in page_rows],
"scope": {"channel": channel, "start": start, "end": end,
"view": view},
"data_as_of": await health_svc.data_as_of(session),
"metric_version": METRIC_VERSION,
"config_source": config_source,
}
@router.get("/employees/{user_id}")
async def management_employee_detail(
session: SessionDep,
user_id: str,
channel: AllChannelParam = "all",
start: YmdParam | None = None,
end: YmdParam | None = None,
) -> dict[str, Any]:
"""员工详情:身份+账号清单+期指标全字段+评分完整明细(含逐指标
metrics_detail)+异常卡片+prev/baseline 原始 PeriodMetrics。"""
start, end = _window(start, end)
ch = _translate(channel)
staff = await _get_staff(session, user_id)
config, config_source = await asm.load_score_config(session)
cur_map = await period_metrics(session, start, end, ch, user_ids={user_id})
bs, be = baseline_window(start)
base_map = await period_metrics(session, bs, be, ch, user_ids={user_id})
ps, pe = prev_window(start, end)
prev_map = await period_metrics(session, ps, pe, ch, user_ids={user_id})
cur, base, prev = (cur_map.get(user_id), base_map.get(user_id),
prev_map.get(user_id))
if cur is None: # 防御:_get_staff 已保证在 staff,period_metrics 必含
raise HTTPException(404, f"employee {user_id} not found")
team = asm.team_derived_medians(
await period_metrics(session, start, end, ch)
)
score = asm.score_with_guards(config, cur, baseline=base, team=team,
prev=prev)
anomalies = detect_anomalies(
user_id, cur, base, n_days=len(day_range(start, end)), config=config
)
accounts: list[dict] = []
for wa in (await session.execute(
sa.select(WechatAccount).where(WechatAccount.user_id == user_id)
)).scalars():
accounts.append({
"channel": "wechat", "account_id": wa.wx_id,
"nick_name": wa.nick_name, "user_name": wa.user_name,
})
for wa in (await session.execute(
sa.select(WhatsappAccount).where(WhatsappAccount.user_id == user_id)
)).scalars():
accounts.append({
"channel": "whatsapp", "account_id": wa.uid,
"nick_name": wa.nick_name, "user_name": wa.user_name,
})
return {
"user_id": user_id,
"name": staff.name,
"is_on_job": staff.is_on_job,
"accounts": accounts,
"metrics": cur._asdict(),
"score": score,
"anomalies": anomalies,
"prev": prev._asdict() if prev is not None else None,
"baseline": base._asdict() if base is not None else None,
"scope": {"channel": channel, "start": start, "end": end},
"data_as_of": await health_svc.data_as_of(session),
"metric_version": METRIC_VERSION,
"config_source": config_source,
}
@router.get("/employees/{user_id}/metrics")
async def management_employee_metrics(
session: SessionDep,
user_id: str,
section: SectionParam = "overview",
channel: AllChannelParam = "all",
start: YmdParam | None = None,
end: YmdParam | None = None,
) -> dict[str, Any]:
"""员工分段指标:该域期指标 + 窗口逐日趋势 + 四对比
(current/prev/baseline/median,派生率走 scoring.derive_metrics 口径)。"""
start, end = _window(start, end)
ch = _translate(channel)
await _get_staff(session, user_id)
cur_map = await period_metrics(session, start, end, ch, user_ids={user_id})
bs, be = baseline_window(start)
base_map = await period_metrics(session, bs, be, ch, user_ids={user_id})
ps, pe = prev_window(start, end)
prev_map = await period_metrics(session, ps, pe, ch, user_ids={user_id})
cur, base, prev = (cur_map.get(user_id), base_map.get(user_id),
prev_map.get(user_id))
if cur is None:
raise HTTPException(404, f"employee {user_id} not found")
team = asm.team_derived_medians(
await period_metrics(session, start, end, ch)
)
if section == "calls":
src_rows = await asm.call_daily_rows(session, start, end,
user_id=user_id)
else:
src_rows = await asm.wide_daily_rows(session, start, end, ch,
user_id=user_id)
by_day = {r["day"]: r for r in src_rows}
cols = _SECTION_TREND_COLS[section]
trend = []
for day in day_range(start, end):
row = by_day.get(day)
point: dict[str, Any] = {"day": day}
for col in cols:
if row is not None:
point[col] = row.get(col)
else: # 无数据日:计数列补 0,分位列补 None
point[col] = None if col.startswith("first_reply") else 0
trend.append(point)
return {
"section": section,
"metrics": {f: getattr(cur, f) for f in _SECTION_FIELDS[section]},
"trend": trend,
"comparisons": asm.derived_comparisons(cur, prev, base, team),
"scope": {"channel": channel, "start": start, "end": end,
"user_id": user_id},
"data_as_of": await health_svc.data_as_of(session),
"metric_version": METRIC_VERSION,
}
@router.get("/employees/{user_id}/drilldown")
async def management_employee_drilldown(
session: SessionDep,
user_id: str,
metric_code: str,
channel: AllChannelParam = "all",
start: YmdParam | None = None,
end: YmdParam | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
"""指标下钻(白名单六项,外 422):首响分位逐串证据 / 未回串 /
降温·沉睡客户名单 / 通话跟进列表。所有分支按员工 owner 账号过滤。"""
if metric_code not in _DRILLDOWN_CODES:
raise HTTPException(422, f"unknown metric_code: {metric_code}")
start, end = _window(start, end)
ch = _translate(channel)
await _get_staff(session, user_id)
scope = {"user_id": user_id, "metric_code": metric_code,
"channel": channel, "start": start, "end": end}
owners = await asm.user_owner_accounts(session, user_id, ch)
def _paged(items: list[dict]) -> dict[str, Any]:
ix = (page - 1) * page_size
return {"items": items[ix: ix + page_size], "total": len(items),
"page": page, "page_size": page_size, "scope": scope}
if metric_code in ("first_reply_p50", "first_reply_p90"):
if not owners:
return _paged([])
total, items = await asm.fr_fact_rows(
session, owners, ch, start, end, page=page, page_size=page_size
)
return {"items": items, "total": total, "page": page,
"page_size": page_size, "scope": scope}
if metric_code == "unreplied":
if not owners: # 无账号→必空,免进上游决赛圈点查
return _paged([])
# 上游无私有 owner 过滤:一次拉全量决赛圈结果(候选 ≤500 时完整),
# 按 owner 账号过滤后内存分页;候选超限标 truncated(过滤可能不全)
res = await metrics_svc.unreplied(
session, channel=ch, hours=24, page=1, page_size=_UNREPLIED_FETCH
)
owner_set = set(owners)
owned = [i for i in res["items"]
if i["owner_account_id"] in owner_set]
out = _paged(owned)
out["note"] = ("按该员工 owner 账号自全量未回清单过滤"
"(hours=24,与看板清单同源同判定)")
if res["total"] > len(res["items"]):
out["truncated"] = True
return out
if metric_code in ("cooling_customers", "dormant_customers"):
want = ("cooling" if metric_code == "cooling_customers"
else "dormant")
if not owners:
return _paged([])
matches = await asm.lifecycle_customers(session, owners, end, want)
return _paged(matches)
# call_followup:窗口通话列表(start desc)+ 期聚合摘要
# (逐通话跟进标记复用批次A _followup_exists 太重,留批次C按需再加)
total, items = await asm.user_calls_page(
session, user_id, start, end, page=page, page_size=page_size
)
summary_rows = await asm.call_daily_rows(session, start, end,
user_id=user_id)
summary = {col: sum(r[col] for r in summary_rows)
for col in ("call_count", "call_seconds",
"answered_call_count", "call_followup_count")}
return {"items": items, "total": int(total), "page": page,
"page_size": page_size, "summary": summary, "scope": scope,
"note": "逐通话跟进标记留批次C;此处为窗口通话列表+期聚合"
"(followup 为接通后24h内有跟进的通话数)"}
@router.get("/metric-definitions")
async def management_metric_definitions(
session: SessionDep,
) -> dict[str, Any]:
"""指标口径字典 + 当前评分配置(含 pending 维度)+ 三方口径差异说明。
config_source: "db"=激活配置 / "default"=未种子回退默认。"""
config, source = await asm.load_score_config(session)
return {
"definitions": METRIC_DEFINITIONS,
"score_config": {
"version": config.get("version"),
"name": config.get("name"),
"trial": config.get("trial"),
"normalization": config.get("normalization"),
"sample_guards": config.get("sample_guards"),
"grade_bounds": config.get("grade_bounds"),
"dimensions": config.get("dimensions"),
},
"config_source": source,
"caliber_notes": CALIBER_NOTES,
}
+11
View File
@@ -199,6 +199,17 @@ class GroupOut(BaseModel):
owner_avatar: str | None = None
friend_count: int | None = None
create_time: int | None = None # 建群时间 ms epoch
# 微信群统计页(2026-09-09,云客 getChatroomList 同款)
group_remark: str | None = None # 群备注
staff_nickname: str | None = None # 员工微信·昵称(wechat_account.nick_name)
staff_wx_alias: str | None = None # 员工微信·微信号(wechat_account.alias)
staff_avatar: str | None = None # 员工微信·头像
staff_name: str | None = None # 员工信息·姓名(staff.name)
staff_phone: str | None = None # 员工信息·手机(staff.phone)
# 统计时段窗内聚合(仅请求带 msg_start/msg_end 时非 None):
window_msg_count: int | None = None # 沟通消息数(窗内,msg_daily_stats)
window_added: int | None = None # 新增成员数(窗内,按天表求和)
window_lost: int | None = None # 退出成员数(窗内)
raw_json: dict[str, Any] | None = None
+48 -3
View File
@@ -344,6 +344,9 @@ class ChatGroup(Base):
owner_avatar: Mapped[str | None] = mapped_column(String(1024), nullable=True)
friend_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 群成员中员工好友数
create_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 建群时间 ms epoch
# 群备注(微信群统计页「群备注」列;getChatroomList.remark /
# friend_list/es 的 friendWxRemark,2026-09-09)
group_remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
version: Mapped[str | None] = mapped_column(String(64), nullable=True)
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
@@ -362,6 +365,34 @@ class ChatGroup(Base):
)
class ChatGroupMemberStats(Base):
"""群成员进出按天统计(chat_group_member_stats,2026-09-09)。
云客「微信群统计」的新增/退出成员数是 ``getChatroomList`` 按
``startTime~endTime`` 任意窗实时算的;本地无成员进出事件流,改为每夜
抓一次「当日窗」的 addMembers/lossMembers 落一行,页面任意日窗=按天
求和。**上线日起积累**(历史日期无数据;chat_group.add_members/
loss_members 旧列保留作最近一次抓取窗的快照)。
"""
__tablename__ = "chat_group_member_stats"
channel: Mapped[str] = mapped_column(String(16), primary_key=True)
owner_account_id: Mapped[str] = mapped_column(String(128), primary_key=True)
room_id: Mapped[str] = mapped_column(String(128), primary_key=True)
# yyyymmdd(上海自然日,同 msg_daily_stats 口径)
day: Mapped[int] = mapped_column(Integer, primary_key=True)
added: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
lost: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
fetched_at: Mapped[dt.datetime] = _fetched_at()
updated_at: Mapped[dt.datetime] = _updated_at()
class GroupMember(Base):
"""群成员 — composite PK (channel, room_id, member_id).
@@ -506,6 +537,18 @@ class Message(Base):
"ix_message_fetched_at",
"fetched_at",
),
# 该回未回决赛圈点查(owner+talker 定位末条消息)在 channel=None
# (全平台计数——管理驾驶舱 action_counts 复用)时无渠道前缀条件,
# 上面全部以 channel 打头的索引用不上,逐候选退化扫表+filesort:
# 2026-09-10 服务器实测 dashboard 34~37s 撞前端 15s 超时
# (ECONNABORTED);补无渠道前缀索引后 1.3~1.8s(100 万行在线建
# 索引 49s)。「模型+迁移双声明」防 alembic autogenerate 反 DROP。
Index(
"ix_message_owner_talker_time",
"owner_account_id",
"talker_id",
"timestamp",
),
)
@@ -1090,7 +1133,8 @@ class EmployeeMetricDaily(Base):
reactivated_customer_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
# fact 是否已覆盖当日(宽表生成前置条件;False=串类列可能滞后)
# 预留完整性标记:宽表生成当前恒写 True(串类列与 fact 同一算法直算,
# 不读 fact 表)——真实语义(如校验 fact 覆盖/同步完整)由批次B定义
data_complete: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=sa.text("0")
)
@@ -1102,8 +1146,9 @@ class EmployeeMetricDaily(Base):
class EmployeeCallDaily(Base):
"""员工×日通话小表(v2 §1.2 #9)——通话无消息渠道维度,独立于宽表。
phone_call.channel 是通话类型(phone_call/voice_call)非消息渠道,
塞进 (day,channel,user) 宽表会在 channel=all 聚合时双计。接通 =
phone_call.channel 是通话类型(实测值 voice_call/wa_voice_call,
无蜂窝 phone_call 行)非消息渠道,塞进 (day,channel,user) 宽表会在
channel=all 聚合时双计。接通 =
call_duration > 0;followup = 通话后 24h 内该员工任一账号对该客户
有消息(Task 0 已核实:1:1 通话可匹配率 100%,@chatroom 群通话除外)。
"""
+234
View File
@@ -0,0 +1,234 @@
"""管理域确定性异常规则(批次B Task B3,v1 §5.4 异常卡片)。
4 条规则,输入=当前+个人基线 PeriodMetrics,输出异常卡片 dict 列表。
全部确定性——不使用 LLM 推断,reason/title/suggested_action 均为固定
中文模板填数,reason 只描述数据事实(百分比/样本数/未回串数)。
规则表(触发比较均为严格 > / <):
- reply_p90_worsened : cur.fr_p90_ms > base×1.5 且 cur.cust_threads≥
soft_threads;>×2.5 升 critical
- reply_rate_dropped : derive(reply_2h_rate) cur < base 同式×0.7 且
cur.cust_threads≥soft_threads(率由 scoring.
derive_metrics 派生,与评分同一口径)
- activity_drop : cur 日均 (msg_sent+msg_recv)/n_days < base 日均
(…/28)×0.5 且 base.days_with_data≥min_days
(基线太稀疏不可比;基线窗口恒 28 天=baseline_window
语义,当前窗口天数由 n_days 传入)
- workload_overload : cur.active_friends(周期 distinct) > workload 维
active_friends_band.high(与评分 band 同源阈值)
无基线不判:baseline=None 直接返回 [](新员工无个人对比位即不出卡,
含与基线无关的 overload——新入职负载爬坡期误报多,不判);某侧值
None/不可算(无串样本的率、无 fact 的分位、0ms 退化基线)→ 对应规则
不触发。
纯函数(无 session/IO):B4 dashboard/employees 对全员逐个调用。
阈值不硬编码:soft_threads/min_days 取 config.sample_guards,过载上限取
config.dimensions.workload.metrics.active_friends_band.high;乘数
(1.5/2.5/0.7/0.5)是规则语义本身,定义为模块常量而非可调参数。
"""
from __future__ import annotations
from app.management.employee_metrics import PeriodMetrics
from app.management.scoring import derive_metrics
# 规则乘数(语义常量,非可调参数——调整即换规则版本)
P90_WARN_MULTIPLIER = 1.5
P90_CRITICAL_MULTIPLIER = 2.5
RATE_DROP_MULTIPLIER = 0.7
ACTIVITY_DROP_MULTIPLIER = 0.5
BASELINE_WINDOW_DAYS = 28 # 个人基线窗口恒 28 天(employee_metrics.baseline_window)
# ---- 固定文案模板(.format 填数,reason 只含数据事实) ----
TITLE_P90 = "回复 P90 较个人基线上升 {pct}%"
TITLE_RATE = "2h 回复率较个人基线下降 {pct}%"
TITLE_ACTIVITY = "日均消息量较个人基线下降 {pct}%"
TITLE_OVERLOAD = "活跃客户 {current} 人超过负载上限 {limit} 人"
REASON_P90 = (
"近 {n_days} 天首响 P90 为 {cur}(基线前 28 天为 {base}),上升 {pct}%;"
"期内 {threads} 串客户消息中 {unreplied} 串未回"
)
REASON_RATE = (
"近 {n_days} 天 2h 回复率为 {cur:.0%}({cur_cnt}/{cur_thr} 串),"
"基线(前 28 天)为 {base:.0%}({base_cnt}/{base_thr} 串);"
"期内 {unreplied} 串未回"
)
REASON_ACTIVITY = (
"近 {n_days} 天日均消息 {cur:.1f} 条/天,基线(前 28 天)日均 {base:.1f} 条/天,"
"下降 {pct}%;当前活跃客户 {friends} 人"
)
REASON_OVERLOAD = (
"近 {n_days} 天活跃客户 {current} 人(周期 distinct 口径),"
"超过配置上限 {limit} 人"
)
ACTION_P90 = "查看超时未回客户明细,与员工确认近期负载与接待安排"
ACTION_RATE = "查看未回串明细,排查回复流程与时段覆盖问题"
ACTION_ACTIVITY = "与员工确认在岗状态与客户维护情况,并核对账号数据接入是否正常"
ACTION_OVERLOAD = "评估客户分配与负载均衡,考虑分流部分客户或安排协助"
def _fmt_ms(ms: float) -> str:
# 与 scoring._fmt_ms 同式(模块私有不跨文件引用)
minutes = ms / 60_000
if minutes >= 60 and minutes % 60 == 0:
return f"{minutes // 60:g}h"
return f"{minutes:g}min"
def _pct_up(cur: float, base: float) -> int:
"""较基线上升百分比(四舍五入取整;触发后调用,base>0 已保证)。"""
return int(round((cur - base) / base * 100))
def _pct_down(cur: float, base: float) -> int:
"""较基线下降百分比(触发后调用,base>0 已由触发条件保证)。"""
return int(round((base - cur) / base * 100))
def detect_anomalies(
user_id: str,
current: PeriodMetrics,
baseline: PeriodMetrics | None,
*,
n_days: int,
config: dict,
) -> list[dict]:
"""单员工确定性异常检测(纯函数,卡片结构见 v1 §5.4/模块 docstring)。
输出按规则表序:p90 恶化 → 回复率骤降 → 活动骤降 → 过载
(排序/聚合由 B4 调用方按 severity/affected_count 处理)。
"""
if baseline is None:
return [] # 无基线不判(新员工)
guards = config.get("sample_guards", {})
soft = guards.get("soft_threads", 10)
min_days = guards.get("min_days", 5)
band_high = (
config.get("dimensions", {})
.get("workload", {})
.get("metrics", {})
.get("active_friends_band", {})
.get("high")
)
cards: list[dict] = []
unreplied = current.cust_threads - current.threads_answered
threads_ok = current.cust_threads >= soft
# ---- 1. reply_p90_worsened:P90 较个人基线恶化 ×1.5/×2.5 ----
if (
threads_ok
and current.fr_p90_ms is not None
and baseline.fr_p90_ms is not None
and baseline.fr_p90_ms > 0 # 0ms 基线为退化数据,不可比
):
base_p90 = baseline.fr_p90_ms
if current.fr_p90_ms > base_p90 * P90_CRITICAL_MULTIPLIER:
severity = "critical"
elif current.fr_p90_ms > base_p90 * P90_WARN_MULTIPLIER:
severity = "warning"
else:
severity = None
if severity is not None:
pct = _pct_up(current.fr_p90_ms, base_p90)
cards.append({
"rule_code": "reply_p90_worsened",
"severity": severity,
"employee_user_id": user_id,
"title": TITLE_P90.format(pct=pct),
"current_value": current.fr_p90_ms,
"baseline_value": base_p90,
"unit": "ms",
"sample_size": current.cust_threads,
"affected_count": unreplied,
"reason": REASON_P90.format(
n_days=n_days, cur=_fmt_ms(current.fr_p90_ms),
base=_fmt_ms(base_p90), pct=pct,
threads=current.cust_threads, unreplied=unreplied,
),
"drilldown": {"type": "metric", "query": {
"metric_code": "first_reply_p90", "user_id": user_id}},
"suggested_action": ACTION_P90,
})
# ---- 2. reply_rate_dropped:2h 回复率较基线降 30%+ ----
if threads_ok:
cur_rate = derive_metrics(current)["reply_2h_rate"]
base_rate = derive_metrics(baseline)["reply_2h_rate"]
if (
cur_rate is not None and base_rate is not None
and cur_rate < base_rate * RATE_DROP_MULTIPLIER
):
cards.append({
"rule_code": "reply_rate_dropped",
"severity": "warning",
"employee_user_id": user_id,
"title": TITLE_RATE.format(
pct=_pct_down(cur_rate, base_rate)),
"current_value": cur_rate,
"baseline_value": base_rate,
"unit": "0-1",
"sample_size": current.cust_threads,
"affected_count": unreplied,
"reason": REASON_RATE.format(
n_days=n_days, cur=cur_rate, cur_cnt=current.reply_2h_count,
cur_thr=current.cust_threads, base=base_rate,
base_cnt=baseline.reply_2h_count,
base_thr=baseline.cust_threads, unreplied=unreplied,
),
"drilldown": {"type": "metric", "query": {
"metric_code": "unreplied", "user_id": user_id}},
"suggested_action": ACTION_RATE,
})
# ---- 3. activity_drop:日均消息量较基线腰斩 ----
if n_days > 0 and baseline.days_with_data >= min_days:
cur_daily = (current.msg_sent + current.msg_recv) / n_days
base_daily = (
baseline.msg_sent + baseline.msg_recv
) / BASELINE_WINDOW_DAYS
if cur_daily < base_daily * ACTIVITY_DROP_MULTIPLIER:
cards.append({
"rule_code": "activity_drop",
"severity": "warning",
"employee_user_id": user_id,
"title": TITLE_ACTIVITY.format(
pct=_pct_down(cur_daily, base_daily)),
"current_value": cur_daily,
"baseline_value": base_daily,
"unit": "条",
"sample_size": current.active_friends,
"affected_count": current.active_friends,
"reason": REASON_ACTIVITY.format(
n_days=n_days, cur=cur_daily, base=base_daily,
pct=_pct_down(cur_daily, base_daily),
friends=current.active_friends,
),
"drilldown": {"type": "employee",
"query": {"user_id": user_id}},
"suggested_action": ACTION_ACTIVITY,
})
# ---- 4. workload_overload:活跃客户超 band 上限 ----
if band_high is not None and current.active_friends > band_high:
cards.append({
"rule_code": "workload_overload",
"severity": "warning",
"employee_user_id": user_id,
"title": TITLE_OVERLOAD.format(
current=current.active_friends, limit=band_high),
"current_value": current.active_friends,
"baseline_value": None, # 与个人基线无关,阈值在 band 配置
"unit": "人",
"sample_size": current.active_friends,
"affected_count": current.active_friends,
"reason": REASON_OVERLOAD.format(
n_days=n_days, current=current.active_friends,
limit=band_high,
),
"drilldown": {"type": "employee", "query": {"user_id": user_id}},
"suggested_action": ACTION_OVERLOAD,
})
return cards
+533
View File
@@ -0,0 +1,533 @@
"""管理 API 组装层(批次B Task B4)——router 六端点共用的重查询与行构造。
拆出 router 的原因:employees/dashboard 的组装(多窗聚合 + 评分 + 异常 +
对比派生)上百行,放 router 会淹没端点定义;本模块只做数据组装,参数
校验/HTTP 语义留在 router。
查询纪律(与 analytics.metrics 同):**禁止 asyncio.gather**——单
AsyncSession 不能并发执行查询,全部顺序 await。重端点不做 N+1:
period_metrics 每窗一次调用全员;员工循环(评分/异常)是纯函数。
"""
from __future__ import annotations
import copy
import statistics
from bisect import bisect_right
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.analytics.lifecycle import classify
from app.db.models import (
Customer,
EmployeeCallDaily,
EmployeeMetricDaily,
EmployeeScoreConfig,
FirstReplyFact,
MsgDailyStats,
PhoneCall,
Staff,
)
from app.management.anomalies import detect_anomalies
from app.management.employee_metrics import (
PeriodMetrics,
baseline_window,
day_range,
percentile,
period_metrics,
prev_window,
team_medians,
)
from app.management.metric_definitions import DEFAULT_SCORE_CONFIG
from app.management.scoring import derive_metrics, score_employee
from app.storage.employee_metrics import METRIC_VERSION
from app.storage.staff_summary import account_map
from app.storage.summary import day_bounds_ms
# 异常权重(anomaly_severity 排序/汇总):critical=2 / warning=1
SEVERITY_WEIGHT = {"critical": 2, "warning": 1}
# 派生指标中需对「员工派生值」另取中位的键(scoring.team 键=派生指标名;
# 其余如 fr_p90_ms/active_friends/call_count 直接用 team_medians 输出)
_DERIVED_MEDIAN_KEYS = ("reply_2h_rate", "reply_24h_rate", "active_share")
async def load_score_config(session: AsyncSession) -> tuple[dict, str]:
"""激活评分配置(DB)→ (config_json, "db");无 active 行 →
(DEFAULT_SCORE_CONFIG 深拷贝, "default")——未跑种子的环境也能出分。"""
row = (await session.execute(
sa.select(EmployeeScoreConfig)
.where(EmployeeScoreConfig.status == "active")
.order_by(EmployeeScoreConfig.id.desc())
.limit(1)
)).scalar_one_or_none()
if row is not None:
return row.config_json, "db"
return copy.deepcopy(DEFAULT_SCORE_CONFIG), "default"
def team_derived_medians(metrics: dict[str, PeriodMetrics]) -> dict[str, float | None]:
"""评分用团队中位:PeriodMetrics 字段中位 + 派生率/占比的员工级中位
(scoring 要求 team 键=派生指标名,见其模块 docstring)。"""
team = dict(team_medians(metrics))
for key in _DERIVED_MEDIAN_KEYS:
vals = [
v for v in (derive_metrics(pm).get(key) for pm in metrics.values())
if v is not None
]
team[key] = statistics.median(vals) if vals else None
return team
def score_with_guards(
config: dict,
cur: PeriodMetrics,
baseline: PeriodMetrics | None = None,
team: dict[str, float] | None = None,
prev: PeriodMetrics | None = None,
) -> dict:
"""score_employee + min_days 接线(B2 评审遗留):当前窗天数 <
config.sample_guards.min_days → score_status 覆盖 insufficient_sample,
total/明细保留展示(按天稀疏的数据不参与考核但可查看)。"""
score = score_employee(config, cur, baseline=baseline, team=team, prev=prev)
min_days = config.get("sample_guards", {}).get("min_days", 5)
if cur.days_with_data < min_days:
score["score_status"] = "insufficient_sample"
return score
async def assemble_employees(
session: AsyncSession,
config: dict,
start_day: int,
end_day: int,
channel: str | None,
*,
with_prev: bool = True,
) -> dict[str, dict]:
"""全员组装:staff → 当前/基线(/前)窗 period_metrics → 逐员工
score_with_guards + detect_anomalies(纯函数循环)。
dashboard 传 with_prev=False(只需评分+异常,省一次前窗聚合);
employees/detail 需要 prev/团队中位时传 True。返回
{uid: {name, is_on_job, cur, prev, baseline, score, anomalies,
anomaly_count, anomaly_weight}}——过滤/排序/分页由调用方做。
"""
staff_rows = (await session.execute(
sa.select(Staff.user_id, Staff.name, Staff.is_on_job)
)).all()
staff = {uid: (name, onjob) for uid, name, onjob in staff_rows if uid}
if not staff:
return {}
n_days = len(day_range(start_day, end_day))
cur = await period_metrics(session, start_day, end_day, channel)
bs, be = baseline_window(start_day)
base = await period_metrics(session, bs, be, channel)
prev: dict[str, PeriodMetrics] | None = None
team: dict[str, float | None] | None = None
if with_prev:
ps, pe = prev_window(start_day, end_day)
prev = await period_metrics(session, ps, pe, channel)
team = team_derived_medians(cur)
out: dict[str, dict] = {}
for uid, (name, onjob) in staff.items():
c = cur.get(uid)
if c is None: # 防御:period_metrics 恒含全员,此处不该走到
continue
b = base.get(uid)
p = prev.get(uid) if prev is not None else None
score = score_with_guards(config, c, baseline=b, team=team, prev=p)
anomalies = detect_anomalies(uid, c, b, n_days=n_days, config=config)
out[uid] = {
"name": name,
"is_on_job": onjob,
"cur": c,
"prev": p,
"baseline": b,
"score": score,
"anomalies": anomalies,
"anomaly_count": len(anomalies),
"anomaly_weight": sum(
SEVERITY_WEIGHT.get(a["severity"], 0) for a in anomalies
),
}
return out
def _code_to_source(config: dict) -> dict[str, str]:
"""配置指标 code → 源指标名(spec.metric,无则 code 本身)。
comparisons 键域统一为源指标名(与 derive_metrics / sections 端点一致,
冻结批次C契约):active_friends_band→active_friends、call_band→call_count,
其余 code 本身即源名。"""
out: dict[str, str] = {}
for dim in config.get("dimensions", {}).values():
for code, spec in dim.get("metrics", {}).items():
out[code] = spec.get("metric", code)
return out
def build_employee_row(uid: str, data: dict, config: dict) -> dict:
"""employees 列表行:metrics 全字段 + comparisons 四值(自评分
metrics_detail 透传,键映射为源指标名)+ score 摘要 + 异常计数/权重。"""
score = data["score"]
code_src = _code_to_source(config)
return {
"user_id": uid,
"name": data["name"],
"is_on_job": data["is_on_job"],
"metrics": data["cur"]._asdict(),
"comparisons": {
code_src.get(code, code): {
"current": d["value"],
"prev": d["prev"],
"baseline": d["baseline"],
"median": d["median"],
}
for code, d in score["metrics_detail"].items()
},
"score": {
"total_score": score["total_score"],
"grade": score["grade"],
"score_status": score["score_status"],
"trial": score["trial"],
"dimensions": {
key: {"score": dim["score"], "status": dim["status"]}
for key, dim in score["dimensions"].items()
},
},
"anomaly_count": data["anomaly_count"],
"anomaly_weight": data["anomaly_weight"],
}
def derived_comparisons(
cur: PeriodMetrics,
prev: PeriodMetrics | None,
baseline: PeriodMetrics | None,
team: dict[str, float | None] | None,
) -> dict[str, dict]:
"""四对比(current/prev/baseline/median),派生口径=scoring.derive_metrics
——评分、异常、展示三方同一派生式。"""
cv = derive_metrics(cur)
pv = derive_metrics(prev) if prev is not None else {}
bv = derive_metrics(baseline) if baseline is not None else {}
return {
key: {
"current": cv.get(key),
"prev": pv.get(key),
"baseline": bv.get(key),
"median": team.get(key) if team else None,
}
for key in cv
}
async def wide_daily_rows(
session: AsyncSession,
start_day: int,
end_day: int,
channel: str | None,
user_id: str | None = None,
) -> list[dict]:
"""宽表逐日聚合(加性列跨渠道求和;user_id=None 为全员合计)。
first_reply_p50/p90_ms 是 (day,channel,user) 行内单值不可求和:
单员工视图(channel=None 双渠道同日)取当日 cust_threads 较大渠道的
值(当日主力渠道,并列取 wechat);全员视图不取用这两列(分位由
service_daily_trend 从 fact 精确算)。
"""
conds = [
EmployeeMetricDaily.day.between(start_day, end_day),
EmployeeMetricDaily.metric_version == METRIC_VERSION,
]
if channel is not None:
conds.append(EmployeeMetricDaily.channel == channel)
if user_id is not None:
conds.append(EmployeeMetricDaily.user_id == user_id)
rows = (await session.execute(
sa.select(
EmployeeMetricDaily.day.label("day"),
EmployeeMetricDaily.channel.label("ch"),
sa.func.sum(EmployeeMetricDaily.msg_sent).label("sent"),
sa.func.sum(EmployeeMetricDaily.msg_recv).label("recv"),
sa.func.sum(EmployeeMetricDaily.active_friends).label("af"),
sa.func.sum(EmployeeMetricDaily.active_rooms).label("ar"),
sa.func.sum(EmployeeMetricDaily.cust_threads).label("thr"),
sa.func.sum(EmployeeMetricDaily.threads_answered).label("ans"),
sa.func.sum(EmployeeMetricDaily.fr_sum_ms).label("frs"),
sa.func.sum(EmployeeMetricDaily.new_customer_count).label("nw"),
sa.func.sum(EmployeeMetricDaily.cooling_customer_count).label("cool"),
sa.func.sum(EmployeeMetricDaily.dormant_customer_count).label("dorm"),
sa.func.sum(EmployeeMetricDaily.reactivated_customer_count).label("react"),
sa.func.max(EmployeeMetricDaily.first_reply_p50_ms).label("p50"),
sa.func.max(EmployeeMetricDaily.first_reply_p90_ms).label("p90"),
).where(*conds).group_by(EmployeeMetricDaily.day, EmployeeMetricDaily.channel)
)).all()
per_day: dict[int, dict] = {}
def _blank(day: int) -> dict:
return {
"day": day, "msg_sent": 0, "msg_recv": 0, "active_friends": 0,
"active_rooms": 0, "cust_threads": 0, "threads_answered": 0,
"fr_sum_ms": 0, "new_customer_count": 0,
"cooling_customer_count": 0, "dormant_customer_count": 0,
"reactivated_customer_count": 0,
"first_reply_p50_ms": None, "first_reply_p90_ms": None,
"_pick": (-1, -1), # 主力渠道追踪 (cust_threads, wechat 加分)
}
for (day, ch, sent, recv, af, ar, thr, ans, frs, nw, cool, dorm,
react, p50, p90) in rows:
d = per_day.setdefault(day, _blank(day))
d["msg_sent"] += int(sent or 0)
d["msg_recv"] += int(recv or 0)
d["active_friends"] += int(af or 0)
d["active_rooms"] += int(ar or 0)
d["cust_threads"] += int(thr or 0)
d["threads_answered"] += int(ans or 0)
d["fr_sum_ms"] += int(frs or 0)
d["new_customer_count"] += int(nw or 0)
d["cooling_customer_count"] += int(cool or 0)
d["dormant_customer_count"] += int(dorm or 0)
d["reactivated_customer_count"] += int(react or 0)
if user_id is not None:
pick = (int(thr or 0), 1 if ch == "wechat" else 0)
if pick > d["_pick"]:
d["_pick"] = pick
d["first_reply_p50_ms"] = p50
d["first_reply_p90_ms"] = p90
out = []
for day in sorted(per_day):
d = per_day[day]
d.pop("_pick", None)
out.append(d)
return out
async def call_daily_rows(
session: AsyncSession,
start_day: int,
end_day: int,
user_id: str | None = None,
) -> list[dict]:
"""通话小表逐日聚合(恒全渠道——表无渠道维);user_id=None 为全员。"""
conds = [EmployeeCallDaily.day.between(start_day, end_day)]
if user_id is not None:
conds.append(EmployeeCallDaily.user_id == user_id)
rows = (await session.execute(
sa.select(
EmployeeCallDaily.day,
sa.func.sum(EmployeeCallDaily.call_count),
sa.func.sum(EmployeeCallDaily.call_seconds),
sa.func.sum(EmployeeCallDaily.answered_call_count),
sa.func.sum(EmployeeCallDaily.call_followup_count),
).where(*conds).group_by(EmployeeCallDaily.day)
)).all()
return [
{
"day": day, "call_count": int(cc or 0),
"call_seconds": int(cs or 0),
"answered_call_count": int(ca or 0),
"call_followup_count": int(cf or 0),
}
for day, cc, cs, ca, cf in rows
]
async def service_daily_trend(
session: AsyncSession,
start_day: int,
end_day: int,
channel: str | None,
threads_by_day: dict[int, int],
) -> list[dict]:
"""服务趋势(dashboard 全团队):fact 逐日 (day, fr_ms) 行拉回 Python
最近邻秩分位(跨员工合并样本——团队级分位);reply_2h_rate = fact
当日 ≤2h 计数 / 宽表当日 cust_threads 合计(分母 0 → None)。
输出全窗口逐日(无数据日 p50/p90 为 None),threads_by_day 来自
wide_daily_rows 的全员 cust_threads 列。
"""
conds = [FirstReplyFact.day.between(start_day, end_day)]
if channel is not None:
conds.append(FirstReplyFact.channel == channel)
rows = (await session.execute(
sa.select(FirstReplyFact.day, FirstReplyFact.fr_ms).where(*conds)
)).all()
by_day: dict[int, list[int]] = {}
for day, fr in rows:
by_day.setdefault(day, []).append(int(fr))
out = []
for day in day_range(start_day, end_day):
frs = by_day.get(day, [])
cnt_2h = sum(1 for f in frs if f <= 7_200_000)
denom = threads_by_day.get(day, 0)
out.append({
"day": day,
"fr_p50_ms": percentile(frs, 1, 2),
"fr_p90_ms": percentile(frs, 9, 10),
"reply_2h_rate": (cnt_2h / denom) if denom else None,
})
return out
async def user_owner_accounts(
session: AsyncSession, user_id: str, channel: str | None
) -> list[str]:
"""员工在所选渠道下的 owner 账号(channel=None → 两渠道合并)。"""
chs = ("wechat", "whatsapp") if channel is None else (channel,)
out: list[str] = []
for ch in chs:
amap = await account_map(session, ch)
out.extend(owner for owner, uid in amap.items() if uid == user_id)
return out
async def lifecycle_customers(
session: AsyncSession,
owner_accounts: list[str],
as_of_day: int,
want_state: str,
) -> list[dict]:
"""drilldown cooling/dormant:该员工账号归属客户(delete_flag 过滤)+
msg_daily_stats friend 行全历史 → lifecycle.classify(as_of=窗口末日),
返回状态匹配行 {friend_id, channel, nickname, last_day, state},按 last_day asc
(最陈旧在前)、friend_id asc 排序。
历史按 (channel, peer) 键控、跨 owner 合并——与批次A _lifecycle_daily
的 peer 键控口径一致(归属由 Customer.owner_account_id 决定,互动史
不随归属走:共享/换归属客户的全部历史都算,否则 KPI 计了而名单丢人)。
hist 为 ≤as_of 的历史消息数(时点语义,as_of 之后的消息不虚增沉睡门槛)。"""
custs = (await session.execute(
sa.select(Customer).where(
sa.or_(Customer.delete_flag.is_(None), Customer.delete_flag == 0),
Customer.owner_account_id.in_(owner_accounts),
)
)).scalars().all()
if not custs:
return []
ids = [c.friend_id for c in custs]
rows = (await session.execute(
sa.select(
MsgDailyStats.channel,
MsgDailyStats.peer_id,
MsgDailyStats.day,
MsgDailyStats.send_cnt,
MsgDailyStats.recv_cnt,
).where(
MsgDailyStats.peer_kind == "friend",
MsgDailyStats.peer_id.in_(ids),
)
)).all()
hist: dict[tuple[str, str], dict[int, int]] = {}
for ch, peer, day, snd, rcv in rows:
dm = hist.setdefault((ch, peer), {})
dm[day] = dm.get(day, 0) + int(snd or 0) + int(rcv or 0)
matches: list[dict] = []
for c in custs:
dm = hist.get((c.channel, c.friend_id))
if not dm:
continue
days_list = sorted(dm)
idx = bisect_right(days_list, as_of_day) - 1
if idx < 0:
continue # 窗口末日之前从未互动
last = days_list[idx]
prev_d = days_list[idx - 1] if idx > 0 else None
hist_msgs = sum(dm[d] for d in days_list[: idx + 1])
state = classify(days_list[0], last, prev_d, as_of_day, hist_msgs)
if state == want_state:
matches.append({
"friend_id": c.friend_id,
"channel": c.channel,
"nickname": c.friend_nickname or c.friend_remark or c.friend_id,
"last_day": last,
"state": state,
})
matches.sort(key=lambda m: (m["last_day"], m["friend_id"]))
return matches
async def fr_fact_rows(
session: AsyncSession,
owner_accounts: list[str],
channel: str | None,
start_day: int,
end_day: int,
*,
page: int,
page_size: int,
) -> tuple[int, list[dict]]:
"""drilldown 首响分位:该员工账号窗内 fact 逐串证据行,fr_ms desc
(同值按日/串末时刻 desc 决胜)分页。chat_hint 给出会话定位参数
(owner+talker 直达聊天记录)。返回 (total, items)。"""
conds = [
FirstReplyFact.day.between(start_day, end_day),
FirstReplyFact.owner_account_id.in_(owner_accounts),
]
if channel is not None:
conds.append(FirstReplyFact.channel == channel)
total = (await session.execute(
sa.select(sa.func.count()).select_from(FirstReplyFact).where(*conds)
)).scalar_one()
rows = (await session.execute(
sa.select(FirstReplyFact).where(*conds)
.order_by(FirstReplyFact.fr_ms.desc(), FirstReplyFact.day.desc(),
FirstReplyFact.thread_end_ts.desc())
.offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
items = [{
"day": f.day,
"channel": f.channel,
"owner_account_id": f.owner_account_id,
"peer_id": f.peer_id,
"thread_end_ts": f.thread_end_ts,
"reply_ts": f.reply_ts,
"fr_ms": f.fr_ms,
"chat_hint": {"owner_account_id": f.owner_account_id,
"talker_id": f.peer_id},
} for f in rows]
return int(total), items
async def user_calls_page(
session: AsyncSession,
user_id: str,
start_day: int,
end_day: int,
*,
page: int,
page_size: int,
) -> tuple[int, list[dict]]:
"""drilldown 通话跟进:该员工窗内通话行(start_call_time desc,
call_id desc 决胜)分页。返回 (total, items)。"""
lo, _ = day_bounds_ms(start_day)
_, hi = day_bounds_ms(end_day) # 末日次日 00:00(左闭右开上界)
conds = [
PhoneCall.user_id == user_id,
PhoneCall.start_call_time.is_not(None),
PhoneCall.start_call_time >= lo,
PhoneCall.start_call_time < hi,
]
total = (await session.execute(
sa.select(sa.func.count()).select_from(PhoneCall).where(*conds)
)).scalar_one()
rows = (await session.execute(
sa.select(PhoneCall).where(*conds)
.order_by(PhoneCall.start_call_time.desc(), PhoneCall.call_id.desc())
.offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
items = [{
"call_id": c.call_id,
"channel": c.channel,
"call_number": c.call_number,
"start_call_time": c.start_call_time,
"call_duration": c.call_duration,
} for c in rows]
return int(total), items
@@ -0,0 +1,342 @@
"""管理域期聚合查询(批次B)——评分/异常/employees API 的单一数据源。
窗口:days=[start_day..end_day] 闭区间 yyyymmdd;上期=等长紧邻前窗;
个人基线=紧邻当前窗之前的 28 天。所有查询走 (day, ...) 索引,不扫消息表
(除 distinct 活跃客户走 msg_daily_stats friend 行)。
口径要点:
- 分位/回复计数:first_reply_fact 窗内行拉回 Python 按员工合并排序取
最近邻秩 ceil(q·n)(整除算术 (n+1)//2 / (9n+9)//10——SQLite 无原生
ceil,MySQL `/` 返回浮点,Python 侧取秩零方言风险)。合并按员工而非
owner:一员工多账号(wx1+wx2→u1,channel=None 时人人如此)的样本必须
跨账号归并,逐 owner 取分位再映射会互相覆盖。fact 全库全期 ≤6.8万行,
Python 侧聚合可行。
- 期末存量(new/cooling/dormant/end_active_friends):状态快照取
「每 (user, channel) 最新 ≤end 行」——channel=None 时两渠道各取各的
最新行再相加(直接取全渠道最大 day 的那一行会丢另一渠道的存量)。
end_active_friends 与 new/cooling/dormant 同源同行(该渠道最新行的
active_friends 列),供评分 active_share 用——区别于 active_friends
字段的周期 distinct 口径。
- active_rooms_avg = SUM(active_rooms)/全窗天数(含无数据日摊薄),
非有数据天数——「日均负载」语义。
- days_with_data = COUNT(DISTINCT day):单渠道时与 COUNT(*) 等价
(PK 含 day+channel,每日至多一行),channel=None 双渠道同日也只计 1 天。
- 通话四列恒全渠道(employee_call_daily 无渠道维),channel 参数不过滤。
"""
from __future__ import annotations
import datetime as dt
import statistics
from collections import namedtuple
from collections.abc import Iterable
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import (
EmployeeCallDaily,
EmployeeMetricDaily,
FirstReplyFact,
MsgDailyStats,
Staff,
)
from app.storage.employee_metrics import METRIC_VERSION
from app.storage.staff_summary import account_map
PeriodMetrics = namedtuple("PeriodMetrics", [
"user_id", "days_with_data", "msg_sent", "msg_recv", "cust_threads",
"threads_answered", "fr_sum_ms", "fr_p50_ms", "fr_p90_ms",
"reply_2h_count", "reply_24h_count",
"active_friends", # 期内 distinct(直查,非日值求和)
"active_rooms_avg", # 日均(全窗天数摊薄)
"new_customer", "cooling_customer", "dormant_customer", # 期末单日存量
"end_active_friends", # 期末行 active_friends 跨渠道 SUM(active_share 分子)
"reactivated_customer", # 期内求和
"call_count", "call_seconds", "answered_call", "call_followup",
])
def _to_date(k: int) -> dt.date:
return dt.date(k // 10000, k // 100 % 100, k % 100)
def _to_key(d: dt.date) -> int:
return d.year * 10000 + d.month * 100 + d.day
def day_range(start: int, end: int) -> list[int]:
"""闭区间日键列表。"""
out, cur = [], _to_date(start)
while cur <= _to_date(end):
out.append(_to_key(cur))
cur += dt.timedelta(days=1)
return out
def prev_window(start: int, end: int) -> tuple[int, int]:
"""等长紧邻前窗。"""
n = len(day_range(start, end))
pe = _to_date(start) - dt.timedelta(days=1)
ps = pe - dt.timedelta(days=n - 1)
return _to_key(ps), _to_key(pe)
def baseline_window(start: int) -> tuple[int, int]:
"""个人基线:紧邻当前窗之前的 28 天。"""
be = _to_date(start) - dt.timedelta(days=1)
bs = be - dt.timedelta(days=27)
return _to_key(bs), _to_key(be)
def percentile(vals: list[int], q_num: int, q_den: int) -> int | None:
"""最近邻秩分位 ceil(q·n) 的样本值,q=q_num/q_den(1/2、9/10)。
公有供同包 api_assembly(B4 服务趋势)复用;整数算术
rank=(q_num*n + q_den - 1)//q_den 对正整数 n 恒落在 [1,n]
(与 math.ceil(q·n) 等价,与批次A storage 层 _percentile 同口径——
后者为不同层单独保留);空列表→None(与 0 严格区分)。
"""
if not vals:
return None
s = sorted(vals)
n = len(s)
rank = max(1, min(n, (q_num * n + q_den - 1) // q_den))
return s[rank - 1]
# 累加器初值:全员(staff)都出结果行,无数据员工为零值而非缺行——
# employees API 全员列表语义。
_ZERO: dict[str, object] = {
"days_with_data": 0, "msg_sent": 0, "msg_recv": 0, "cust_threads": 0,
"threads_answered": 0, "fr_sum_ms": 0,
"fr_p50_ms": None, "fr_p90_ms": None,
"reply_2h_count": 0, "reply_24h_count": 0,
"active_friends": 0, "active_rooms_sum": 0,
"new_customer": 0, "cooling_customer": 0, "dormant_customer": 0,
"end_active_friends": 0,
"reactivated_customer": 0,
"call_count": 0, "call_seconds": 0, "answered_call": 0, "call_followup": 0,
}
async def period_metrics(
session: AsyncSession,
start_day: int,
end_day: int,
channel: str | None,
user_ids: Iterable[str] | None = None,
) -> dict[str, PeriodMetrics]:
"""窗口 [start_day, end_day] × channel(None=全渠道)的员工期指标。
user_ids 传入时与 staff 表求交(幽灵过滤);返回含全部目标员工的
PeriodMetrics(无数据员工为零值行)。channel=None 时两渠道
account_map 合并(wx_id 与 WA uid 命名空间不撞,无冲突)。
"""
staff_ids = {
u for u in (await session.execute(sa.select(Staff.user_id))).scalars() if u
}
target = staff_ids if user_ids is None else staff_ids & set(user_ids)
if not target:
return {}
if channel is None:
amap: dict[str, str] = {}
for ch in ("wechat", "whatsapp"):
amap.update(await account_map(session, ch))
else:
amap = await account_map(session, channel)
n_days = len(day_range(start_day, end_day))
acc: dict[str, dict] = {uid: dict(_ZERO) for uid in target}
# ---- 宽表 SUM:消息计数/串类/日均负载/复活 ----
wide_conds = [
EmployeeMetricDaily.day.between(start_day, end_day),
EmployeeMetricDaily.metric_version == METRIC_VERSION,
EmployeeMetricDaily.user_id.in_(target),
]
if channel is not None:
wide_conds.append(EmployeeMetricDaily.channel == channel)
wide = (await session.execute(
sa.select(
EmployeeMetricDaily.user_id.label("uid"),
sa.func.count(sa.distinct(EmployeeMetricDaily.day)).label("nd"),
sa.func.sum(EmployeeMetricDaily.msg_sent).label("sent"),
sa.func.sum(EmployeeMetricDaily.msg_recv).label("recv"),
sa.func.sum(EmployeeMetricDaily.cust_threads).label("thr"),
sa.func.sum(EmployeeMetricDaily.threads_answered).label("ans"),
sa.func.sum(EmployeeMetricDaily.fr_sum_ms).label("frs"),
sa.func.sum(EmployeeMetricDaily.active_rooms).label("rooms"),
sa.func.sum(
EmployeeMetricDaily.reactivated_customer_count
).label("react"),
).where(*wide_conds).group_by(EmployeeMetricDaily.user_id)
)).all()
for uid, nd, sent, recv, thr, ans, frs, rooms, react in wide:
a = acc[uid]
a["days_with_data"] = int(nd or 0)
a["msg_sent"] = int(sent or 0)
a["msg_recv"] = int(recv or 0)
a["cust_threads"] = int(thr or 0)
a["threads_answered"] = int(ans or 0)
a["fr_sum_ms"] = int(frs or 0)
a["active_rooms_sum"] = int(rooms or 0)
a["reactivated_customer"] = int(react or 0)
# ---- 期末存量:每 (user, channel) 最新 ≤end 行,跨渠道相加 ----
stock_conds = [
EmployeeMetricDaily.day <= end_day,
EmployeeMetricDaily.metric_version == METRIC_VERSION,
EmployeeMetricDaily.user_id.in_(target),
]
if channel is not None:
stock_conds.append(EmployeeMetricDaily.channel == channel)
latest = (await session.execute(
sa.select(
EmployeeMetricDaily.user_id.label("uid"),
EmployeeMetricDaily.channel.label("ch"),
sa.func.max(EmployeeMetricDaily.day).label("d"),
).where(*stock_conds).group_by(
EmployeeMetricDaily.user_id, EmployeeMetricDaily.channel
)
)).all()
if latest:
# (user, channel, day) 含 metric_version 唯一(主键)——OR 点查
# 精确取回各渠道最新行,再按 user 跨渠道 SUM
stocks = (await session.execute(
sa.select(
EmployeeMetricDaily.user_id.label("uid"),
sa.func.sum(EmployeeMetricDaily.new_customer_count).label("nw"),
sa.func.sum(
EmployeeMetricDaily.cooling_customer_count
).label("cool"),
sa.func.sum(
EmployeeMetricDaily.dormant_customer_count
).label("dorm"),
sa.func.sum(EmployeeMetricDaily.active_friends).label("af"),
).where(sa.or_(*(
sa.and_(
EmployeeMetricDaily.user_id == uid,
EmployeeMetricDaily.channel == ch,
EmployeeMetricDaily.day == d,
EmployeeMetricDaily.metric_version == METRIC_VERSION,
)
for uid, ch, d in latest
))).group_by(EmployeeMetricDaily.user_id)
)).all()
for uid, nw, cool, dorm, af in stocks:
a = acc[uid]
a["new_customer"] = int(nw or 0)
a["cooling_customer"] = int(cool or 0)
a["dormant_customer"] = int(dorm or 0)
a["end_active_friends"] = int(af or 0)
# ---- fact 分位 + 2h/24h 回复计数(同一过滤,Python 侧一次聚合) ----
fr_conds = [FirstReplyFact.day.between(start_day, end_day)]
if channel is not None:
fr_conds.append(FirstReplyFact.channel == channel)
fr_rows = (await session.execute(
sa.select(
FirstReplyFact.owner_account_id.label("owner"),
FirstReplyFact.fr_ms.label("fr"),
).where(*fr_conds)
)).all()
fr_by_user: dict[str, list[int]] = {}
for owner, fr in fr_rows:
uid = amap.get(owner or "")
if uid is not None and uid in target:
fr_by_user.setdefault(uid, []).append(int(fr))
for uid, frs in fr_by_user.items():
acc[uid]["fr_p50_ms"] = percentile(frs, 1, 2)
acc[uid]["fr_p90_ms"] = percentile(frs, 9, 10)
# 2h/24h 计数与分位同源同窗——回复率分子分母口径一致
acc[uid]["reply_2h_count"] = sum(1 for fr in frs if fr <= 7_200_000)
acc[uid]["reply_24h_count"] = sum(1 for fr in frs if fr <= 86_400_000)
# ---- distinct 活跃客户:账号归并到员工后按 peer 去重 ----
af_conds = [
MsgDailyStats.day.between(start_day, end_day),
MsgDailyStats.peer_kind == "friend",
]
if channel is not None:
af_conds.append(MsgDailyStats.channel == channel)
pairs = (await session.execute(
sa.select(MsgDailyStats.owner_account_id, MsgDailyStats.peer_id)
.distinct().where(*af_conds)
)).all()
active: dict[str, set[str]] = {}
for owner, peer in pairs:
uid = amap.get(owner or "")
if uid is None or uid not in target:
continue
# (uid, peer) 粒度:同一员工多账号(wx1+wx2)的共享客户只计 1
# ——与宽表日值口径一致(storage 侧账号归并后 add(peer))
active.setdefault(uid, set()).add(peer or "")
for uid, pset in active.items():
acc[uid]["active_friends"] = len(pset)
# ---- 通话:恒全渠道(表无渠道维,channel 参数不过滤) ----
calls = (await session.execute(
sa.select(
EmployeeCallDaily.user_id.label("uid"),
sa.func.sum(EmployeeCallDaily.call_count).label("cc"),
sa.func.sum(EmployeeCallDaily.call_seconds).label("cs"),
sa.func.sum(EmployeeCallDaily.answered_call_count).label("ca"),
sa.func.sum(EmployeeCallDaily.call_followup_count).label("cf"),
).where(
EmployeeCallDaily.day.between(start_day, end_day),
EmployeeCallDaily.user_id.in_(target),
).group_by(EmployeeCallDaily.user_id)
)).all()
for uid, cc, cs, ca, cf in calls:
a = acc[uid]
a["call_count"] = int(cc or 0)
a["call_seconds"] = int(cs or 0)
a["answered_call"] = int(ca or 0)
a["call_followup"] = int(cf or 0)
return {
uid: PeriodMetrics(
user_id=uid,
days_with_data=a["days_with_data"],
msg_sent=a["msg_sent"],
msg_recv=a["msg_recv"],
cust_threads=a["cust_threads"],
threads_answered=a["threads_answered"],
fr_sum_ms=a["fr_sum_ms"],
fr_p50_ms=a["fr_p50_ms"],
fr_p90_ms=a["fr_p90_ms"],
reply_2h_count=a["reply_2h_count"],
reply_24h_count=a["reply_24h_count"],
active_friends=a["active_friends"],
active_rooms_avg=(a["active_rooms_sum"] / n_days) if n_days else 0.0,
new_customer=a["new_customer"],
cooling_customer=a["cooling_customer"],
dormant_customer=a["dormant_customer"],
end_active_friends=a["end_active_friends"],
reactivated_customer=a["reactivated_customer"],
call_count=a["call_count"],
call_seconds=a["call_seconds"],
answered_call=a["answered_call"],
call_followup=a["call_followup"],
)
for uid, a in acc.items()
}
def team_medians(metrics: dict[str, PeriodMetrics]) -> dict[str, float | None]:
"""每数值字段的全员中位数(纯函数,评分归一用)。
None(无样本的分位等)剔除后取中位——statistics.median:奇数取中位
样本、偶数取中间两数均值;全员 None → None(调用方按无基线处理)。
"""
out: dict[str, float | None] = {}
for field in PeriodMetrics._fields:
if field == "user_id":
continue
vals = [
v for v in (getattr(m, field) for m in metrics.values())
if v is not None
]
out[field] = statistics.median(vals) if vals else None
return out
@@ -0,0 +1,205 @@
"""管理域指标元数据与默认评分配置(批次B,v2 §6/§10)。
METRIC_DEFINITIONS 是 /api/management/metric-definitions 的单一数据源:
名称/单位/方向/口径说明(含与现有看板的三方口径差异标注)。
DEFAULT_SCORE_CONFIG 为 v1.0 试算起点——所有阈值标「试算」,调整走
新配置版本(employee_score_config),不静默改数。
"""
from __future__ import annotations
import copy
import sqlalchemy as sa
# direction: +1 越大越好 / -1 越小越好;caliber 注明与看板差异
METRIC_DEFINITIONS: dict[str, dict] = {
"msg_sent": {"name": "发出消息(1:1)", "unit": "条", "direction": 1,
"caliber": "仅私聊;现有看板为私聊+群聊混合"},
"msg_recv": {"name": "收到消息(1:1)", "unit": "条", "direction": 1,
"caliber": "仅私聊"},
"cust_threads": {"name": "客户消息串", "unit": "串", "direction": 1,
"caliber": "跨日精确,按串末条归属日;看板为日切口径(多日串重复计)"},
"threads_answered": {"name": "被回串数", "unit": "串", "direction": 1,
"caliber": "≡first_reply_fact行数(同源)"},
"fr_avg_ms": {"name": "平均首响", "unit": "ms", "direction": -1,
"caliber": "fr_sum_ms/threads_answered"},
"fr_p50_ms": {"name": "首响中位数", "unit": "ms", "direction": -1,
"caliber": "周期内 fact 精确分位(窗口函数)"},
"fr_p90_ms": {"name": "首响P90", "unit": "ms", "direction": -1,
"caliber": "周期内 fact 精确分位;日切口径会丢跨夜长尾"},
"reply_2h_rate": {"name": "2h回复率", "unit": "0-1", "direction": 1,
"formula": "count(fr_ms<=2h)/cust_threads",
"caliber": "分子分母同源(fact/宽表同一算法)"},
"reply_24h_rate": {"name": "24h回复率", "unit": "0-1", "direction": 1,
"formula": "count(fr_ms<=24h)/cust_threads"},
"active_friends": {"name": "活跃客户(周期)", "unit": "人", "direction": 1,
"caliber": "期内distinct客户(msg_daily_stats直查),非宽表日值求和"},
"active_rooms": {"name": "活跃群(日均)", "unit": "个", "direction": 1},
"new_customer_count": {"name": "新客(期末)", "unit": "人", "direction": 1,
"caliber": "期末单日持有状态,非期内求和"},
"cooling_customer_count": {"name": "降温客户(期末)", "unit": "人", "direction": -1,
"caliber": "距今天数口径(7-29天);频次基线口径二期"},
"dormant_customer_count": {"name": "沉睡客户(期末)", "unit": "人", "direction": -1,
"caliber": "≥30天且历史≥5条"},
"reactivated_customer_count": {"name": "回流客户(期内)", "unit": "人", "direction": 1,
"caliber": "当日回流且前次间隔>30天,期内求和"},
"call_count": {"name": "通话数", "unit": "通", "direction": 1,
"caliber": "语音通话(微信+WA),无消息渠道维度"},
"answered_rate": {"name": "接通率", "unit": "0-1", "direction": 1,
"formula": "answered_call/call_count", "caliber": "接通=duration>0"},
"call_followup_rate": {"name": "通话后跟进率", "unit": "0-1", "direction": 1,
"formula": "call_followup_count/answered_call",
"caliber": "接通后24h内任一账号有1:1消息(含客户发来)"},
"active_share": {"name": "活跃客户占比", "unit": "0-1", "direction": 1,
"formula": "期末active_friends/(期末active+cooling+dormant)",
"caliber": "试算口径:期末单日存量;宽表无生命周期active存量列,active以active_friends列充当"},
}
# 阈值全部为试算起点(v2 §0 决议:全默认+试算标记);type 三种:
# target : floor..target 线性 0..100(方向已含在 target>floor)
# reverse : ≤target_ms 100 分,≥floor_ms 0 分,中间线性降
# band : 区间内 100,超上限 80(不再加分),低于下限 None(负载
# 不足→不评分)——得分取值范围 80..100
DEFAULT_SCORE_CONFIG: dict = {
"version": "v1.0",
"name": "一期试算(3维可用)",
"normalization": "available_only_rescale", # 可用维度 100/Σweight 再 ×100
"trial": True,
"sample_guards": {"min_threads": 30, "soft_threads": 10, "min_days": 5},
"grade_bounds": {"A": 90, "B": 80, "C": 70, "D": 60}, # 低于 D 线为 E
"dimensions": {
"customer": {"weight": 30, "name": "客户经营", "metrics": {
"active_share": {"type": "target", "floor": 0.20, "target": 0.60},
}},
"service": {"weight": 25, "name": "服务质量", "metrics": {
"reply_2h_rate": {"type": "target", "floor": 0.40, "target": 0.85},
"reply_24h_rate": {"type": "target", "floor": 0.75, "target": 0.97},
"fr_p90_ms": {"type": "reverse", "target_ms": 1_800_000,
"floor_ms": 14_400_000}, # 30min..4h
}},
"action": {"weight": 20, "name": "行动执行",
"status": "pending_data"}, # 批次E 接 followups/任务
"workload": {"weight": 15, "name": "有效负载", "metrics": {
# basis=period_distinct:用周期 distinct active_friends(B1 口径),
# 比单日期末值更抗单日噪声(2026-09-09 B2 决议)
"active_friends_band": {"type": "band", "metric": "active_friends",
"low": 5, "high": 80,
"basis": "period_distinct"},
"call_band": {"type": "band", "metric": "call_count",
"low": 1, "high": 200, "basis": "period_total",
"note": "通话低不扣分仅提示;band 得分下限 80"},
}},
"risk": {"weight": 10, "name": "合规风险",
"status": "pending_data"}, # 批次E/三期接风险事件
},
}
def validate_config(config: dict) -> list[str]:
"""配置校验:权重总和/结构/引用的 metric_code 有元数据/阈值形状/
grade_bounds 形状/指标 code 跨维度唯一。
阈值形状(随评分引擎落地的 B0 评审预埋要求):
- type 须为 target/reverse/band 三者之一
- target : floor < target
- reverse: target_ms < floor_ms(100 分线在 0 分线之前)
- band : low < high
B4 预埋(配置将自 DB 加载,写入侧不再重跑种子校验):
- grade_bounds 须含 A/B/C/D 且严格降序
- 指标 code 跨维度不得重复:metrics_detail 为扁平 dict,
同名 code 会相互覆盖(后写胜出)
- 源指标名跨维度不得重复:employees 行 comparisons 按源名建键
(api_assembly._code_to_source),两个 code 指向同一源名会静默覆盖
"""
problems: list[str] = []
dims = config.get("dimensions", {})
total = sum(d.get("weight", 0) for d in dims.values())
if total != 100:
problems.append(f"维度权重总和须=100,当前 {total}")
gb = config.get("grade_bounds")
if not isinstance(gb, dict) or not all(
isinstance(gb.get(k), int) for k in ("A", "B", "C", "D")
):
problems.append("grade_bounds 须含 A/B/C/D 且严格降序")
elif not (gb["A"] > gb["B"] > gb["C"] > gb["D"]):
problems.append(
f"grade_bounds 须严格降序 A>B>C>D"
f"(当前 A={gb['A']} B={gb['B']} C={gb['C']} D={gb['D']})"
)
seen_codes: dict[str, str] = {}
seen_sources: dict[str, str] = {}
for key, dim in dims.items():
if dim.get("status") == "pending_data":
continue
for code, spec in dim.get("metrics", {}).items():
if code in seen_codes:
problems.append(f"指标 code 重复定义于多维度: {code}")
seen_codes[code] = key
src = spec.get("metric", code)
if src in seen_sources:
problems.append(
f"{key}.{code} 与 {seen_sources[src]} 指向同一源指标 {src}"
f"(comparisons 键域将相互覆盖)"
)
else:
seen_sources[src] = f"{key}.{code}"
if src not in METRIC_DEFINITIONS:
problems.append(f"{key}.{code} 引用未定义指标 {src}")
mtype = spec.get("type")
if mtype not in ("target", "reverse", "band"):
problems.append(
f"{key}.{code} 类型非法:{mtype!r}(须 target/reverse/band)"
)
elif mtype == "target" and not spec.get("floor", 0) < spec.get("target", 0):
problems.append(
f"{key}.{code} target 型须 floor<target"
f"(当前 floor={spec.get('floor')} target={spec.get('target')})"
)
elif mtype == "reverse" and not (
spec.get("target_ms", 0) < spec.get("floor_ms", 0)
):
problems.append(
f"{key}.{code} reverse 型须 target_ms<floor_ms"
f"(当前 target_ms={spec.get('target_ms')}"
f" floor_ms={spec.get('floor_ms')})"
)
elif mtype == "band" and not spec.get("low", 0) < spec.get("high", 0):
problems.append(
f"{key}.{code} band 型须 low<high"
f"(当前 low={spec.get('low')} high={spec.get('high')})"
)
return problems
async def _seed_score_config(session) -> str | None:
"""种子默认评分配置;已存在同版本返回 None(幂等)。纯会话函数可测。
同 ensure_starter_words 先例:域包提供纯会话种子函数,CLI 只做接线。
config_json 用 deepcopy 冻结快照——JSON 列须整列重赋值,深拷贝防止
长驻 API 进程未来的原地修改波及模块常量。
"""
from app.db.models import EmployeeScoreConfig
problems = validate_config(DEFAULT_SCORE_CONFIG)
if problems:
raise ValueError(f"score config invalid: {problems}")
exists = (
await session.execute(
sa.select(EmployeeScoreConfig.id).where(
EmployeeScoreConfig.version == DEFAULT_SCORE_CONFIG["version"]
)
)
).scalar_one_or_none()
if exists is not None:
return None
session.add(
EmployeeScoreConfig(
version=DEFAULT_SCORE_CONFIG["version"],
name=DEFAULT_SCORE_CONFIG["name"],
status="active",
config_json=copy.deepcopy(DEFAULT_SCORE_CONFIG),
created_by="seed-cli",
)
)
await session.flush()
return DEFAULT_SCORE_CONFIG["version"]
+230
View File
@@ -0,0 +1,230 @@
"""管理域评分引擎(批次B)——试算版(v2 §0 决议:全默认+试算标记)。
三类标准化(v1 §10.2):
- target : floor..target 线性 0..100(≤floor 得 0,≥target 得 100)
- reverse : ≤target_ms 100 分,≥floor_ms 0 分,中间线性降
- band : 区间内 100,超上限 80(不再加),低于下限 None(负载不足→不评分)
样本保护(v1 §10.3):指标样本量=串类 cust_threads/band 类 active_friends;
<soft_threads(10) insufficient 不评分;soft(10)~min_threads(30) 低置信照评。
维度分=指标均分(等权,None 剔除按剩余归一);总分=可用维度加权重归一
(满分 Σweight×100/Σweight,一期可用 70 → 100/70 放大回百分制)——
action/risk pending_data 不参与,不默认 0/100。
band 基数=周期 distinct active_friends(basis=period_distinct,B2 决议:
比单日期末值更抗噪声且与 B1 口径一致)。
team 中位数键=派生指标名(spec 的 metric 字段,无则 code 本身):
fr_p90_ms/active_friends/call_count 可直接用 team_medians 输出,
reply_*_rate/active_share 需调用方对派生值取中位后传入。
"""
from __future__ import annotations
import math
from app.management.employee_metrics import PeriodMetrics
from app.management.metric_definitions import METRIC_DEFINITIONS
_GRADE_ORDER = ("A", "B", "C", "D") # 低于 D 线为 E
def normalize(spec: dict, value: float | None) -> float | None:
"""三类标准化(v1 §10.2):value None→None;band 低于下限→None。
- target : (v-floor)/(target-floor) 线性 0..100,两端截断
- reverse : (floor_ms-v)/(floor_ms-target_ms) 线性降 100..0,两端截断
- band : [low,high] 内 100,>high 80(不再加),<low None(不评分)
"""
if value is None:
return None
mtype = spec["type"]
if mtype == "target":
floor, target = spec["floor"], spec["target"]
if value <= floor:
return 0.0
if value >= target:
return 100.0
return (value - floor) / (target - floor) * 100.0
if mtype == "reverse":
target_ms, floor_ms = spec["target_ms"], spec["floor_ms"]
if value <= target_ms:
return 100.0
if value >= floor_ms:
return 0.0
return (floor_ms - value) / (floor_ms - target_ms) * 100.0
if mtype == "band":
low, high = spec["low"], spec["high"]
if value < low:
return None
if value > high:
return 80.0
return 100.0
raise ValueError(f"未知指标类型 {mtype!r}(须 target/reverse/band)")
def derive_metrics(pm: PeriodMetrics) -> dict[str, float | None]:
"""从 PeriodMetrics 派生可评分指标值(键=指标名,None=无值≠0)。
公有供 anomalies(B3)/dashboard(B4) 复用——评分与异常对比同一派生口径。
"""
out: dict[str, float | None] = {
"fr_p90_ms": pm.fr_p90_ms,
"active_friends": pm.active_friends, # band 用,周期 distinct
"call_count": pm.call_count, # band 用,period_total
}
if pm.cust_threads:
out["reply_2h_rate"] = pm.reply_2h_count / pm.cust_threads
out["reply_24h_rate"] = pm.reply_24h_count / pm.cust_threads
else:
# 无串样本→无率(严格区分 0%),分子分母同源
out["reply_2h_rate"] = None
out["reply_24h_rate"] = None
denom = pm.end_active_friends + pm.cooling_customer + pm.dormant_customer
out["active_share"] = (
pm.end_active_friends / denom if denom > 0 else None
)
return out
def _fmt_ms(ms: float) -> str:
minutes = ms / 60_000
if minutes >= 60 and minutes % 60 == 0:
return f"{minutes // 60:g}h"
return f"{minutes:g}min"
def _formula_note(spec: dict) -> str:
"""指标计分公式的中文说明(B4 透出,阈值全部来自 config)。"""
mtype = spec["type"]
if mtype == "target":
return (
f"floor {spec['floor']}..target {spec['target']} 线性 0..100 分"
"(≤floor 0,≥target 100)"
)
if mtype == "reverse":
return (
f"≤{_fmt_ms(spec['target_ms'])} 100 分,"
f"≥{_fmt_ms(spec['floor_ms'])} 0 分,中间线性降"
)
return (
f"{spec['low']}..{spec['high']} 区间内 100 分,超上限 80 分,"
f"低于 {spec['low']} 不评分(负载不足);basis={spec.get('basis', '-')}"
)
def score_employee(
config: dict,
current: PeriodMetrics,
baseline: PeriodMetrics | None = None,
team: dict[str, float] | None = None,
prev: PeriodMetrics | None = None,
) -> dict:
"""单员工试算评分(纯函数,B4 端点直接透出)。
prev/baseline/team 为四对比位槽位:prev/baseline 按同口径派生后对比,
team 为指标级中位数(键=派生指标名,可 None)。返回结构见模块 docstring
——metrics_detail 键=配置里的 metric_code(band 型如 active_friends_band)。
"""
cur_vals = derive_metrics(current)
prev_vals = derive_metrics(prev) if prev is not None else None
base_vals = derive_metrics(baseline) if baseline is not None else None
guards = config.get("sample_guards", {})
soft = guards.get("soft_threads", 10)
minimum = guards.get("min_threads", 30)
dimensions: dict[str, dict] = {}
metrics_detail: dict[str, dict] = {}
for dim_key, dim in config.get("dimensions", {}).items():
entry: dict = {
"name": dim.get("name", dim_key),
"weight": dim.get("weight", 0),
"score": None,
"status": "pending",
}
if dim.get("status") == "pending_data" or not dim.get("metrics"):
# 一期 action/risk:数据未接入——不参与归一,不带 metrics
dimensions[dim_key] = entry
continue
dim_scores: list[float] = []
any_insufficient = False # 有值但样本不足→维度 insufficient
dim_metrics: dict[str, dict] = {}
for code, spec in dim["metrics"].items():
src = spec.get("metric", code)
value = cur_vals.get(src)
# 样本量:串类=cust_threads,band 类=active_friends
sample_size = (
current.active_friends
if spec.get("type") == "band" else current.cust_threads
)
if sample_size >= minimum:
sample_status = "ok"
elif sample_size >= soft:
sample_status = "low_confidence"
else:
sample_status = "insufficient"
score: float | None = None
if value is not None:
if sample_status == "insufficient":
any_insufficient = True # 有可评值但不评分
else:
score = normalize(spec, value)
if score is not None:
dim_scores.append(score)
dim_metrics[code] = {
"value": value,
"score": score,
"sample_size": sample_size,
"sample_status": sample_status,
"formula_note": _formula_note(spec),
}
metrics_detail[code] = {
"value": value,
"prev": prev_vals.get(src) if prev_vals else None,
"baseline": base_vals.get(src) if base_vals else None,
"median": team.get(src) if team else None,
"sample_size": sample_size,
"sample_status": sample_status,
"score": score,
"unit": METRIC_DEFINITIONS.get(src, {}).get("unit", ""),
"name": METRIC_DEFINITIONS.get(src, {}).get("name", code),
}
if dim_scores:
entry["score"] = sum(dim_scores) / len(dim_scores)
entry["status"] = "scored"
elif any_insufficient:
entry["status"] = "insufficient"
else:
entry["status"] = "pending" # 全部无值(期末无存量/无串样本等)
entry["metrics"] = dim_metrics
dimensions[dim_key] = entry
# 可用维度加权重归一:Σ(w×s)/Σw(满分=Σw×100/Σw=100),四舍五入取整
available = [
(d["weight"], d["score"]) for d in dimensions.values()
if d["score"] is not None
]
total: int | None = None
if available:
wsum = sum(w for w, _ in available)
total = int(math.floor(
sum(w * s for w, s in available) / wsum + 0.5
))
grade: str | None = None
if total is not None:
grade = "E"
bounds = config.get("grade_bounds", {})
for g in _GRADE_ORDER: # A..D 阈值递减,首中即停
if total >= bounds[g]:
grade = g
break
return {
"total_score": total,
"grade": grade,
"score_status": "scored" if total is not None else "insufficient_sample",
"trial": bool(config.get("trial")),
"dimensions": dimensions,
"metrics_detail": metrics_detail,
}
+7 -1
View File
@@ -901,7 +901,7 @@ def _normalize_chat_group(record: dict[str, Any]) -> dict[str, Any]:
``'YYYY-MM-DD HH:mm:ss'`` 字符串,另兼容 ms-int/数字串,统一归一为
ms-epoch int,复用 ``_parse_add_time`` 的双形态解析)
"""
return {
rec = {
"room_id": record.get("friendWxId"),
"channel": "wechat",
"room_name": record.get("friendWxNickname"),
@@ -929,6 +929,12 @@ def _normalize_chat_group(record: dict[str, Any]) -> dict[str, Any]:
"version": record.get("version"),
"raw_json": record,
}
# 群备注(2026-09-09 微信群统计):friend_list/es 是唯一备注源
# (getChatroomList 行无备注键);**有值才写**——空值不放键,upsert 不
# 更新该列,不会被无备注的抓取轮次清空
if record.get("friendWxRemark"):
rec["group_remark"] = record["friendWxRemark"]
return rec
async def scrape_wechat_groups(
+17 -9
View File
@@ -15,6 +15,7 @@ import logging
import math
from bisect import bisect_right
from collections.abc import Iterable
from itertools import accumulate
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
@@ -170,8 +171,10 @@ async def _lifecycle_daily(
) -> dict[int, dict[str, dict[str, int]]]:
"""逐日 {day: {user: {state: count}}}——customer 当前归属 + 互动日 sweep。
数据源:msg_daily_stats friend 行全历史(互动日/条数)。16k 客户 × 窗口
天数 bisect,夜间全量约秒级;4h tick 只算当日+3 天。
数据源:msg_daily_stats friend 行全历史(互动日/条数)。hist 为 ≤as_of
的历史消息数(时点语义:与 last 同一 bisect 位取按日累积数组 cum[idx],
as_of 之后的消息不虚增历史日的沉睡门槛)。16k 客户 × 窗口天数
bisect+classify,夜间全量实测见批次A核实文档;4h tick 只算当日+3 天。
"""
rows = (
await session.execute(
@@ -184,12 +187,13 @@ async def _lifecycle_daily(
)
)
).all()
days_map: dict[str, set[int]] = {}
cnt: dict[str, int] = {}
# peer → {day: 消息条数};days_list 与累积数组 cum 对齐:
# cum[i] = 该客户 ≤days_list[i] 的历史消息数(hist 时点语义)
day_msgs: dict[str, dict[int, int]] = {}
for peer, day, snd, rcv in rows:
days_map.setdefault(peer, set()).add(day)
cnt[peer] = cnt.get(peer, 0) + int(snd or 0) + int(rcv or 0)
interaction = {p: sorted(ds) for p, ds in days_map.items()}
dm = day_msgs.setdefault(peer, {})
dm[day] = dm.get(day, 0) + int(snd or 0) + int(rcv or 0)
interaction = {p: sorted(dm) for p, dm in day_msgs.items()}
custs = (
await session.execute(
@@ -212,14 +216,15 @@ async def _lifecycle_daily(
if uid is None:
continue
first = days_list[0]
hist = cnt.get(peer, 0)
cum = list(accumulate(day_msgs[peer][d] for d in days_list))
for d in days:
idx = bisect_right(days_list, d) - 1
if idx < 0:
continue
last = days_list[idx]
prev = days_list[idx - 1] if idx > 0 else None
state = classify(first, last, prev, d, hist)
hist_asof = cum[idx]
state = classify(first, last, prev, d, hist_asof)
if state:
out[d].setdefault(uid, {}).setdefault(state, 0)
out[d][uid][state] += 1
@@ -345,4 +350,7 @@ async def recompute_metric_days(
)
)
written += 1
# 防御性 flush:防 autoflush=False 类配置下逐日 add 的 ORM 对象
# 攒到事务尾(夜间全量约 52k 行;与 Task3 周期性 flush 同教训)
await session.flush()
return written
+23 -2
View File
@@ -62,6 +62,7 @@ from sqlalchemy.orm import DeclarativeBase
from app.db.models import (
ChatGroup,
ChatGroupMemberStats,
Customer,
CustomerAccountLink,
Department,
@@ -82,6 +83,7 @@ _logger = logging.getLogger(__name__)
__all__ = [
"upsert_chat_group",
"upsert_chat_group_member_stats",
"upsert_customer",
"upsert_customers",
"prune_customer_links",
@@ -631,6 +633,18 @@ async def upsert_chat_group(session: AsyncSession, rec: dict[str, Any]) -> None:
await _upsert_one(session, GroupMember, member_rec)
async def upsert_chat_group_member_stats(
session: AsyncSession, rec: dict[str, Any]
) -> None:
"""Idempotently upsert one :class:`ChatGroupMemberStats` day row.
PK = (channel, owner_account_id, room_id, day)。每夜「当日窗」抓取的
addMembers/lossMembers 落一行;同日重跑刷新(幂等)。页面任意日窗的
新增/退出 = 按天求和(2026-09-09 微信群统计对齐)。
"""
await _upsert_one(session, ChatGroupMemberStats, rec)
# ---------------------------------------------------------------------------
# PhoneCall (voice_call) upsert (M2, Task 2.4)
# ---------------------------------------------------------------------------
@@ -789,8 +803,13 @@ async def upsert_media(
dialect = await _dialect_name(session)
pk_cols = _pk_col_names(Media)
# 只更新实际插入的列(2026-09-10 生产修复):此前取全部表列,server_default
# 列(fetched_at/updated_at)不在 values 里 → MySQL 编译出
# ``new.fetched_at`` 悬空引用 → 每夜 call_recordings 步骤 1054 静默
# 失败,录音本地化停更多日。
update_cols = [
c.name for c in Media.__table__.columns if c.name not in pk_cols
c.name for c in Media.__table__.columns
if c.name not in pk_cols and c.name in values
]
media_cols = Media.__table__.c
@@ -798,7 +817,8 @@ async def upsert_media(
"""Map each updated column to its conflict-set expression.
Volatile download artifacts use ``COALESCE(incoming, existing)``; every
other column is refreshed directly from the incoming row.
other column is refreshed directly from the incoming row. ``updated_at``
刷新为 now()(冲突重下时反映最新一次落盘)。
"""
set_: dict[str, Any] = {}
for name in update_cols:
@@ -806,6 +826,7 @@ async def upsert_media(
set_[name] = sa.func.coalesce(other_cols[name], media_cols[name])
else:
set_[name] = other_cols[name]
set_["updated_at"] = sa.func.now()
return set_
if dialect == "sqlite":
+119
View File
@@ -15,6 +15,9 @@ Three invocations (plus analytics backfills, see below)::
python -m app.sync.cli daily-stats
python -m app.sync.cli staff-daily-stats
python -m app.sync.cli sensitive-scan --full
python -m app.sync.cli first-reply-facts
python -m app.sync.cli employee-metrics
python -m app.sync.cli score-config
Auth + DB
---------
@@ -49,6 +52,8 @@ import signal
import sys
from typing import Any
import sqlalchemy as sa
from app.config import get_settings
from app.db import models # noqa: F401 (register tables on Base.metadata)
from app.db.base import Base
@@ -136,6 +141,43 @@ def _build_parser() -> argparse.ArgumentParser:
help="Full backfill scan (first deploy / new-word history).",
)
# ``first-reply-facts`` ----------------------------------------------
p_fr = sub.add_parser(
"first-reply-facts",
help="Rebuild first_reply_fact (cross-day exact threads).",
description=(
"Full rebuild of the first-reply fact table from the message "
"table (cross-day exact thread algorithm — overnight samples "
"not lost, unlike msg_daily_stats day-cut). Deploy backfill + "
"nightly self-heal step. Idempotent."
),
)
# ``employee-metrics`` ------------------------------------------------
p_emp = sub.add_parser(
"employee-metrics",
help="Rebuild employee_metric_daily + employee_call_daily.",
description=(
"Full rebuild of the management wide table (per-day x channel "
"x staff, 1:1 caliber) and the call daily table. Must run "
"AFTER first-reply-facts (the fact table feeds Batch-B "
"scoring and drilldown; both recompute threads from messages "
"via one shared algorithm). Deploy backfill + nightly "
"self-heal. Idempotent."
),
)
# ``score-config`` ----------------------------------------------------
p_score = sub.add_parser(
"score-config",
help="Seed the default score config (idempotent).",
description=(
"Insert DEFAULT_SCORE_CONFIG (v1.0, trial) into "
"employee_score_config as active. Skips if the version "
"already exists. Batch B."
),
)
# ``scheduler`` -----------------------------------------------------------
p_sched = sub.add_parser(
"scheduler",
@@ -374,6 +416,77 @@ async def _cmd_sensitive_scan(args: argparse.Namespace) -> int:
await engine.dispose()
async def _cmd_first_reply_facts(args: argparse.Namespace) -> int:
"""全量重建 first_reply_fact(跨日精确串;部署回填/夜间自愈,幂等)。"""
settings = get_settings()
engine, session_maker = await _init_db(settings.db_url)
try:
from app.storage.first_reply_facts import recompute_all
async with session_maker() as session:
pairs = await recompute_all(session)
await session.commit()
print(f"[first-reply-facts] rebuilt {pairs} conversations")
return 0
finally:
await engine.dispose()
async def _cmd_employee_metrics(args: argparse.Namespace) -> int:
"""全量重建 employee_metric_daily + employee_call_daily(幂等)。"""
settings = get_settings()
engine, session_maker = await _init_db(settings.db_url)
try:
from app.db.models import MsgDailyStats, PhoneCall
from app.storage.employee_metrics import (
recompute_call_days,
recompute_metric_days,
)
from app.storage.summary import day_key
async with session_maker() as session:
msg_days = (
await session.execute(sa.select(MsgDailyStats.day).distinct())
).scalars().all()
# 宽表全走消息派生源,只算消息日。
rows = await recompute_metric_days(session, list(msg_days))
# 通话日 = 消息日 ∪ 通话日:消息空窗日(节假日/同步缺口)的通话
# 也要进小表。start_call_time→上海日必须在 Python 侧走 day_key
# ——MySQL 会话时区是 UTC,SQL 日期函数会切错日。
call_ts = (
await session.execute(
sa.select(PhoneCall.start_call_time)
.where(PhoneCall.start_call_time.is_not(None))
.distinct()
)
).scalars().all()
call_days = sorted(set(msg_days) | {day_key(ts) for ts in call_ts})
calls = await recompute_call_days(session, call_days)
await session.commit()
print(
f"[employee-metrics] rebuilt {rows} wide rows, {calls} call rows "
f"({len(call_days)} days)"
)
return 0
finally:
await engine.dispose()
async def _cmd_score_config(args: argparse.Namespace) -> int:
settings = get_settings()
engine, session_maker = await _init_db(settings.db_url)
try:
from app.management.metric_definitions import _seed_score_config
async with session_maker() as session:
version = await _seed_score_config(session)
await session.commit()
print(f"[score-config] {'seeded ' + version if version else 'already present, skipped'}")
return 0
finally:
await engine.dispose()
def main(argv: list[str] | None = None) -> int:
"""CLI entry point. Returns a process exit code."""
# Configure logging so the scheduler's JSON line + the orchestrator's
@@ -398,6 +511,12 @@ def main(argv: list[str] | None = None) -> int:
return asyncio.run(_cmd_staff_daily_stats(args))
if args.command == "sensitive-scan":
return asyncio.run(_cmd_sensitive_scan(args))
if args.command == "first-reply-facts":
return asyncio.run(_cmd_first_reply_facts(args))
if args.command == "employee-metrics":
return asyncio.run(_cmd_employee_metrics(args))
if args.command == "score-config":
return asyncio.run(_cmd_score_config(args))
except KeyboardInterrupt:
# So `python -m app.sync.cli scheduler` exits cleanly on Ctrl-C.
return 0
+122 -51
View File
@@ -602,50 +602,77 @@ class SyncScheduler:
if not bash:
logger.error("page sync 'full': bash not found on PATH")
return
commands: list[list[str]] = [[bash, "scripts/full_sync_all.sh"]]
commands = [([bash, "scripts/full_sync_all.sh"], 22 * 3600)] # 22h 上限:防极端情况拖进次日相位(全量常态 10-20h)
elif kind == "messages":
# 先刷客户列表(最近沟通时间是云客列表的实时排序键,friends 快照
# 滞后会让本地排序与云客第一页完全不同,2026-08-20),再抓全部四类
# 消息增量(2026-08-21 全接入:水位增量后单对话 1 页,可承受);
# 末尾追加员工朋友圈浅扫(2026-08-25,max-pages 3 即每员工最多
# 3 页,新帖通常落第一页;全量翻页由每日 full 走 full_sync_all.sh)
# 2026-09-10 调度根治(用户报「员工列表时间差 2 天」):
# ① 序列头部刷员工账号表(accounts ~1min)——员工列表「数据
# 更新时间/最新消息时间」从此最多滞后 4h(此前只在每日
# full 刷,长任务挤掉 full 就停更);
# ② 消息实体只扫**近 24h 活跃**对话(--active-window-hours;
# 从未扫过的不受限)——全对话遍历(WA 9,357 个×~3.5s≈9h)
# 曾把序列拖到 15h+,超过 4h 间隔→与 02:00 full 背靠背
# 互相挤死;陈旧对话无新消息,每日 full 兜底;
# ③ 每命令超时保险丝(超时 kill 下轮补,增量幂等)。
# friends 仍先刷(最近沟通时间是排序键+活跃窗口的判定源)。
msg_entities = [
("accounts", None, 900),
("wa-accounts", None, 900),
("friends", None, 2400),
("wa-friends", None, 2400),
("messages", 24, 4800),
("wa-messages", 24, 4800),
# chatroom-stats 全窗(~1min):刷 chat_group.last_chat_time——
# 群消息活跃窗的判定源。不加则窗口用的是昨晚 full 的快照,
# 「昨天安静、今天突然活跃」的群会被窗口漏到明晚(2026-09-10)
("chatroom-stats", None, 1800),
("group-messages", 24, 4800),
("wa-group-messages", 24, 2400),
("moments", None, 2400),
]
commands = [
[sys.executable, "scripts/scrape_via_page.py", "--entity", "friends"],
[sys.executable, "scripts/scrape_via_page.py", "--entity", "wa-friends"],
] + [
[
sys.executable,
"scripts/scrape_via_page.py",
"--entity",
e,
"--max-conversations",
"99999",
]
for e in ("messages", "wa-messages", "group-messages", "wa-group-messages")
] + [
[
sys.executable,
"scripts/scrape_via_page.py",
"--entity",
"moments",
"--max-pages",
"3",
]
(
[
sys.executable,
"scripts/scrape_via_page.py",
"--entity",
entity,
]
+ (
[
"--max-conversations",
"99999",
"--active-window-hours",
str(window),
]
if window
else (
["--max-pages", "3"]
if entity == "moments"
else []
)
),
timeout,
)
for entity, window, timeout in msg_entities
]
elif kind == "full-history":
# 每周全量对齐(周日 05:00):强制全量翻历史,对齐增量模式抓不到的
# 撤回/编辑消息 + 彻底消除 lastChatTime 滞后窗口(2026-08-21)
entities = ["messages", "group-messages", "wa-messages", "wa-group-messages"]
commands = [
[
sys.executable,
"scripts/scrape_via_page.py",
"--entity",
e,
"--max-conversations",
"99999",
"--full-history",
]
(
[
sys.executable,
"scripts/scrape_via_page.py",
"--entity",
e,
"--max-conversations",
"99999",
"--full-history",
],
6 * 3600, # 单实体 6h 上限(2026-09-10:防周日的全历史
# 对齐拖进周一,把 02:00 full 又挤掉——同类事故根治)
)
for e in entities
]
else:
@@ -653,8 +680,8 @@ class SyncScheduler:
return
rc = 0
for args in commands:
rc = await self._page_subprocess(args) or rc
for args, timeout in commands:
rc = await self._page_subprocess(args, timeout=timeout) or rc
if rc != 0:
logger.warning("page sync %s: command failed rc=%s, skipping rest", kind, rc)
break
@@ -674,25 +701,27 @@ class SyncScheduler:
print(line, flush=True)
async def _analytics_tick(self) -> None:
"""4h 分析链(stat_daily_staff 滚动3天 + 敏感词增量)——异常只记日志不断链。
"""4h 分析链(staff 滚动3天 + 敏感词增量 + 管理域滚动)——异常只记日志不断链。
纯 DB 无浏览器子进程,不抢 ``_page_lock``(DB 写毫秒级;
``max_instances=1`` 防自身重叠即可)。与夜间 full_sync 的
staff-daily-stats 步骤重叠时,两侧 DELETE+INSERT 在重叠 PK 范围上
可能死锁/锁等待——非致命:tick 吞异常下轮重试,夜间脚本无 set -e
次日自愈(UNIQUE/PK 幂等只保证串行提交下不重复,不消锁竞争)。
两段独立事务:段1 staff+敏感词(既有);段2 管理域(fact 近3天活跃会话
+ 宽表/通话 滚动3天,批次F 2026-09-10)。段2 失败只回滚自身,不牵连
段1 已提交结果(v2 Task12:指标失败不中断主分析链)。纯 DB 无浏览器
子进程,不抢 ``_page_lock``;``max_instances=1`` 防自身重叠即可。与
夜间 full_sync 的汇总/管理步重叠时,两侧 DELETE+INSERT 在重叠 PK
范围可能锁等待——非致命:tick 吞异常下轮重试,夜间脚本幂等自愈。
时区:``day_key`` 内部钉死 Asia/Shanghai,喂 UTC epoch
(``time.time()*1000``)即无歧义——用 epoch 而非 naive datetime
只为读法直接,数学上两者经本地时区往返等价。
(``time.time()*1000``)即无歧义。
"""
from app.storage.summary import day_key
today = day_key(int(time.time() * 1000))
# ── 段1:staff 滚动 + 敏感词增量(既有,语义原样) ──
try:
async with self._session_maker() as session:
from app.analytics.sensitive import scanner
from app.storage.staff_summary import recompute_days, rolling_days
from app.storage.summary import day_key
today = day_key(int(time.time() * 1000))
n = await recompute_days(session, rolling_days(today, 3))
scan = await scanner.run_incremental(session)
await session.commit()
@@ -703,8 +732,39 @@ class SyncScheduler:
except Exception: # noqa: BLE001 — scheduler MUST NOT crash
logger.exception("analytics tick failed")
async def _page_subprocess(self, args: list[str]) -> int:
"""启动页面子进程并等待结束;继承环境 + 补 DB_URL/PYTHONIOENCODING。"""
# ── 段2:管理域滚动(fact 活跃会话 + 宽表/通话,当日+滚动3天) ──
try:
async with self._session_maker() as session:
from app.storage.employee_metrics import (
recompute_call_days,
recompute_metric_days,
)
from app.storage.first_reply_facts import (
pairs_active_in_days,
recompute_pairs,
)
from app.storage.staff_summary import rolling_days
rd = rolling_days(today, 3)
pairs = await pairs_active_in_days(session, rd)
f = await recompute_pairs(session, pairs)
w = await recompute_metric_days(session, rd)
c = await recompute_call_days(session, rd)
await session.commit()
logger.info(
"[analytics-tick] mgmt fact_pairs=%s wide_rows=%s call_rows=%s",
f, w, c,
)
except Exception: # noqa: BLE001
logger.exception("management metrics tick failed")
async def _page_subprocess(self, args: list[str], *, timeout: float | None = None) -> int:
"""启动页面子进程并等待结束;继承环境 + 补 DB_URL/PYTHONIOENCODING。
``timeout``(秒,2026-09-10 调度根治保险丝):超时 kill 子进程返回
非零——上游 break 跳过本序列剩余命令,增量幂等下轮自动补,防止
单命令拖死整个调度相位(4h 序列曾因此 15h+ 与 full 互相挤死)。
"""
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
settings = get_settings()
if settings.db_url and "DB_URL" not in env:
@@ -714,7 +774,18 @@ class SyncScheduler:
cwd=str(REPO_ROOT),
env=env,
)
return await proc.wait()
if timeout is None:
return await proc.wait()
try:
return await asyncio.wait_for(proc.wait(), timeout)
except asyncio.TimeoutError:
logger.error(
"page sync subprocess timeout after %.0fs (killed): %s",
timeout, " ".join(args[-6:]),
)
proc.kill()
await proc.wait()
return 124 # 超时语义(同 shell timeout 惯例)
async def _safe_run(
self, entity: str, *, log: bool = True
@@ -0,0 +1,80 @@
"""chat_group 补 group_remark 列 + chat_group_member_stats 按天进出表
Revision ID: c9d0e1f2a3b4
Revises: b7c8d9e0f1a2
Create Date: 2026-09-09
内容
----
微信群列表对齐云客「微信群统计」页(2026-09-09 真机探明,数据源切
``getChatroomList``):
* ``chat_group.group_remark`` —— 群备注列(getChatroomList.remark /
friend_list/es 的 friendWxRemark,此前只存 raw_json);
* ``chat_group_member_stats`` —— 群成员进出**按天**统计表。云客的新增/
退出成员数是按任意时间窗实时算的,本地无成员进出事件流,改为每夜抓
一次「当日窗」的 addMembers/lossMembers 落行,页面任意日窗=按天求和;
上线日起积累,历史日期无数据。
幂等:列/表存在即跳过。SQLite/MySQL 通用。
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'c9d0e1f2a3b4'
down_revision: str | Sequence[str] | None = 'b7c8d9e0f1a2'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_TABLE = 'chat_group_member_stats'
_COL = 'group_remark'
def _has_table(table: str) -> bool:
insp = sa.inspect(op.get_bind())
return table in insp.get_table_names()
def _has_column(table: str, column: str) -> bool:
insp = sa.inspect(op.get_bind())
return column in {c["name"] for c in insp.get_columns(table)}
def upgrade() -> None:
"""Upgrade schema."""
if not _has_column("chat_group", _COL):
op.add_column("chat_group", sa.Column(_COL, sa.String(255), nullable=True))
# 回填:群备注从存量 raw_json.friendWxRemark 提取(friend_list/es 来源;
# getChatroomList 行无备注键)。幂等:只补 NULL 行。MySQL 方言
# (JSON_UNQUOTE),SQLite(测试库,不走生产迁移)跳过。
if op.get_bind().dialect.name == "mysql":
op.execute(
"UPDATE chat_group SET group_remark = JSON_UNQUOTE(JSON_EXTRACT(raw_json, '$.friendWxRemark')) "
"WHERE group_remark IS NULL "
"AND JSON_UNQUOTE(JSON_EXTRACT(raw_json, '$.friendWxRemark')) IS NOT NULL "
"AND JSON_UNQUOTE(JSON_EXTRACT(raw_json, '$.friendWxRemark')) != ''"
)
if _has_table(_TABLE):
return # 已建过,幂等跳过
op.create_table(
_TABLE,
sa.Column('channel', sa.String(16), primary_key=True),
sa.Column('owner_account_id', sa.String(128), primary_key=True),
sa.Column('room_id', sa.String(128), primary_key=True),
sa.Column('day', sa.Integer(), primary_key=True),
sa.Column('added', sa.Integer(), nullable=False, server_default='0'),
sa.Column('lost', sa.Integer(), nullable=False, server_default='0'),
sa.Column('fetched_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
)
def downgrade() -> None:
"""Downgrade schema."""
if _has_table(_TABLE):
op.drop_table(_TABLE)
if _has_column("chat_group", _COL):
op.drop_column("chat_group", _COL)
@@ -0,0 +1,41 @@
"""message 补无渠道前缀索引 (owner_account_id, talker_id, timestamp)
Revision ID: d8e9f0a1b2c3
Revises: c9d0e1f2a3b4
Create Date: 2026-09-10
背景:该回未回决赛圈点查在 channel=None(管理驾驶舱 action_counts)时
无渠道前缀条件,现有索引全部以 channel 打头用不上——服务器 100 万行
实测 dashboard 34~37s 撞前端 15s 超时;索引落地后 1.3~1.8s。
幂等:sa.inspect 先查,存在即跳过(服务器已先行手工建同名索引,本迁移
兜底对齐 schema 版本)。「模型+迁移双声明」防 autogenerate 反 DROP。
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "d8e9f0a1b2c3"
down_revision: str | Sequence[str] | None = "c9d0e1f2a3b4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_IDX = "ix_message_owner_talker_time"
def _has_index(bind) -> bool:
insp = sa.inspect(bind)
if not insp.has_table("message"):
return False
return _IDX in {ix["name"] for ix in insp.get_indexes("message")}
def upgrade() -> None:
if not _has_index(op.get_bind()):
op.create_index(_IDX, "message",
["owner_account_id", "talker_id", "timestamp"])
def downgrade() -> None:
if _has_index(op.get_bind()):
op.drop_index(_IDX, table_name="message")
+34 -12
View File
@@ -1,12 +1,15 @@
#!/usr/bin/env python
"""从群消息派生 group_member(发言者=成员)。
"""从群消息派生 group_member(发言者=成员)+ customer 兜底补全昵称/头像。
云客 API 不单独给群成员列表(chatroom payload 的 memberIds 我们抓不到,
2026-08-20 确认),但群消息的 talker 就是发言成员的 wxid、sender_name 是其
群昵称——368k 群消息足够派生出高质量成员表(覆盖所有发过言的成员)。
2026-08-20 确认),但群消息的 talker 就是发言成员的 wxid、sender_name/
sender_head_url 是其群昵称/头像——群消息足够派生出高质量成员表(覆盖
所有发过言的成员)。昵称/头像取该成员在群里**最新一条**消息的值。
昵称取该成员在群里最新一条消息的 sender_name(SQLite 的 max()+裸列行为,
取 max(timestamp) 同行的值)。幂等可重跑;建议在 group-messages 之后执行。
未发言的成员(被拉进群从没说话)从 customer 表兜底:member_id=friend_id
命中本司好友时补好友昵称/头像(2026-09-10 群详情成员墙)。
幂等可重跑;建议在 group-messages 之后执行(full_sync_all.sh 8b 已挂)。
用法:
cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" \
@@ -26,13 +29,14 @@ from app.storage.repos import _upsert_one
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("backfill_group_members")
# row_number() 窗口取 timestamp 最新一行的 sender_name:MySQL 8 的
# only_full_group_by 拒绝 SQLite 式「max()+裸列」(1055),窗口写法两库通用
# 且语义严格(最新一条的昵称),不依赖方言碰巧行为
# row_number() 窗口取 timestamp 最新一行的 sender_name/sender_head_url:
# MySQL 8 的 only_full_group_by 拒绝 SQLite 式「max()+裸列」(1055),窗口
# 写法两库通用且语义严格(最新一条的昵称/头像),不依赖方言碰巧行为
SQL = sa_text("""
SELECT room_id, talker_id, sender_name, last_ts, msg_count
SELECT room_id, talker_id, sender_name, sender_head_url, last_ts, msg_count
FROM (
SELECT room_id, talker_id, sender_name, timestamp AS last_ts,
SELECT room_id, talker_id, sender_name, sender_head_url,
timestamp AS last_ts,
count(*) OVER (PARTITION BY room_id, talker_id) AS msg_count,
row_number() OVER (
PARTITION BY room_id, talker_id ORDER BY timestamp DESC
@@ -44,6 +48,17 @@ FROM (
WHERE rn = 1
""")
# 未发言成员兜底:member_id 命中 customer(本司好友)时补 NULL 的昵称/头像。
# customer 全局一人一行(PK=friend_id),join 不放大行数;COALESCE 只补不清。
CUSTOMER_FALLBACK = sa_text("""
UPDATE group_member gm
JOIN customer c ON c.friend_id = gm.member_id AND c.channel = gm.channel
SET gm.nickname = COALESCE(gm.nickname, c.friend_nickname),
gm.avatar = COALESCE(gm.avatar, c.head_image_url)
WHERE gm.channel = 'wechat'
AND (gm.nickname IS NULL OR gm.avatar IS NULL)
""")
async def main() -> None:
async with async_engine.connect() as conn:
@@ -51,12 +66,13 @@ async def main() -> None:
log.info(f"派生出 {len(rows)} 个 (群, 成员) 对")
async with async_session_maker() as session:
for room_id, talker_id, sender_name, last_ts, msg_count in rows:
for room_id, talker_id, sender_name, head_url, last_ts, msg_count in rows:
await _upsert_one(session, GroupMember, {
"channel": "wechat",
"room_id": room_id,
"member_id": talker_id,
"nickname": sender_name,
"avatar": head_url,
# raw_json 附加派生统计,抽屉可见
"raw_json": {
"derived_from": "group_messages",
@@ -65,7 +81,13 @@ async def main() -> None:
},
})
await session.commit()
log.info(f"DONE: group_member upserted={len(rows)}")
# 兜底补未发言成员(独立事务,幂等)
async with async_engine.begin() as conn:
res = await conn.execute(CUSTOMER_FALLBACK)
log.info(f"customer 兜底补全 {res.rowcount} 行昵称/头像")
log.info("DONE: group_member upsert 完成")
if __name__ == "__main__":
+18
View File
@@ -26,6 +26,12 @@ echo "$(date) --- 5/10 WeChat groups ---"
$PY scripts/scrape_via_page.py --entity groups --max-employees 100
echo "$(date) --- 5b/10 WA groups ---"
$PY scripts/scrape_via_page.py --entity wa-groups
# 5c: 微信群统计(getChatroomList,云客「微信群统计」同款)——全窗刷档案列
# (真成员数/群备注/员工信息)+ 昨日窗落成员进出日行(页面窗内新增/退出)
echo "$(date) --- 5c/10 WeChat chatroom stats ---"
$PY scripts/scrape_via_page.py --entity chatroom-stats
YDAY=$(date -d "yesterday" +%F)
$PY scripts/scrape_via_page.py --entity chatroom-stats --day "$YDAY"
echo "$(date) --- 6/10 Voice calls (WeChat + WA) ---"
$PY scripts/scrape_via_page.py --entity voice-calls
# 语音重扫会把 record_media_hash 覆盖回 None —— 必须紧跟录音重链接
@@ -53,6 +59,15 @@ $PY -m app.sync.cli daily-stats
# A1 员工指标读路径依赖此表,2026-09-04)
echo "$(date) --- 12/12 Staff-daily-stats rebuild ---"
$PY -m app.sync.cli staff-daily-stats
# 13:首响事实表全量重建(管理域,跨日精确串;批次A)——会话发现读
# msg_daily_stats(fact 行直算读 message),故须在 11 步后;与 11/12
# 同为夜间自愈链(绕过钩子的写入由全量重算纠正)
echo "$(date) --- 13/14 First-reply-facts rebuild ---"
$PY -m app.sync.cli first-reply-facts
# 14:管理宽表+通话小表全量重建(驾驶舱/员工页读路径;串类列与 fact 同
# 算法各自直算,放 fact 后保持派生链顺序语义)
echo "$(date) --- 14/14 Employee-metrics rebuild ---"
$PY -m app.sync.cli employee-metrics
echo "=============================================="
echo "$(date) === FULL RE-SYNC COMPLETE ==="
@@ -78,6 +93,9 @@ TABLES = [
("wechat messages", "SELECT COUNT(*) FROM message WHERE channel='wechat'"),
("whatsapp messages", "SELECT COUNT(*) FROM message WHERE channel='whatsapp'"),
("daily stats rows", "SELECT COUNT(*) FROM msg_daily_stats"),
("first reply facts", "SELECT COUNT(*) FROM first_reply_fact"),
("employee metric rows", "SELECT COUNT(*) FROM employee_metric_daily"),
("employee call rows", "SELECT COUNT(*) FROM employee_call_daily"),
("voice calls", "SELECT COUNT(*) FROM phone_call WHERE channel IN ('voice_call','wa_voice_call')"),
("voice clips", "SELECT COUNT(*) FROM voice_clip"),
("moments posts", "SELECT COUNT(*) FROM moments_post"),
+347 -26
View File
@@ -514,7 +514,50 @@ async def paginate_chats(fetch_page, end_ms: int, max_pages: int = 200, sleep=No
await sleep(1)
async def _discover_conversations(channel: str, voice_channel: str) -> list[tuple[str, str]]:
# ---------------------------------------------------------------------------
# 空对话/空群「已扫」记忆(2026-09-10 同步审计):从未有消息的对话(纯语音/
# 未聊过客户,WA 实测 2,468 个)水位恒 0,每轮都被当 unscanned 全量翻史
# (~2.6s/个)——时长黑洞本体。拉到 0 条有效消息即记 sync_state 扫描标记;
# 发现层排除「已扫空且不活跃」的——客户一旦开始发消息(last_chat_time 进
# 活跃窗)自动回到访问集;周日 full_history 强制全访问兜底(云客补历史)。
# ---------------------------------------------------------------------------
async def _mark_scan_empty(kind: str, scope: str) -> None:
"""记录「该对话/群已扫且无消息」。UPDATE 0 行则 INSERT(sync_state 无唯一键)。"""
import time as _t
from app.db.base import async_engine
from sqlalchemy import text as sa_text
now_ms = int(_t.time() * 1000)
async with async_engine.begin() as conn:
res = await conn.execute(sa_text(
"UPDATE sync_state SET last_timestamp = :ts "
"WHERE entity_type = :et AND scope_id = :sid"
), {"et": kind, "sid": scope, "ts": now_ms})
if not res.rowcount:
await conn.execute(sa_text(
"INSERT INTO sync_state (entity_type, channel, scope_id, last_timestamp) "
"VALUES (:et, NULL, :sid, :ts)"
), {"et": kind, "sid": scope, "ts": now_ms})
async def _load_scan_empty(kind: str) -> set[str]:
from app.db.base import async_engine
from sqlalchemy import text as sa_text
async with async_engine.connect() as conn:
rows = (await conn.execute(sa_text(
"SELECT scope_id FROM sync_state WHERE entity_type = :et"
), {"et": kind})).fetchall()
return {r[0] for r in rows if r[0]}
async def _discover_conversations(
channel: str, voice_channel: str, active_since_ms: int | None = None,
*, exclude_scanned_empty: bool = True,
) -> list[tuple[str, str]]:
"""Conversation-pair discovery for the message scrapers (wechat + WA).
Sources (union, dedup):
@@ -527,6 +570,11 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
Order: pairs with no messages yet FIRST (new coverage survives an
interruption), then the rest by customer last_chat_time DESC.
``active_since_ms``(2026-09-10 调度根治):4h 增量模式传「now-24h」——
**已扫过**的对话仅保留 last_chat_time 在窗内的(活跃的),陈旧对话本轮
跳过(没有新消息,每日全量兜底);**从未扫过**的(fresh)无论新旧全保
(新覆盖优先原则不变)。None=不过滤(全量模式)。
"""
import json as _json
@@ -536,14 +584,14 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
async with async_engine.connect() as conn:
cust = (
await conn.execute(sa_text(
"SELECT owner_account_id, friend_id FROM customer "
"SELECT owner_account_id, friend_id, last_chat_time FROM customer "
"WHERE channel=:ch AND owner_account_id IS NOT NULL AND friend_id IS NOT NULL "
"ORDER BY last_chat_time DESC"
), {"ch": channel})
).fetchall()
voice = (
await conn.execute(sa_text(
"SELECT raw_json FROM phone_call WHERE channel=:vc"
"SELECT raw_json, start_call_time FROM phone_call WHERE channel=:vc"
), {"vc": voice_channel})
).fetchall()
with_msgs = (
@@ -554,20 +602,45 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
).fetchall()
have = {(r[0], r[1]) for r in with_msgs}
scanned_empty = (
await _load_scan_empty(f"{channel}_conv_empty") if exclude_scanned_empty else set()
)
seen: set[tuple[str, str]] = set()
fresh: list[tuple[str, str]] = []
known: list[tuple[str, str]] = []
def _add(key: tuple[str, str]) -> None:
def _active(last_ts: int | None) -> bool:
"""窗口判定:未开窗口恒真;有窗时 last_ts 缺失(从未聊过)也保(fresh)。"""
if active_since_ms is None:
return True
return last_ts is None or last_ts >= active_since_ms
def _add(key: tuple[str, str], last_ts: int | None) -> None:
if key in seen:
return
seen.add(key)
(fresh if key not in have else known).append(key)
if key not in have:
# 从未扫过:新覆盖优先,不受窗口限制。但「已扫空且不活跃」的除外
# (2026-09-10:WA 2,468 个无消息对话每轮全翻史的黑洞)——一旦
# 活跃(last_chat_time 进窗)自动回到访问集;full_history 强制全访。
scope = f"{key[0]}:{key[1]}"
if (
exclude_scanned_empty
and scope in scanned_empty
and active_since_ms is not None
and not _active(last_ts)
):
return
fresh.append(key)
elif _active(last_ts):
known.append(key)
# else: 已扫过且陈旧 → 本轮跳过
for owner, friend in cust:
_add((owner, friend))
for owner, friend, last_ts in cust:
_add((owner, friend), last_ts)
voice_only = 0
for (rj,) in voice:
cust_keys = {(r[0], r[1]) for r in cust}
for rj, call_ts in voice:
try:
d = _json.loads(rj)
except (TypeError, ValueError):
@@ -576,13 +649,15 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
if not owner or not talker or str(talker).endswith("@chatroom"):
continue
before = len(seen)
_add((str(owner), str(talker)))
if len(seen) > before and (str(owner), str(talker)) not in cust:
_add((str(owner), str(talker)), call_ts)
if len(seen) > before and (str(owner), str(talker)) not in cust_keys:
voice_only += 1
skipped = len(cust) + voice_only - len(fresh) - len(known)
logger.info(
f"discovery[{channel}]: {len(cust)} customer pairs + {voice_only} voice-only pairs, "
f"{len(fresh)} unscanned (first) / {len(known)} already have messages"
f"{len(fresh)} unscanned (first) / {len(known)} active in window"
+ (f" / {skipped} stale skipped" if active_since_ms is not None else f" / {len(known)} already have messages")
)
return fresh + known
@@ -635,7 +710,7 @@ def _page_min_ts(data: dict) -> int:
return min(ts_list) if ts_list else 0
async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_history: bool = False):
async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_history: bool = False, active_window_hours: int | None = None):
"""Scrape wechat messages via browser fetch POST /wechatweb/msg/chats.
**增量同步(默认)**:每对话水位 = 本地 max(timestamp);请求带
@@ -648,7 +723,15 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_hi
from app.db.base import async_session_maker
from app.storage.repos import upsert_messages
conversations = await _discover_conversations("wechat", "voice_call")
conversations = await _discover_conversations(
"wechat", "voice_call",
active_since_ms=(
int((__import__("time").time() - active_window_hours * 3600) * 1000)
if active_window_hours else None
),
# full_history(周日对齐)强制全访问:云客可能补出历史消息
exclude_scanned_empty=not full_history,
)
watermarks = await _conversation_watermarks("wechat")
cust_activity = await _customer_activity("wechat")
@@ -737,6 +820,9 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_hi
ts = int(str(ts_raw)) if ts_raw else None
except (ValueError, TypeError):
ts = None
# 增量模式过滤边界旧消息(云客 start inclusive,2026-09-10)
if watermark and ts is not None and ts <= watermark:
continue
records.append({
"msg_id": str(row.get("msgId") or row.get("msgSvrId") or row.get("id") or f"{owner_id}:{friend_id}:{i}:{len(records)}"),
"channel": "wechat",
@@ -786,6 +872,8 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_hi
continue
total_fetched += len(records)
if not records:
await _mark_scan_empty("wechat_conv_empty", f"{owner_id}:{friend_id}")
total_pages += pages
if records:
@@ -853,7 +941,7 @@ def _normalize_group_message(row: dict, owner_id: str, room_id: str) -> dict | N
}
async def scrape_group_messages(cookie_str: str, max_groups: int = 500, full_history: bool = False):
async def scrape_group_messages(cookie_str: str, max_groups: int = 500, full_history: bool = False, active_window_hours: int | None = None):
"""Scrape WeChat GROUP chat messages via browser fetch POST /wechatweb/msg/chats.
Same endpoint as 1:1 chats — pass the room id as ``friendId`` (confirmed
@@ -868,12 +956,17 @@ async def scrape_group_messages(cookie_str: str, max_groups: int = 500, full_his
from app.storage.repos import upsert_messages
from sqlalchemy import text as sa_text
active_since_ms = (
int((__import__("time").time() - active_window_hours * 3600) * 1000)
if active_window_hours else None
)
async with async_engine.connect() as conn:
result = await conn.execute(sa_text(
"SELECT owner_account_id, room_id FROM chat_group "
"WHERE channel='wechat' AND owner_account_id IS NOT NULL "
"AND (:t IS NULL OR last_chat_time IS NULL OR last_chat_time >= :t) "
"ORDER BY last_chat_time DESC"
))
), {"t": active_since_ms})
groups = [(row[0], row[1]) for row in result.fetchall()]
room_wms = await _room_watermarks()
@@ -934,6 +1027,9 @@ async def scrape_group_messages(cookie_str: str, max_groups: int = 500, full_his
continue
rec = _normalize_group_message(row, owner_id, room_id)
if rec is not None:
# 增量模式过滤边界旧消息(同 1:1,2026-09-10)
if watermark and (rec.get("timestamp") or 0) <= watermark:
continue
records.append(rec)
except Exception as e:
@@ -1631,6 +1727,200 @@ async def scrape_wa_groups(cookie_str: str, max_pages: int = 200):
return total_fetched, total_upserted
# ---------------------------------------------------------------------------
# 微信群统计 getChatroomList(2026-09-09 真机探明,云客同款数据源)
#
# 云客「沟通→微信号管理→微信群统计」页(/cms/wechat/wechatgroup)数据源:
# POST /wechatweb/chatroom/getChatroomList(form-urlencoded)
# departmentId + startTime/endTime('YYYY-MM-DD HH:MM:SS' 时间窗) + 分页;
# 信封 data.overviews[] + totalCount(1,854 行,员工×群维度)。
# 行含:真 memberCount(群成员数量——friend_list/es 的 friendNum 其实是
# 员工好友数,旧映射存反了)、remark(群备注)、员工微信(wechatId=微信号
# alias/wechatNickName/头像)、员工信息(userName/userPhone)、以及**窗内**
# addMembers/lossMembers(新增/退出成员数,任意窗实时算)。
#
# 本地无成员进出事件流 → add/loss 只能按天积累:每夜抓「当日窗」落
# chat_group_member_stats 日行,页面任意日窗=按天求和(历史无数据)。
# ---------------------------------------------------------------------------
CHATROOM_LIST_PATH = "/wechatweb/chatroom/getChatroomList"
def _normalize_chatroom_stat_row(row: dict, owner_wxid: str) -> dict:
"""getChatroomList 行 → ChatGroup 补列记录。
只放该端点有的字段——未出现的列不参与 upsert 的 UPDATE 子句,
friend_list/es 抓的群主信息等旧值得以保留。
"""
rec = {
"room_id": row.get("chatRoomId"),
"channel": "wechat",
"owner_account_id": owner_wxid,
"room_name": row.get("chatRoomName"),
# 真群成员数(修正:旧源 friendNum 是「员工好友数」被误存此列)
"member_count": row.get("memberCount"),
"head_image_url": row.get("headImageUrl"),
"owner_account_nickname": row.get("wechatNickName"),
"owner_user_id": row.get("userId"),
"owner_user_phone": row.get("userPhone"),
"message_count": row.get("messageCount"),
"own_message_count": row.get("ownMessageCount"),
"add_members": row.get("addMembers"), # 本窗快照(日窗=当日进出)
"loss_members": row.get("lossMembers"),
"last_chat_time": row.get("lastChatTime"),
"update_time": row.get("updateTime"),
"raw_json": row,
}
# 群备注:getChatroomList 行通常无 remark 键(云客页面也显示 -);有才写,
# 无则不放该键——upsert 不更新未出现的列,friend_list/es 来源的
# friendWxRemark 备注得以保留
if row.get("remark"):
rec["group_remark"] = row["remark"]
return rec
async def _chatroom_rows(
page, dept: str, start_str: str, end_str: str, max_pages: int = 100
) -> tuple[list[dict], int]:
"""翻完 getChatroomList 一个窗,返回 (全部行, 云客声明 total)。"""
rows_all: list[dict] = []
declared = 0
page_num = 1
while page_num <= max_pages:
body = await _browser_fetch(
page, CHATROOM_LIST_PATH,
form_body={
"departmentId": dept, "startTime": start_str, "endTime": end_str,
"pageNum": page_num, "pageSize": 50,
},
)
data = body.get("data") or {}
rows = data.get("overviews") or []
rows_all.extend(rows)
declared = data.get("totalCount") or declared
logger.info(f" chatroom-list page {page_num}: {len(rows)} rows (total {declared})")
if not rows or page_num * 50 >= (declared or 0):
break
page_num += 1
await asyncio.sleep(1.2)
return rows_all, declared or 0
async def _owner_alias_map(conn):
"""wechat_account 的 alias/nick_name → wx_id 映射。
getChatroomList 行的 wechatId 是员工**微信号**(alias,如 Nancyjade)
而非 wxId,owner 归属需经此解析;miss 时回退存原值(不影响其余列)。
"""
from sqlalchemy import text as sa_text
result = await conn.execute(sa_text(
"SELECT alias, nick_name, wx_id FROM wechat_account "
"WHERE wx_id IS NOT NULL AND wx_id != ''"
))
mapping: dict[str, str] = {}
for alias, nick, wx in result.fetchall():
if alias:
mapping[str(alias)] = wx
if nick:
mapping.setdefault(str(nick), wx)
return mapping
async def scrape_chatroom_stats(cookie_str: str, day: str | None = None):
"""getChatroomList 抓取(云客微信群统计同款)。
* ``day=None``:全窗(2020 起)——chat_group 档案补列基线(真成员数/
群备注/员工信息列;add/loss 存全窗快照);
* ``day='YYYY-MM-DD'``:单日窗——落 chat_group_member_stats 当日行
(页面任意日窗的新增/退出=按天求和)。
"""
import datetime as _dt
from app.db.base import async_engine
from app.storage.repos import upsert_chat_group, upsert_chat_group_member_stats
dept = await _root_dept_id()
async with async_engine.connect() as conn:
alias_map = await _owner_alias_map(conn)
if day:
start_str, end_str = f"{day} 00:00:00", f"{day} 23:59:59"
day_key = int(day.replace("-", ""))
else:
start_str = "2020-01-01 00:00:00"
end_str = f"{_dt.date.today()} 23:59:59"
day_key = None
group_recs: list[dict] = []
stats_recs: list[dict] = []
declared = 0
async with PageScraper(cookie_str) as scraper:
page = await _open_fetch_page(scraper)
rows, declared = await _chatroom_rows(page, dept, start_str, end_str)
for row in rows:
rid = row.get("chatRoomId")
if not rid:
continue
owner_wxid = (
alias_map.get(str(row.get("wechatId") or ""))
or alias_map.get(str(row.get("wechatNickName") or ""))
or str(row.get("wechatId") or "")
)
group_recs.append(_normalize_chatroom_stat_row(row, owner_wxid))
if day_key is not None:
stats_recs.append({
"channel": "wechat",
"owner_account_id": owner_wxid,
"room_id": rid,
"day": day_key,
"added": row.get("addMembers") or 0,
"lost": row.get("lossMembers") or 0,
})
if group_recs:
await _upsert_all(upsert_chat_group, group_recs)
if stats_recs:
await _upsert_all(upsert_chat_group_member_stats, stats_recs)
# 对账(2026-09-10,全窗模式):删掉本地有而云客名单里没有的 chat_group 行
# ——friend_list/es 时代的残留行(owner 组合已不在 getChatroomList 返回,
# 实测多 148 行致页脚与云客不一致)。名单取满(>=声明 total)才删,防短页
# 误删;只动 wechat(WA 群另一来源);GroupMember 按 room 共享不动。
pruned = 0
if day_key is None and group_recs and declared and len(group_recs) >= declared:
import sqlalchemy as sa
from app.db.base import async_session_maker
from app.db.models import ChatGroup
keep = {(r["owner_account_id"], r["room_id"]) for r in group_recs}
async with async_session_maker() as session:
local = (await session.execute(
sa.select(ChatGroup.owner_account_id, ChatGroup.room_id)
.where(ChatGroup.channel == "wechat")
)).all()
stale = [(o, r) for (o, r) in local if (o, r) not in keep]
for owner, room in stale:
# 逐行删(差集常态 0~几行;不用 tuple IN——asyncmy 对其参数
# 形态挑剔,实测 TypeError)
await session.execute(
sa.delete(ChatGroup).where(
ChatGroup.channel == "wechat",
ChatGroup.owner_account_id == owner,
ChatGroup.room_id == room,
)
)
pruned = len(stale)
await session.commit()
logger.info(
f"DONE: chatroom-stats day={day or '全窗'} groups={len(group_recs)} "
f"stats_rows={len(stats_recs)} pruned={pruned}"
)
return len(group_recs), len(stats_recs)
# ---------------------------------------------------------------------------
# 员工朋友圈 Moments (browser fetch, per employee)
# ---------------------------------------------------------------------------
@@ -1986,7 +2276,7 @@ async def scrape_voice_clips(cookie_str: str, max_employees: int = 200, page_siz
# ---------------------------------------------------------------------------
async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full_history: bool = False):
async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full_history: bool = False, active_window_hours: int | None = None):
"""Scrape WhatsApp chat records via browser fetch POST
/qqweb/WhatsAppUser/chatRecords (JSON body {uid, friendUid, start, end}).
@@ -2001,7 +2291,14 @@ async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full
from app.scraper.whatsapp import _is_wa_divider, _normalize_whatsapp_message
from app.storage.repos import upsert_messages
conversations = await _discover_conversations("whatsapp", "wa_voice_call")
conversations = await _discover_conversations(
"whatsapp", "wa_voice_call",
active_since_ms=(
int((__import__("time").time() - active_window_hours * 3600) * 1000)
if active_window_hours else None
),
exclude_scanned_empty=not full_history,
)
watermarks = await _conversation_watermarks("whatsapp")
cust_activity = await _customer_activity("whatsapp")
@@ -2047,7 +2344,14 @@ async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full
msgs = data.get("messages") or []
for row in msgs:
if isinstance(row, dict) and not _is_wa_divider(row):
records.append(_normalize_whatsapp_message(row))
rec = _normalize_whatsapp_message(row)
# 增量模式过滤边界旧消息:云客 start 参数是 inclusive
# (返回 ts>=start 含边界),不过滤则每对话白拉数条已
# 入库旧消息(2026-09-10 实测一夜 fetched 53,682 仅
# 321 条真新,99% 白写)。
if watermark and (rec.get("timestamp") or 0) <= watermark:
continue
records.append(rec)
if not data.get("hasLast"):
break
# 增量触底停:翻到已同步区
@@ -2078,6 +2382,8 @@ async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full
continue
total_fetched += len(records)
if not records:
await _mark_scan_empty("whatsapp_conv_empty", f"{uid}:{friend_uid}")
if records:
# upsert_messages 收整列表(逐条版 _upsert_all 会把 dict 迭代成
# key 字符串 → TypeError, 2026-08-19 WA 阶段首条即崩的根因)
@@ -2124,7 +2430,7 @@ def _normalize_wa_group_message(record: dict) -> dict:
return rec
async def scrape_wa_group_messages(cookie_str: str, max_groups: int = 500, full_history: bool = False):
async def scrape_wa_group_messages(cookie_str: str, max_groups: int = 500, full_history: bool = False, active_window_hours: int | None = None):
"""WA 群消息:浏览器 fetch chatRecords,friendUid 直接传 @g.us 群 id
(真机确认 2026-08-20,与微信群的 msg/chats+roomId 同套路)。
@@ -2138,11 +2444,16 @@ async def scrape_wa_group_messages(cookie_str: str, max_groups: int = 500, full_
from app.storage.repos import upsert_messages
from sqlalchemy import text as sa_text
active_since_ms = (
int((__import__("time").time() - active_window_hours * 3600) * 1000)
if active_window_hours else None
)
async with async_engine.connect() as conn:
result = await conn.execute(sa_text(
"SELECT owner_account_id, room_id FROM chat_group "
"WHERE channel='whatsapp' AND owner_account_id IS NOT NULL"
))
"WHERE channel='whatsapp' AND owner_account_id IS NOT NULL "
"AND (:t IS NULL OR last_chat_time IS NULL OR last_chat_time >= :t)"
), {"t": active_since_ms})
groups = [(row[0], row[1]) for row in result.fetchall()]
room_wms = await _room_watermarks()
@@ -2186,7 +2497,11 @@ async def scrape_wa_group_messages(cookie_str: str, max_groups: int = 500, full_
for row in rows:
if not isinstance(row, dict) or _is_wa_divider(row):
continue
records.append(_normalize_wa_group_message(row))
rec = _normalize_wa_group_message(row)
# 增量模式过滤边界旧消息(同 1:1,2026-09-10)
if watermark and (rec.get("timestamp") or 0) <= watermark:
continue
records.append(rec)
except Exception as e:
logger.warning(f" [{i+1}] {str(owner_id)[:18]}:{str(room_id)[:18]} failed: {e}")
@@ -2301,6 +2616,7 @@ async def main():
"wa-accounts", # WA员工账号 (waBusiness/userlist interception)
"wa-friends", # WA客户 (waBusiness/friendlist interception)
"wa-groups", # WA群 (browser fetch businessFriendList type=1)
"chatroom-stats", # 微信群统计 (getChatroomList 带窗,云客同款)
"voice-calls", # 语音通话记录 (voice-call-list + wavoicecalllist)
"voice-clips", # 长语音 (browser fetch call/list per employee)
"wa-messages", # WA消息 (browser fetch chatRecords per conversation)
@@ -2315,6 +2631,9 @@ async def main():
parser.add_argument("--max-pages", type=int, default=200, help="列表翻页上限 (accounts/wa-accounts/wa-friends/voice-calls/moments)")
parser.add_argument("--batch-size", type=int, default=50, help="媒体下载批次大小 (media)")
parser.add_argument("--full-history", action="store_true", help="消息类实体强制全量翻历史(默认增量水位)")
parser.add_argument("--active-window-hours", type=int, default=None,
help="只扫近 N 小时活跃的对话(4h 增量模式传 24;未扫过的对话不受限;默认不过滤=全量发现)")
parser.add_argument("--day", type=str, default=None, help="chatroom-stats:单日窗 YYYY-MM-DD(落按天进出表);缺省=全窗档案基线")
args = parser.parse_args()
cookie = _load_cookie()
@@ -2337,9 +2656,9 @@ async def _dispatch_entity(args, cookie: str) -> None:
elif args.entity == "groups":
await scrape_groups(cookie, args.max_employees)
elif args.entity == "messages":
await scrape_messages(cookie, args.max_conversations, args.full_history)
await scrape_messages(cookie, args.max_conversations, args.full_history, args.active_window_hours)
elif args.entity == "group-messages":
await scrape_group_messages(cookie, args.max_conversations, args.full_history)
await scrape_group_messages(cookie, args.max_conversations, args.full_history, args.active_window_hours)
elif args.entity == "accounts":
await scrape_accounts(cookie, args.max_pages)
elif args.entity == "wa-accounts":
@@ -2348,14 +2667,16 @@ async def _dispatch_entity(args, cookie: str) -> None:
await scrape_wa_friends(cookie, args.max_pages)
elif args.entity == "wa-groups":
await scrape_wa_groups(cookie, args.max_pages)
elif args.entity == "chatroom-stats":
await scrape_chatroom_stats(cookie, args.day)
elif args.entity == "voice-calls":
await scrape_voice_calls(cookie, args.max_pages)
elif args.entity == "voice-clips":
await scrape_voice_clips(cookie, args.max_employees)
elif args.entity == "wa-messages":
await scrape_wa_messages(cookie, args.max_conversations, args.full_history)
await scrape_wa_messages(cookie, args.max_conversations, args.full_history, args.active_window_hours)
elif args.entity == "wa-group-messages":
await scrape_wa_group_messages(cookie, args.max_conversations, args.full_history)
await scrape_wa_group_messages(cookie, args.max_conversations, args.full_history, args.active_window_hours)
elif args.entity == "moments":
await scrape_moments(cookie, args.max_pages)
elif args.entity == "media":
+81 -3
View File
@@ -64,17 +64,19 @@ async def _seed(session: AsyncSession) -> None:
async def test_default_list_returns_all_ordered_desc(make_client):
"""No filters -> 3 groups, ordered by ``last_chat_time desc`` (nulls last)."""
"""No filters -> 3 groups;微信排序=owner 二进制字符串升序分组+room_id
升序(云客群统计同款,2026-09-10 终判:区分大小写,大写排小写前)。
owner 序:wa_owner_1 < wx_owner_1 < wx_owner_2 → [wa_room_x, room_a, room_b]。
"""
client = await make_client(_seed)
r = await client.get("/api/groups")
assert r.status_code == 200
body = r.json()
assert body["total"] == 3
# 2000, 1000, null -> [room_a, room_b, wa_room_x]
assert [it["room_id"] for it in body["items"]] == [
"wa_room_x@s.whatsapp.net",
"room_a@chatroom",
"room_b@chatroom",
"wa_room_x@s.whatsapp.net",
]
for key in (
"room_id",
@@ -344,3 +346,79 @@ async def test_q_room_name_substring_filter(make_client):
# 空串 -> 不过滤
r3 = await client.get("/api/groups", params={"q": ""})
assert r3.json()["total"] == 3
# ---------------------------------------------------------------------------
# 微信群统计扩展(2026-09-09):统计时段窗聚合 + 员工列 + 群备注/最新消息筛选
# ---------------------------------------------------------------------------
async def _seed_stat(session: AsyncSession) -> None:
from app.db.models import ChatGroupMemberStats, MsgDailyStats, Staff, WechatAccount
session.add_all([
WechatAccount(wx_id="wx_owner_1", nick_name="玉", alias="Nancyjade",
user_id="u_wx_1", raw_json={"headImageUrl": "http://a/h.png"}),
Staff(user_id="u_wx_1", name="聂玉婷", phone="18500000000"),
ChatGroup(room_id="stat_room@chatroom", channel="wechat", room_name="统计群",
owner_account_id="wx_owner_1", owner_user_id="u_wx_1",
member_count=46, group_remark="VIP群", last_chat_time=5000),
# 窗内两天日行(0907+0908):沟通 3+4=7 条
MsgDailyStats(channel="wechat", owner_account_id="wx_owner_1", peer_kind="room",
peer_id="stat_room@chatroom", day=20260907, send_cnt=1, recv_cnt=2),
MsgDailyStats(channel="wechat", owner_account_id="wx_owner_1", peer_kind="room",
peer_id="stat_room@chatroom", day=20260908, send_cnt=2, recv_cnt=2),
# 窗外日行(0901)不应计入
MsgDailyStats(channel="wechat", owner_account_id="wx_owner_1", peer_kind="room",
peer_id="stat_room@chatroom", day=20260901, send_cnt=99, recv_cnt=99),
# 窗内进出:0907 +2/-1
ChatGroupMemberStats(channel="wechat", owner_account_id="wx_owner_1",
room_id="stat_room@chatroom", day=20260907, added=2, lost=1),
])
await session.commit()
async def test_group_window_aggregates_and_staff_columns(make_client):
"""msg_start/msg_end 窗内三聚合 + 员工微信/员工信息关联列。"""
client = await make_client(_seed_stat)
# 2026-09-07 ~ 2026-09-09(上海日 20260907..20260909,UTC ms)
r = await client.get("/api/groups", params={
"channel": "wechat", "room_id": "stat_room@chatroom",
"msg_start": 1788758400000, "msg_end": 1789017600000,
})
assert r.status_code == 200
it = r.json()["items"][0]
assert it["window_msg_count"] == 7 # 窗内日行求和,0901 排除
assert it["window_added"] == 2
assert it["window_lost"] == 1
assert it["group_remark"] == "VIP群"
assert it["staff_nickname"] == "玉"
assert it["staff_wx_alias"] == "Nancyjade"
assert it["staff_avatar"] == "http://a/h.png"
assert it["staff_name"] == "聂玉婷"
assert it["staff_phone"] == "18500000000"
# 无窗请求 → 聚合三列为 None(不误显 0)
r2 = await client.get("/api/groups", params={
"channel": "wechat", "room_id": "stat_room@chatroom"})
it2 = r2.json()["items"][0]
assert it2["window_msg_count"] is None
assert it2["window_added"] is None
async def test_group_remark_and_last_window_filters(make_client):
"""群备注子串 + 最新消息时间段过滤。"""
client = await make_client(_seed_stat)
r = await client.get("/api/groups", params={
"channel": "wechat", "group_remark": "VIP"})
assert [i["room_id"] for i in r.json()["items"]] == ["stat_room@chatroom"]
r2 = await client.get("/api/groups", params={
"channel": "wechat", "group_remark": "不存在"})
assert r2.json()["total"] == 0
# last_chat_time=5000 → last_start=4000 命中,last_start=6000 排除
r3 = await client.get("/api/groups", params={
"channel": "wechat", "last_start": 4000, "last_end": 9999})
assert "stat_room@chatroom" in [i["room_id"] for i in r3.json()["items"]]
r4 = await client.get("/api/groups", params={
"channel": "wechat", "last_start": 6000, "last_end": 9999})
assert r4.json()["total"] == 0
@@ -123,6 +123,7 @@ async def test_metric_daily_friend_only_caliber(make_session):
assert row.msg_sent == 3 and row.msg_recv == 2 # room 的 10 不进
assert row.active_rooms == 1
assert row.cust_threads == 1 and row.threads_answered == 0 # 未回复串
assert row.first_reply_p50_ms is None and row.first_reply_p90_ms is None # 空样本→None(≠0)
async def test_metric_daily_ghost_filtered(make_session):
@@ -173,6 +174,35 @@ async def test_metric_daily_same_source_as_fact(make_session):
sa.select(EmployeeMetricDaily))).scalars().all()) == 2
async def test_metric_daily_same_source_as_fact_answered(make_session):
"""同源判别正向路径(v2 §1.1 #5):被回串——threads_answered ≡ fact 行数,
单样本 p50=p90=fr_ms(锁分位非 None 映射)。"""
from app.storage.first_reply_facts import recompute_pairs
s = make_session
await _seed_org(s)
# 客户 10:00 提问,员工 10:25 回复 → fr=25min,串末条归属 09-01
fr_ms = 25 * 60 * 1000
s.add(Message(msg_id="an0", channel="wechat", owner_account_id="wx1",
talker_id="pa", timestamp=_ts(2026, 9, 1, 10, 0),
is_send=False, type="text", text="q"))
s.add(Message(msg_id="an1", channel="wechat", owner_account_id="wx1",
talker_id="pa", timestamp=_ts(2026, 9, 1, 10, 25),
is_send=True, type="text", text="a"))
await _seed_msg_rows(s, day=20260901, peer="pa", send=1, recv=1)
await s.commit()
await recompute_pairs(s, [("wechat", "wx1", "pa")])
await s.commit()
await recompute_metric_days(s, [20260901])
await s.commit()
n_facts = (await s.execute(
sa.select(sa.func.count()).select_from(FirstReplyFact))).scalar_one()
row = (await s.execute(sa.select(EmployeeMetricDaily))).scalar_one()
assert row.cust_threads == 1
assert row.threads_answered == 1 == n_facts
assert row.fr_sum_ms == fr_ms
assert row.first_reply_p50_ms == fr_ms == row.first_reply_p90_ms
async def test_metric_daily_lifecycle_counts(make_session):
"""生命周期列:cooling/dormant/reactivated 判定进宽表。"""
s = make_session
@@ -204,3 +234,36 @@ async def test_metric_daily_lifecycle_counts(make_session):
await s.execute(sa.select(EmployeeMetricDaily))).scalars().all()}
assert got[20261010].dormant_customer_count == 1 # 39 天静默+历史 8 条
assert got[20261011].reactivated_customer_count == 1 # 当日回流且 gap>30
async def test_metric_daily_lifecycle_hist_point_in_time(make_session):
"""hist 时点语义判别:回填历史日 M,沉睡门槛只计 ≤M 的消息。
客户 pd:X=09-01 共 2 条 + Y=10-02 共 3 条(全历史 5 条):
- M=10-01(X+30,<Y):gap≥30 达沉睡判定,但 hist_asof=2 < 5 → None
(全历史口径 hist=5 会把 M 误判 dormant——M 之后的消息虚增);
- D=11-01(Y+30):hist_asof=5 ≥ 5 → dormant。"""
s = make_session
await _seed_org(s)
s.add(Customer(friend_id="pd", channel="wechat", owner_account_id="wx1",
delete_flag=0))
for i, (day, h, is_send) in enumerate([
(20260901, 10, False), (20260901, 11, True), # X 2 条
(20261002, 10, False), (20261002, 11, True), (20261002, 12, False), # Y 3 条
]):
s.add(Message(msg_id=f"pd{i}", channel="wechat", owner_account_id="wx1",
talker_id="pd",
timestamp=_ts(day // 10000, day // 100 % 100, day % 100, h, 0),
is_send=is_send, type="text", text="x"))
await _seed_msg_rows(s, day=20260901, peer="pd", send=1, recv=1)
await _seed_msg_rows(s, day=20261002, peer="pd", send=1, recv=2)
# M 是无消息日:state=None 时纯生命周期源不出行,补一条 room 行撑出
# 宽表行,让 dormant==0 有断言落点
await _seed_msg_rows(s, day=20261001, kind="room", peer="r1", send=1, recv=0)
await s.commit()
await recompute_metric_days(s, [20261001, 20261101])
await s.commit()
got = {r.day: r for r in (
await s.execute(sa.select(EmployeeMetricDaily))).scalars().all()}
assert got[20261001].dormant_customer_count == 0 # hist_asof=2<5 → None
assert got[20261101].dormant_customer_count == 1 # hist_asof=5 ≥ 5
@@ -0,0 +1,229 @@
"""管理域确定性异常规则判别测试(批次B Task B3,v1 §5.4 异常卡片)。"""
import json
import pytest
from app.management.anomalies import detect_anomalies
from app.management.employee_metrics import PeriodMetrics
from app.management.metric_definitions import DEFAULT_SCORE_CONFIG
def _pm(**overrides):
"""全零/None 默认的 PeriodMetrics 构造器(22 字段,覆盖式改)。
与 test_management_scoring._pm 同式(测试间不共享 helper,复制保持独立)。
"""
base = {
"user_id": "u1", "days_with_data": 0, "msg_sent": 0, "msg_recv": 0,
"cust_threads": 0, "threads_answered": 0, "fr_sum_ms": 0,
"fr_p50_ms": None, "fr_p90_ms": None,
"reply_2h_count": 0, "reply_24h_count": 0,
"active_friends": 0, "active_rooms_avg": 0.0,
"new_customer": 0, "cooling_customer": 0, "dormant_customer": 0,
"end_active_friends": 0, "reactivated_customer": 0,
"call_count": 0, "call_seconds": 0, "answered_call": 0,
"call_followup": 0,
}
base.update(overrides)
return PeriodMetrics(**base)
def _detect(cur, base, *, n_days=7, user_id="u1", config=None):
return detect_anomalies(
user_id, cur, base, n_days=n_days,
config=config if config is not None else DEFAULT_SCORE_CONFIG)
# ---- 1. reply_p90_worsened ----
def test_p90_worsened_warning_with_pct():
"""×1.6 → warning;标题百分比=(160-100)/100=60%;affected=未回串数。"""
cur = _pm(cust_threads=20, threads_answered=12, fr_p90_ms=9_600_000) # 160min
base = _pm(cust_threads=40, threads_answered=40, fr_p90_ms=6_000_000) # 100min
cards = _detect(cur, base)
assert [c["rule_code"] for c in cards] == ["reply_p90_worsened"]
c = cards[0]
assert c["severity"] == "warning"
assert c["title"] == "回复 P90 较个人基线上升 60%"
assert c["current_value"] == 9_600_000
assert c["baseline_value"] == 6_000_000
assert c["unit"] == "ms"
assert c["sample_size"] == 20
assert c["affected_count"] == 8 # 20 串 - 12 已回 = 期内未回串数
assert c["drilldown"] == {"type": "metric",
"query": {"metric_code": "first_reply_p90",
"user_id": "u1"}}
def test_p90_worsened_critical_at_3x():
cur = _pm(cust_threads=20, threads_answered=20, fr_p90_ms=18_000_000)
base = _pm(cust_threads=40, threads_answered=40, fr_p90_ms=6_000_000)
cards = _detect(cur, base)
assert len(cards) == 1
assert cards[0]["severity"] == "critical"
assert "上升 200%" in cards[0]["title"]
def test_p90_boundaries_strict_compare():
"""×1.5 恰好 → 严格大于不成立不触发;×2.5 恰好 → warning 非 critical;
threads 恰好 = soft(10) → ≥soft 触发。"""
base = _pm(cust_threads=40, fr_p90_ms=6_000_000)
assert _detect(_pm(cust_threads=20, fr_p90_ms=9_000_000), base) == []
cards = _detect(_pm(cust_threads=20, fr_p90_ms=15_000_000), base)
assert [c["severity"] for c in cards] == ["warning"]
cards = _detect(_pm(cust_threads=10, fr_p90_ms=9_600_000), base)
assert [c["rule_code"] for c in cards] == ["reply_p90_worsened"]
def test_p90_below_soft_threads_not_fired():
cur = _pm(cust_threads=5, fr_p90_ms=9_600_000) # <soft 10
base = _pm(cust_threads=40, threads_answered=40, fr_p90_ms=6_000_000)
assert _detect(cur, base) == []
# ---- 2. 基线缺失 ----
def test_baseline_none_all_rules_silent():
"""新员工无个人基线:即便 p90 恶化/活动骤降/过载形态俱在也不出卡。"""
cur = _pm(cust_threads=20, threads_answered=5, fr_p90_ms=30_000_000,
active_friends=85, msg_sent=10, msg_recv=10)
assert _detect(cur, None) == []
# ---- 3. reply_rate_dropped ----
def test_reply_rate_dropped_warning():
"""0.8→0.5(0.5 < 0.7×0.8=0.56)→ warning;百分比 37.5%→38%。"""
cur = _pm(cust_threads=20, reply_2h_count=10, threads_answered=15) # 0.5
base = _pm(cust_threads=40, reply_2h_count=32, threads_answered=40) # 0.8
cards = _detect(cur, base)
assert [c["rule_code"] for c in cards] == ["reply_rate_dropped"]
c = cards[0]
assert c["severity"] == "warning"
assert c["title"] == "2h 回复率较个人基线下降 38%"
assert c["current_value"] == pytest.approx(0.5)
assert c["baseline_value"] == pytest.approx(0.8)
assert c["unit"] == "0-1"
assert c["sample_size"] == 20
assert c["affected_count"] == 5
assert c["drilldown"] == {"type": "metric",
"query": {"metric_code": "unreplied",
"user_id": "u1"}}
def test_reply_rate_small_drop_not_fired():
"""0.8→0.6(0.6 ≥ 0.56)→ 不触发。"""
cur = _pm(cust_threads=20, reply_2h_count=12) # 0.6
base = _pm(cust_threads=40, reply_2h_count=32) # 0.8
assert _detect(cur, base) == []
def test_rate_baseline_no_threads_not_fired():
"""基线无串样本 → 基线率 None → 不判(基线值 None 不触发)。"""
cur = _pm(cust_threads=20, reply_2h_count=0)
base = _pm(cust_threads=0) # derive → reply_2h_rate None
assert _detect(cur, base) == []
# ---- 4. activity_drop ----
def test_activity_drop_triggered():
"""当前 7 天日均 10 < 基线 28 天日均 30×0.5=15 → warning。"""
cur = _pm(msg_sent=40, msg_recv=30, active_friends=25) # 70/7=10 条/天
base = _pm(msg_sent=600, msg_recv=240, days_with_data=20) # 840/28=30 条/天
cards = _detect(cur, base)
assert [c["rule_code"] for c in cards] == ["activity_drop"]
c = cards[0]
assert c["severity"] == "warning"
assert c["title"] == "日均消息量较个人基线下降 67%" # 66.7→67
assert c["current_value"] == pytest.approx(10.0)
assert c["baseline_value"] == pytest.approx(30.0)
assert c["unit"] == "条"
assert c["sample_size"] == 25
assert c["affected_count"] == 25
assert c["drilldown"] == {"type": "employee", "query": {"user_id": "u1"}}
def test_activity_drop_sparse_baseline_not_fired():
"""基线 days_with_data=3 < min_days(5) → 基线不可比,不触发。"""
base = _pm(msg_sent=600, msg_recv=240, days_with_data=3)
cur = _pm(msg_sent=40, msg_recv=30)
assert _detect(cur, base) == []
# ---- 5. workload_overload ----
def test_workload_overload_triggered():
cards = _detect(_pm(active_friends=85), _pm())
assert [c["rule_code"] for c in cards] == ["workload_overload"]
c = cards[0]
assert c["severity"] == "warning"
assert c["title"] == "活跃客户 85 人超过负载上限 80 人"
assert c["current_value"] == 85
assert c["baseline_value"] is None # 规则与个人基线无关,阈值在 band 配置
assert c["unit"] == "人"
assert c["sample_size"] == 85
assert c["affected_count"] == 85
assert c["drilldown"] == {"type": "employee", "query": {"user_id": "u1"}}
def test_workload_overload_at_limit_not_fired():
assert _detect(_pm(active_friends=80), _pm()) == [] # 严格大于上限
# ---- 6. 配置驱动与组合 ----
def test_thresholds_read_from_config():
"""soft_threads/band high 改配置即改判——阈值不硬编码。"""
cfg = json.loads(json.dumps(DEFAULT_SCORE_CONFIG))
cfg["sample_guards"]["soft_threads"] = 25
cfg["dimensions"]["workload"]["metrics"]["active_friends_band"]["high"] = 90
cur = _pm(cust_threads=20, fr_p90_ms=9_600_000, active_friends=85)
base = _pm(cust_threads=40, fr_p90_ms=6_000_000)
# threads 20 < soft25 → p90/rate 不评;85 ≤ 90 → 不 overload
assert _detect(cur, base, config=cfg) == []
def test_multiple_rules_stack_in_order():
"""p90 ×5 critical + activity 骤降 + overload 90>80 同时触发,按规则表序输出;
两侧回复率同为 0(0<0 不成立)→ rate 规则不触发。"""
cur = _pm(cust_threads=30, threads_answered=10, fr_p90_ms=30_000_000,
active_friends=90, msg_sent=10, msg_recv=10)
base = _pm(cust_threads=60, threads_answered=60, fr_p90_ms=6_000_000,
msg_sent=600, msg_recv=240, days_with_data=20)
cards = _detect(cur, base)
assert [c["rule_code"] for c in cards] == [
"reply_p90_worsened", "activity_drop", "workload_overload"]
assert cards[0]["severity"] == "critical"
def test_card_structure_all_fields():
"""单卡 12 字段齐全且类型正确(v1 §5.4)。"""
cur = _pm(cust_threads=20, reply_2h_count=20, threads_answered=12,
fr_p90_ms=9_600_000, active_friends=30,
msg_sent=80, msg_recv=30) # 日均 110/7≈15.7 ≥ 15 → activity 不触发
base = _pm(cust_threads=40, reply_2h_count=40, threads_answered=40,
fr_p90_ms=6_000_000, active_friends=30,
msg_sent=600, msg_recv=240, days_with_data=20)
cards = _detect(cur, base, user_id="u9")
assert len(cards) == 1
c = cards[0]
assert set(c) == {
"rule_code", "severity", "employee_user_id", "title",
"current_value", "baseline_value", "unit", "sample_size",
"affected_count", "reason", "drilldown", "suggested_action",
}
assert isinstance(c["rule_code"], str)
assert c["severity"] in ("warning", "critical")
assert c["employee_user_id"] == "u9"
assert isinstance(c["title"], str) and c["title"]
assert isinstance(c["current_value"], (int, float))
assert c["baseline_value"] is None or isinstance(
c["baseline_value"], (int, float))
assert isinstance(c["unit"], str)
assert isinstance(c["sample_size"], int)
assert isinstance(c["affected_count"], int)
assert isinstance(c["reason"], str) and c["reason"]
assert set(c["drilldown"]) == {"type", "query"}
assert isinstance(c["drilldown"]["query"], dict)
assert isinstance(c["suggested_action"], str) and c["suggested_action"]
+744
View File
@@ -0,0 +1,744 @@
"""管理 API 六只读端点判别测试(批次B Task B4)。
覆盖:
- 参数校验:422(缺 start / 跨度>366 / 日历非法 / drilldown 白名单外 /
page_size>100)、404(employees/{ghost}、drilldown 员工不存在);
- dashboard 各段齐全与关键数值(kpis/communication_trend/service_trend/
lifecycle/dimension_summary/anomalies/action_summary/data_as_of/
metric_version);批次D 补:prev_kpis 环比块+msg_delta(含前窗零数据
→None)与 anomalies employee_name enrich(详情端点不带);
- employees:默认分页 20/total/critical 优先默认排序/channel=wechat 时
whatsapp-only 员工消失/keyword/status/min_days 不足→insufficient_sample;
- detail:账号清单+metrics 全字段+score 完整明细+anomalies+prev/baseline;
- sections:五段各有 trend 数组与四对比;
- drilldown:p90 分页与 fr_ms desc/unreplied 按 owner 过滤/cooling-dormant
生命周期判定(已删客户剔除)/call_followup 列表+期聚合摘要;
- metric-definitions:trial 标记/口径说明/config_source 回退。
"""
import copy
import datetime as dt
from zoneinfo import ZoneInfo
from app.db.models import (
Customer,
EmployeeCallDaily,
EmployeeMetricDaily,
EmployeeScoreConfig,
FirstReplyFact,
Message,
MsgDailyStats,
PhoneCall,
Staff,
WechatAccount,
WhatsappAccount,
)
from app.management.metric_definitions import DEFAULT_SCORE_CONFIG
_SH = ZoneInfo("Asia/Shanghai")
START, END = 20260901, 20260905 # 当前窗 5 天(min_days=5 恰好够)
def _ts(*args) -> int:
return int(dt.datetime(*args, tzinfo=_SH).timestamp() * 1000)
def _mday(day, *, channel="wechat", user_id="u1", metric_version="v1", **kw):
cols = {
"msg_sent": 0, "msg_recv": 0, "active_friends": 0, "active_rooms": 0,
"cust_threads": 0, "threads_answered": 0, "fr_sum_ms": 0,
"new_customer_count": 0, "cooling_customer_count": 0,
"dormant_customer_count": 0, "reactivated_customer_count": 0,
"data_complete": True,
}
cols.update(kw)
return EmployeeMetricDaily(
day=day, channel=channel, user_id=user_id,
metric_version=metric_version, **cols
)
def _fact(fr_ms, *, channel="wechat", owner="wx1", peer="p1", day=20260901, i=0):
end = _ts(day // 10000, day // 100 % 100, day % 100, 10, 0) + i * 60_000
return FirstReplyFact(
channel=channel, owner_account_id=owner, peer_id=peer,
thread_end_ts=end, reply_ts=end + fr_ms,
reply_msg_id=f"{owner}-{peer}-m{i}", day=day, fr_ms=fr_ms,
)
async def _seed_common(s):
"""三员工全量种子:u1(微信,2 warning)/u2(双渠道,1 critical+1 warning)/
u3(仅 WhatsApp,无异常)——支撑 dashboard/employees/detail/sections/
drilldown 全部判据。"""
for uid, name, onjob in (("u1", "张三", True), ("u2", "李四", True),
("u3", "王五", False)):
s.add(Staff(user_id=uid, name=name, is_on_job=onjob))
s.add(WechatAccount(wx_id="wx1", user_id="u1", nick_name="张三的微信",
user_name="张三"))
s.add(WechatAccount(wx_id="wx2", user_id="u2"))
s.add(WhatsappAccount(uid="wa2", user_id="u2"))
s.add(WhatsappAccount(uid="wa3", user_id="u3"))
# ---- 当前窗宽表(0901-0905)----
for d in range(1, 6):
s.add(_mday(20260900 + d, user_id="u1", msg_sent=1, cust_threads=2,
threads_answered=2, fr_sum_ms=240_000, active_friends=3,
active_rooms=1, cooling_customer_count=2,
dormant_customer_count=1,
first_reply_p50_ms=110_000, first_reply_p90_ms=220_000))
s.add(_mday(20260900 + d, user_id="u2", msg_sent=10, msg_recv=10,
cust_threads=2, threads_answered=2, fr_sum_ms=1_200_000,
active_friends=4, cooling_customer_count=1,
reactivated_customer_count=1))
s.add(_mday(20260900 + d, channel="whatsapp", user_id="u3",
msg_sent=5, msg_recv=5, cust_threads=1, active_friends=1))
# ---- 基线窗宽表(0827-0831,u1 高日均触发 activity_drop)----
for d in range(27, 32):
s.add(_mday(20260800 + d, user_id="u1", msg_sent=14, msg_recv=14))
s.add(_mday(20260800 + d, user_id="u2", msg_sent=5, msg_recv=5))
# ---- fact:当前窗(u1 120s warning / u2 600s critical)+ 基线 60s ----
s.add(_fact(120_000, peer="pA", day=20260901, i=0))
s.add(_fact(120_000, peer="pB", day=20260902, i=1))
s.add(_fact(600_000, owner="wx2", peer="pC", day=20260901, i=2))
s.add(_fact(600_000, owner="wx2", peer="pD", day=20260902, i=3))
for owner, peer, day, i in (("wx1", "pA", 20260827, 10),
("wx1", "pB", 20260828, 11),
("wx2", "pC", 20260827, 12),
("wx2", "pD", 20260828, 13)):
s.add(_fact(60_000, owner=owner, peer=peer, day=day, i=i))
# ---- msg_daily_stats:活跃客户 distinct + data_as_of + 未回候选 ----
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="p1", day=20260901,
send_cnt=0, recv_cnt=1, last_ts=_ts(2026, 9, 1, 10, 0),
last_is_send=False))
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="p2", day=20260902,
send_cnt=1, recv_cnt=0, last_ts=_ts(2026, 9, 2, 10, 0),
last_is_send=True))
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx2",
peer_kind="friend", peer_id="p3", day=20260901,
send_cnt=2, recv_cnt=0, last_ts=_ts(2026, 9, 1, 11, 0),
last_is_send=True))
s.add(MsgDailyStats(channel="whatsapp", owner_account_id="wa3",
peer_kind="friend", peer_id="p4", day=20260903,
send_cnt=1, recv_cnt=0, last_ts=_ts(2026, 9, 3, 9, 0),
last_is_send=True))
# drilldown cooling/dormant 客户互动史(全历史,窗外):
# f1 末次 0801(gap 35, hist 12)→ dormant;f2 末次 0825(gap 11)→ cooling
for day, n in ((20260701, 6), (20260801, 6)):
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="f1", day=day,
send_cnt=n, recv_cnt=0,
last_ts=_ts(day // 10000, day // 100 % 100,
day % 100, 10, 0),
last_is_send=True))
for day in (20260801, 20260825):
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="f2", day=day,
send_cnt=2, recv_cnt=2,
last_ts=_ts(2026, 8, day % 100, 11, 0),
last_is_send=True))
# ---- 客户档案(f9 已删,须被 drilldown 剔除)----
s.add(Customer(friend_id="f1", channel="wechat", owner_account_id="wx1",
friend_nickname="客户一", user_id="u1", delete_flag=0))
s.add(Customer(friend_id="f2", channel="wechat", owner_account_id="wx1",
friend_nickname="客户二", user_id="u1", delete_flag=0))
s.add(Customer(friend_id="f9", channel="wechat", owner_account_id="wx1",
friend_nickname="已删客户", user_id="u1", delete_flag=1))
# ---- 未回候选 p1 的尾部消息(unreplied 决赛圈点查)----
s.add(Message(msg_id="m-p1", channel="wechat", owner_account_id="wx1",
talker_id="p1", is_send=False, type="text", text="在吗",
timestamp=_ts(2026, 9, 1, 10, 0)))
# ---- 通话 ----
s.add(EmployeeCallDaily(day=20260901, user_id="u1", call_count=2,
call_seconds=100, answered_call_count=1,
call_followup_count=1))
s.add(EmployeeCallDaily(day=20260902, user_id="u2", call_count=1,
call_seconds=60, answered_call_count=1,
call_followup_count=0))
s.add(PhoneCall(call_id="c1", channel="voice_call", user_id="u1",
start_call_time=_ts(2026, 9, 1, 15, 0), call_duration=120,
call_number="13800000001"))
s.add(PhoneCall(call_id="c2", channel="voice_call", user_id="u1",
start_call_time=_ts(2026, 9, 2, 15, 0), call_duration=0,
call_number="13800000002"))
await s.commit()
# ---------------------------------------------------------------- 参数与错误
async def test_dashboard_missing_start_422(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/dashboard", params={"end": END})
assert r.status_code == 422
async def test_dashboard_span_and_calendar_422(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/dashboard",
params={"start": 20250101, "end": 20261231})
assert r.status_code == 422
r = await client.get("/api/management/dashboard",
params={"start": 20260230, "end": 20260905})
assert r.status_code == 422 # 日历非法(2 月 30 日)
r = await client.get("/api/management/dashboard",
params={"start": 20260906, "end": 20260901})
assert r.status_code == 422 # start > end
async def test_drilldown_unknown_metric_422(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees/u1/drilldown",
params={"metric_code": "bogus",
"start": START, "end": END})
assert r.status_code == 422
assert "unknown metric_code" in r.json()["detail"]
async def test_employees_page_size_over_100_422(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees",
params={"start": START, "end": END,
"page_size": 101})
assert r.status_code == 422
async def test_employee_detail_404(make_client):
async def seed(s):
await s.commit()
client = await make_client(seed)
r = await client.get("/api/management/employees/ghost",
params={"start": START, "end": END})
assert r.status_code == 404
async def test_drilldown_employee_404(make_client):
async def seed(s):
await s.commit()
client = await make_client(seed)
r = await client.get("/api/management/employees/ghost/drilldown",
params={"metric_code": "unreplied",
"start": START, "end": END})
assert r.status_code == 404
# ---------------------------------------------------------------- dashboard
async def test_dashboard_structure_and_kpis(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/dashboard",
params={"channel": "all", "start": START, "end": END})
assert r.status_code == 200
body = r.json()
for key in ("scope", "data_as_of", "metric_version", "kpis",
"communication_trend", "service_trend", "lifecycle",
"dimension_summary", "anomalies", "action_summary",
"config_source", "caliber_notes"):
assert key in body, key
assert body["metric_version"] == "v1"
assert body["config_source"] == "default"
assert body["scope"] == {"channel": "all", "start": START, "end": END,
"compare": "previous"}
assert body["data_as_of"] == _ts(2026, 9, 3, 9, 0)
k = body["kpis"]
# 活跃客户=PM distinct 求和(u1:2 + u2:1 + u3:1)
assert k["active_customers"] == 4
assert k["active_employees"] == 3
assert k["msg_total"] == 155 # u1 5 + u2 100 + u3 50
assert k["cust_threads"] == 25 # 10 + 10 + 5
assert k["threads_answered"] == 20 # 10 + 10 + 0
assert k["call_count"] == 3
assert k["call_seconds"] == 160
assert k["answered_call"] == 2
# 期末存量:u1 cooling2/dormant1 + u2 cooling1;reactivated 期内求和 u2 5
assert body["lifecycle"] == {"new": 0, "cooling": 3, "dormant": 1,
"reactivated": 5}
assert k["lifecycle"] == body["lifecycle"]
# 前窗环比(批次D):prev 窗 0827-0831 与基线窗种子重叠——
# u1(14+14)×5 + u2(5+5)×5 = 190;宽表无 active_friends/串列 → 0
pk = body["prev_kpis"]
assert set(pk) == {"active_customers", "active_employees",
"msg_total", "cust_threads"}
assert pk == {"active_customers": 0, "active_employees": 2,
"msg_total": 190, "cust_threads": 0}
assert body["msg_delta"] == round((155 - 190) / 190, 3) # -0.184
# 趋势逐日 5 点;0901 = u1(1)+u2(20)+u3(10) 消息,宽表 active 合计 8,通话 2
trend = body["communication_trend"]
assert [t["day"] for t in trend] == [20260901 + i for i in range(5)]
assert trend[0]["msg_total"] == 31
assert trend[0]["active_customers"] == 8
assert trend[0]["call_cnt"] == 2
assert trend[1]["call_cnt"] == 1
assert trend[4] == {"day": 20260905, "msg_total": 31,
"active_customers": 8, "call_cnt": 0}
# 服务趋势:0901 fact [120k, 600k] → p50=120k/p90=600k;
# reply_2h_rate = 2/5(两串均 ≤2h——600s=10min / 宽表当日串 5)
st = {t["day"]: t for t in body["service_trend"]}
assert st[20260901]["fr_p50_ms"] == 120_000
assert st[20260901]["fr_p90_ms"] == 600_000
assert st[20260901]["reply_2h_rate"] == 0.4
assert st[20260903]["fr_p50_ms"] is None # 无 fact 日
assert st[20260903]["reply_2h_rate"] == 0.0 # 分母有串、分子 0
# 维度摘要:5 维全员计数,总数=员工数 3
dims = body["dimension_summary"]
assert set(dims) == {"customer", "service", "action", "workload", "risk"}
for d in dims.values():
assert d["scored_count"] + d["insufficient_count"] + d["pending_count"] == 3
assert "avg_score" in d
# 异常:critical 在前(u2),warning 其后按 affected_count desc;
# dashboard 卡片 enrich employee_name(批次D,前端跳员工详情用)
anoms = body["anomalies"]
assert anoms[0]["severity"] == "critical"
assert anoms[0]["employee_user_id"] == "u2"
assert anoms[0]["employee_name"] == "李四"
assert all(a["severity"] == "critical" for a in anoms[:1])
assert all(a["severity"] == "warning" for a in anoms[1:])
assert all(a["employee_name"] == "张三" for a in anoms[1:])
assert body["action_summary"] == {"unreplied_2h": 1, "churn_real_30d": 0,
"sensitive_pending": 0}
async def test_dashboard_prev_zero_no_delta(make_client):
"""前窗无数据(whatsapp 视图前窗零宽表)→ prev_kpis 四键全 0、
msg_delta None(prev=0 不算环比,None 而非 0/负∞)。"""
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/dashboard",
params={"channel": "whatsapp", "start": START,
"end": END})
assert r.status_code == 200
body = r.json()
assert body["kpis"]["msg_total"] == 50 # 仅 u3 的 whatsapp 宽表
assert body["prev_kpis"] == {"active_customers": 0,
"active_employees": 0, "msg_total": 0,
"cust_threads": 0}
assert body["msg_delta"] is None
# ---------------------------------------------------------------- employees
async def test_employees_default_sort_and_paging(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees",
params={"channel": "all", "start": START, "end": END})
assert r.status_code == 200
body = r.json()
assert body["total"] == 3
assert body["page"] == 1
assert body["page_size"] == 20
assert body["metric_version"] == "v1"
assert body["config_source"] == "default"
assert len(body["rows"]) == 3
# 默认 anomaly_severity 排序:含 critical 的 u2 在最前
# (u2=1 critical——基线无串样本率不可算,rate 规则不触发;u1=2 warning)
ids = [row["user_id"] for row in body["rows"]]
assert ids == ["u2", "u1", "u3"]
u2, u1, u3 = body["rows"]
assert u2["anomaly_count"] == 1 and u2["anomaly_weight"] == 2
assert u1["anomaly_count"] == 2 and u1["anomaly_weight"] == 2
assert u3["anomaly_count"] == 0 and u3["anomaly_weight"] == 0
# 行结构:metrics 全字段 / comparisons 四值 / score 摘要
assert u1["name"] == "张三" and u1["is_on_job"] is True
assert u1["metrics"]["cust_threads"] == 10
assert u1["metrics"]["end_active_friends"] == 3
# M3:comparisons 键域=源指标名(与 sections 端点一致;band 配置 code
# 映射为其 spec.metric——active_friends/call_count,不出现 *_band)
assert set(u1["comparisons"]) >= {
"active_share", "reply_2h_rate", "reply_24h_rate", "fr_p90_ms",
"active_friends", "call_count"}
assert "active_friends_band" not in u1["comparisons"]
assert "call_band" not in u1["comparisons"]
for c in u1["comparisons"].values():
assert set(c) == {"current", "prev", "baseline", "median"}
assert u1["comparisons"]["fr_p90_ms"]["current"] == 120_000
assert u1["comparisons"]["fr_p90_ms"]["baseline"] == 60_000
assert set(u1["score"]["dimensions"]) == {
"customer", "service", "action", "workload", "risk"}
for d in u1["score"]["dimensions"].values():
assert set(d) == {"score", "status"}
assert u1["score"]["trial"] is True
assert u1["score"]["score_status"] == "scored" # 5 天数据 ≥ min_days
async def test_employees_channel_filter(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees",
params={"channel": "wechat", "start": START,
"end": END})
ids = [row["user_id"] for row in r.json()["rows"]]
assert ids == ["u2", "u1"] # u3 仅 WhatsApp,wechat 视图消失
r = await client.get("/api/management/employees",
params={"channel": "whatsapp", "start": START,
"end": END})
ids = [row["user_id"] for row in r.json()["rows"]]
assert ids == ["u2", "u3"] # u1 无 WA 账号
async def test_employees_keyword(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees",
params={"start": START, "end": END, "keyword": "张"})
assert [row["user_id"] for row in r.json()["rows"]] == ["u1"]
r = await client.get("/api/management/employees",
params={"start": START, "end": END, "keyword": "U3"})
assert [row["user_id"] for row in r.json()["rows"]] == ["u3"]
async def test_employees_status_filter(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees",
params={"start": START, "end": END,
"status": "on_job"})
assert [row["user_id"] for row in r.json()["rows"]] == ["u2", "u1"]
r = await client.get("/api/management/employees",
params={"start": START, "end": END,
"status": "offline"})
assert [row["user_id"] for row in r.json()["rows"]] == ["u3"]
async def test_employees_min_days_insufficient(make_client):
"""B2 评审遗留接线:days_with_data < min_days → score_status 覆盖
insufficient_sample,但可评明细(total_score)保留展示。"""
async def seed(s):
s.add(Staff(user_id="u9", name="赵九", is_on_job=True))
s.add(WechatAccount(wx_id="wx9", user_id="u9"))
# 仅 2 天宽表数据(< min_days=5),但串样本 30 ≥ min_threads 可评分
for d in (1, 2):
s.add(_mday(20260900 + d, user_id="u9", cust_threads=15,
threads_answered=15, fr_sum_ms=900_000,
active_friends=10))
s.add(_fact(60_000, owner="wx9", peer="q1", day=20260901, i=0))
s.add(_fact(60_000, owner="wx9", peer="q2", day=20260902, i=1))
for i in range(6): # 6 个 distinct peer → workload band 可评
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx9",
peer_kind="friend", peer_id=f"q{i}",
day=20260901, send_cnt=1, recv_cnt=0,
last_ts=_ts(2026, 9, 1, 12, 0),
last_is_send=True))
await s.commit()
client = await make_client(seed)
r = await client.get("/api/management/employees",
params={"start": START, "end": END})
assert r.status_code == 200
row = r.json()["rows"][0]
assert row["user_id"] == "u9"
assert row["metrics"]["days_with_data"] == 2
assert row["score"]["score_status"] == "insufficient_sample"
assert row["score"]["total_score"] is not None # 明细保留展示
async def test_employees_db_score_config_guard(make_client):
"""config_source=db 路径:激活配置的 min_days=9 生效——u1 五天数据
在默认配置(min_days=5)下 scored,在 DB 配置下翻为 insufficient_sample。"""
async def seed(s):
await _seed_common(s)
cfg = copy.deepcopy(DEFAULT_SCORE_CONFIG)
cfg["version"] = "v1-test"
cfg["name"] = "评审测试配置"
cfg["sample_guards"]["min_days"] = 9
s.add(EmployeeScoreConfig(version="v1-test", name="评审测试配置",
status="active", config_json=cfg,
created_by="test"))
await s.commit()
client = await make_client(seed)
r = await client.get("/api/management/employees",
params={"channel": "all", "start": START,
"end": END})
assert r.status_code == 200
body = r.json()
assert body["config_source"] == "db"
u1 = next(row for row in body["rows"] if row["user_id"] == "u1")
assert u1["metrics"]["days_with_data"] == 5
assert u1["score"]["score_status"] == "insufficient_sample" # 5 < 9
assert u1["score"]["total_score"] is not None
# ---------------------------------------------------------------- detail
async def test_employee_detail_full(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees/u1",
params={"channel": "all", "start": START, "end": END})
assert r.status_code == 200
body = r.json()
assert body["user_id"] == "u1"
assert body["name"] == "张三"
assert body["accounts"] == [
{"channel": "wechat", "account_id": "wx1",
"nick_name": "张三的微信", "user_name": "张三"}]
# metrics 全字段(22 列 namedtuple)
assert len(body["metrics"]) == 22
assert body["metrics"]["cust_threads"] == 10
# score 完整明细:dimensions 含逐指标 metrics;metrics_detail 扁平
assert body["score"]["dimensions"]["service"]["metrics"]["fr_p90_ms"][
"score"] == 100.0
assert body["score"]["metrics_detail"]["fr_p90_ms"]["value"] == 120_000
assert body["score"]["metrics_detail"]["reply_2h_rate"]["value"] == 0.2
assert body["score"]["metrics_detail"]["fr_p90_ms"]["baseline"] == 60_000
# anomalies + prev/baseline 原始值(prev 窗 0827-0831 与基线窗重叠:
# 5 天 × msg_sent 14 = 70);详情端点 anomalies 不带 employee_name
# (自指无意义,仅 dashboard enrich)
assert len(body["anomalies"]) == 2
assert all(a["severity"] == "warning" for a in body["anomalies"])
assert all("employee_name" not in a for a in body["anomalies"])
assert body["prev"]["msg_sent"] == 70
assert body["baseline"]["msg_sent"] == 70
assert body["data_as_of"] == _ts(2026, 9, 3, 9, 0)
# ---------------------------------------------------------------- sections
async def test_employee_sections(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
base = "/api/management/employees/u1/metrics"
for section in ("overview", "service", "customer", "workload", "calls"):
r = await client.get(base, params={
"section": section, "channel": "wechat",
"start": START, "end": END})
assert r.status_code == 200, section
body = r.json()
assert body["section"] == section
assert len(body["trend"]) == 5 # 全窗口逐日(含零填充)
assert all("day" in t for t in body["trend"])
assert body["comparisons"]["reply_2h_rate"]["current"] == 0.2
assert set(body["comparisons"]["reply_2h_rate"]) == {
"current", "prev", "baseline", "median"}
r = await client.get(base, params={"section": "service",
"channel": "wechat",
"start": START, "end": END})
body = r.json()
assert body["metrics"]["cust_threads"] == 10
assert body["metrics"]["reply_2h_count"] == 2
t0 = body["trend"][0]
assert t0 == {"day": 20260901, "cust_threads": 2, "threads_answered": 2,
"fr_sum_ms": 240_000, "first_reply_p50_ms": 110_000,
"first_reply_p90_ms": 220_000}
r = await client.get(base, params={"section": "calls", "channel": "all",
"start": START, "end": END})
calls = {t["day"]: t for t in r.json()["trend"]}
assert calls[20260901]["call_count"] == 2
assert calls[20260901]["call_followup_count"] == 1
assert calls[20260905]["call_count"] == 0 # 零填充
assert r.json()["metrics"]["call_count"] == 2
# ---------------------------------------------------------------- drilldown
async def test_drilldown_fr_p90(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
url = "/api/management/employees/u2/drilldown"
r = await client.get(url, params={
"metric_code": "first_reply_p90", "channel": "all",
"start": START, "end": END, "page": 1, "page_size": 1})
assert r.status_code == 200
body = r.json()
assert body["total"] == 2
assert len(body["items"]) == 1
item = body["items"][0]
# fr_ms desc + 同值按日 desc 决胜:0902 的 pD 在前
assert item["fr_ms"] == 600_000
assert item["peer_id"] == "pD"
assert item["channel"] == "wechat"
assert item["owner_account_id"] == "wx2"
assert item["chat_hint"] == {"owner_account_id": "wx2", "talker_id": "pD"}
r = await client.get(url, params={
"metric_code": "first_reply_p90", "channel": "all",
"start": START, "end": END, "page": 2, "page_size": 1})
assert r.json()["items"][0]["peer_id"] == "pC"
# u2 的 WA 账号无 fact 行
r = await client.get(url, params={
"metric_code": "first_reply_p50", "channel": "whatsapp",
"start": START, "end": END})
assert r.json()["total"] == 0
async def test_drilldown_unreplied(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees/u1/drilldown",
params={"metric_code": "unreplied",
"channel": "wechat",
"start": START, "end": END})
assert r.status_code == 200
body = r.json()
assert body["total"] == 1 # p1(wx1)进入,u1 过滤后
assert body["items"][0]["peer_id"] == "p1"
assert body["items"][0]["owner_account_id"] == "wx1"
# u2 名下无未回候选(p3 尾部为员工消息)
r = await client.get("/api/management/employees/u2/drilldown",
params={"metric_code": "unreplied",
"channel": "wechat",
"start": START, "end": END})
assert r.json()["total"] == 0
async def test_drilldown_lifecycle_customers(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees/u1/drilldown",
params={"metric_code": "cooling_customers",
"channel": "all",
"start": START, "end": END})
assert r.status_code == 200
body = r.json()
assert body["total"] == 1 # f2 降温;f1 沉睡、f9 已删
assert body["items"][0] == {"friend_id": "f2", "channel": "wechat",
"nickname": "客户二",
"last_day": 20260825, "state": "cooling"}
r = await client.get("/api/management/employees/u1/drilldown",
params={"metric_code": "dormant_customers",
"channel": "all",
"start": START, "end": END})
body = r.json()
assert body["total"] == 1
assert body["items"][0]["friend_id"] == "f1"
assert body["items"][0]["state"] == "dormant"
async def test_drilldown_shared_customer_history(make_client):
"""I1:共享/换归属客户——互动史挂在他人账号(wx2)名下而 Customer.owner
=wx1:历史按 (channel, peer) 键控跨 owner 合并(对齐批次A _lifecycle_daily
的 peer 键控口径),客户不丢、历史完整(hist 12 ≥ 5 判沉睡)。"""
async def seed(s):
await _seed_common(s)
for day, month in ((20260701, 7), (20260801, 8)):
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx2",
peer_kind="friend", peer_id="f3", day=day,
send_cnt=3, recv_cnt=3,
last_ts=_ts(2026, month, day % 100, 12, 0),
last_is_send=True))
s.add(Customer(friend_id="f3", channel="wechat",
owner_account_id="wx1", friend_nickname="共享客户",
user_id="u1", delete_flag=0))
await s.commit()
client = await make_client(seed)
r = await client.get("/api/management/employees/u1/drilldown",
params={"metric_code": "dormant_customers",
"channel": "all",
"start": START, "end": END})
body = r.json()
assert body["total"] == 2 # f1 + f3(历史全在 wx2 名下也不丢)
f3 = next(i for i in body["items"] if i["friend_id"] == "f3")
assert f3 == {"friend_id": "f3", "channel": "wechat",
"nickname": "共享客户",
"last_day": 20260801, "state": "dormant"}
# 归属口径仍是 Customer.owner(wx1):u2(wx2)名下看不到
r = await client.get("/api/management/employees/u2/drilldown",
params={"metric_code": "dormant_customers",
"channel": "all",
"start": START, "end": END})
assert r.json()["total"] == 0
async def test_drilldown_call_followup(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees/u1/drilldown",
params={"metric_code": "call_followup",
"channel": "all",
"start": START, "end": END})
assert r.status_code == 200
body = r.json()
assert body["total"] == 2
# start_call_time desc:c2(0902)在前
assert [i["call_id"] for i in body["items"]] == ["c2", "c1"]
assert set(body["items"][0]) == {
"call_id", "channel", "call_number", "start_call_time",
"call_duration"}
assert body["summary"] == {"call_count": 2, "call_seconds": 100,
"answered_call_count": 1,
"call_followup_count": 1}
# ------------------------------------------------------- metric-definitions
async def test_metric_definitions(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/metric-definitions")
assert r.status_code == 200
body = r.json()
assert body["definitions"]["cust_threads"]["unit"] == "串"
assert body["score_config"]["trial"] is True
assert body["score_config"]["dimensions"]["risk"]["status"] == "pending_data"
assert body["config_source"] == "default" # 未种子配置→回退默认
assert "1:1" in body["caliber_notes"]
@@ -0,0 +1,300 @@
"""管理域期聚合查询判别测试(批次B Task B1)。
判据(v2 §3 / B1 规格):
- 分位=fact 表最近邻秩 ceil(q·n) 精确值(整除算术,无 ceil 方言坑),
多账号/跨渠道样本按员工合并;
- 宽表 SUM 按 (day∈窗, metric_version='v1', channel) 过滤;
active_rooms_avg 摊到全窗天数(含无数据日);
- 期末生命周期存量=每 (user, channel) 最新 ≤end 行,跨渠道相加;
- distinct 活跃客户=窗内 (uid, peer) 去重(peer_kind='friend')——
同一员工多账号的共享客户只计 1(与宽表日值口径一致);
- 窗口工具:闭区间 day_range/等长前窗/28 天个人基线窗;
- 幽灵员工(不在 staff)过滤;user_ids 交集;通话恒全渠道。
"""
import datetime as dt
from zoneinfo import ZoneInfo
from app.db.models import (
EmployeeCallDaily,
EmployeeMetricDaily,
FirstReplyFact,
MsgDailyStats,
Staff,
WechatAccount,
WhatsappAccount,
)
from app.management.employee_metrics import (
PeriodMetrics,
baseline_window,
day_range,
period_metrics,
prev_window,
team_medians,
)
_SH = ZoneInfo("Asia/Shanghai")
def _ts(*args) -> int:
return int(dt.datetime(*args, tzinfo=_SH).timestamp() * 1000)
async def _seed_org(s, users=("u1", "u2")):
for u in users:
s.add(Staff(user_id=u, name=u, is_on_job=True))
s.add(WechatAccount(wx_id="wx1", user_id="u1"))
s.add(WechatAccount(wx_id="wx2", user_id="u2"))
await s.commit()
def _mday(day, *, channel="wechat", user_id="u1", metric_version="v1", **kw):
cols = {
"msg_sent": 0, "msg_recv": 0, "active_friends": 0, "active_rooms": 0,
"cust_threads": 0, "threads_answered": 0, "fr_sum_ms": 0,
"new_customer_count": 0, "cooling_customer_count": 0,
"dormant_customer_count": 0, "reactivated_customer_count": 0,
"data_complete": True,
}
cols.update(kw)
return EmployeeMetricDaily(
day=day, channel=channel, user_id=user_id,
metric_version=metric_version, **cols
)
def _fact(fr_ms, *, channel="wechat", owner="wx1", peer="p1", day=20260901, i=0):
end = _ts(2026, 9, 1, 10, 0) + i * 60_000
return FirstReplyFact(
channel=channel, owner_account_id=owner, peer_id=peer,
thread_end_ts=end, reply_ts=end + fr_ms,
reply_msg_id=f"{owner}-{peer}-m{i}", day=day, fr_ms=fr_ms,
)
async def test_percentile_exact(make_session):
"""5 样本 p50=180s/p90=300s(ceil(2.5)=3、ceil(4.5)=5,1-based);
2 样本 p50=10s/p90=20s;窗外行不参与。"""
s = make_session
await _seed_org(s)
for i, sec in enumerate([60, 120, 180, 240, 300]):
s.add(_fact(sec * 1000, i=i))
for i, sec in enumerate([10, 20]):
s.add(_fact(sec * 1000, owner="wx2", peer="q1", i=i))
# 窗外(0906>end)长尾:混入则 p90=999s
s.add(_fact(999_000, i=9, day=20260906))
await s.commit()
m = await period_metrics(s, 20260901, 20260905, "wechat")
assert m["u1"].fr_p50_ms == 180_000
assert m["u1"].fr_p90_ms == 300_000
assert m["u2"].fr_p50_ms == 10_000
assert m["u2"].fr_p90_ms == 20_000
async def test_period_sums_vs_daily_rows(make_session):
"""宽表两天行 SUM 正确;days_with_data=2;active_rooms_avg 摊到全窗
5 天(含无数据日);channel/metric_version/窗外行都不计。"""
s = make_session
await _seed_org(s)
s.add(_mday(20260901, msg_sent=3, msg_recv=5, cust_threads=2,
threads_answered=1, fr_sum_ms=7_000, active_rooms=4,
reactivated_customer_count=1))
s.add(_mday(20260903, msg_sent=4, msg_recv=6, cust_threads=3,
threads_answered=2, fr_sum_ms=11_000, active_rooms=6,
reactivated_customer_count=2))
# 反例三连:错渠道 / 错版本 / 窗外
s.add(_mday(20260901, channel="whatsapp", msg_sent=100))
s.add(_mday(20260901, metric_version="v2", msg_sent=200))
s.add(_mday(20260906, msg_sent=400))
await s.commit()
m = await period_metrics(s, 20260901, 20260905, "wechat")
u1 = m["u1"]
assert u1.msg_sent == 7
assert u1.msg_recv == 11
assert u1.cust_threads == 5
assert u1.threads_answered == 3
assert u1.fr_sum_ms == 18_000
assert u1.days_with_data == 2
assert u1.active_rooms_avg == 2.0 # (4+6)/5 全窗天数,非 2 有数据日
assert u1.reactivated_customer == 3
async def test_end_of_period_stock(make_session):
"""期末存量=每渠道最新 ≤end 行:窗 [0901,0903] 取 0901 行、
[0901,0905] 取 0905 行;channel=None 跨渠道相加(4+2=6)。
end_active_friends 同源同行:wechat 7 + whatsapp 3 → 10(评分
active_share 分子,与周期 distinct 的 active_friends 字段不同口径)。"""
s = make_session
await _seed_org(s)
s.add(_mday(20260901, new_customer_count=1, cooling_customer_count=2,
dormant_customer_count=3, active_friends=7))
s.add(_mday(20260905, dormant_customer_count=4, active_friends=7))
s.add(_mday(20260905, channel="whatsapp", dormant_customer_count=2,
active_friends=3))
await s.commit()
m1 = await period_metrics(s, 20260901, 20260903, "wechat")
assert m1["u1"].new_customer == 1
assert m1["u1"].cooling_customer == 2
assert m1["u1"].dormant_customer == 3
assert m1["u1"].end_active_friends == 7 # 渠道内最新行(0901)的值
m2 = await period_metrics(s, 20260901, 20260905, "wechat")
assert m2["u1"].dormant_customer == 4 # wechat 渠道内最新行
assert m2["u1"].new_customer == 0
assert m2["u1"].end_active_friends == 7
m3 = await period_metrics(s, 20260901, 20260905, None)
assert m3["u1"].dormant_customer == 6 # wechat 4 + whatsapp 2
assert m3["u1"].end_active_friends == 10 # wechat 7 + whatsapp 3
async def test_distinct_active_cross_day(make_session):
"""同 peer 两天两行只计 1;room 行与幽灵 owner 不计。"""
s = make_session
await _seed_org(s)
for day, peer in [(20260901, "p1"), (20260902, "p1"), (20260902, "p2")]:
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id=peer, day=day))
# room 行不计入活跃客户
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="room", peer_id="r1", day=20260902))
# 幽灵 owner(account_map 无映射)不计
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx9",
peer_kind="friend", peer_id="p9", day=20260902))
await s.commit()
m = await period_metrics(s, 20260901, 20260902, "wechat")
assert m["u1"].active_friends == 2
async def test_active_friends_shared_peer_across_accounts(make_session):
"""多账号共享客户去重:u1 的 wx1/wx3 都会话 p1 → active_friends=1
(账号归并到员工后按 peer 去重,非 (owner, peer) 双计)。"""
s = make_session
await _seed_org(s)
s.add(WechatAccount(wx_id="wx3", user_id="u1"))
await s.commit()
for owner in ("wx1", "wx3"):
s.add(MsgDailyStats(channel="wechat", owner_account_id=owner,
peer_kind="friend", peer_id="p1", day=20260901))
await s.commit()
m = await period_metrics(s, 20260901, 20260905, "wechat")
assert m["u1"].active_friends == 1
assert m["u2"].active_friends == 0
def test_window_helpers():
# day_range 闭区间 + 月末边界
assert day_range(20260901, 20260903) == [20260901, 20260902, 20260903]
assert day_range(20260831, 20260901) == [20260831, 20260901]
# prev_window 等长紧邻前窗(7 天窗 → 7 天窗;单日 → 单日)
assert prev_window(20260908, 20260914) == (20260901, 20260907)
assert prev_window(20260901, 20260901) == (20260831, 20260831)
# baseline_window:紧邻窗前 28 天([0915] → [0818, 0914])
assert baseline_window(20260915) == (20260818, 20260914)
async def test_ghost_and_channel_none(make_session):
"""幽灵(不在 staff)宽表行/fact 行不计;channel=None 合并两渠道
amap——u1(wx1+wa1)的宽表 SUM 与 fact 分位都跨渠道归并;
无数据员工(u2)也以零值行出现。"""
s = make_session
await _seed_org(s)
s.add(WhatsappAccount(uid="wa1", user_id="u1"))
# 账号存在但映射到非 staff 用户 → 幽灵
s.add(WechatAccount(wx_id="wxg", user_id="ghost"))
await s.commit()
s.add(_mday(20260901, msg_sent=3))
s.add(_mday(20260901, channel="whatsapp", msg_sent=4))
s.add(_mday(20260901, user_id="ghost", msg_sent=50))
s.add(_fact(100_000, owner="wx1", i=0))
s.add(_fact(300_000, channel="whatsapp", owner="wa1", peer="w1", i=1))
s.add(_fact(50_000, owner="wxg", i=2))
await s.commit()
m = await period_metrics(s, 20260901, 20260905, None)
assert set(m) == {"u1", "u2"} # ghost 不在;零数据 u2 在
u1 = m["u1"]
assert u1.msg_sent == 7 # 两渠道行都归 u1
assert u1.days_with_data == 1 # 双渠道同日只计 1 天(DISTINCT day)
assert u1.fr_p50_ms == 100_000 # [100s,300s] 合并后 ceil(0.5*2)=1
assert u1.fr_p90_ms == 300_000 # ceil(0.9*2)=2
assert u1.reply_2h_count == 2
assert u1.reply_24h_count == 2
assert m["u2"].msg_sent == 0
assert m["u2"].fr_p50_ms is None
async def test_reply_counts(make_session):
"""fr=90min/3h/25h → 2h_count=1、24h_count=2(2h 是 24h 的子集)。"""
s = make_session
await _seed_org(s)
s.add(_fact(5_400_000, i=0)) # 90min ≤2h
s.add(_fact(10_800_000, i=1)) # 3h ∈(2h,24h]
s.add(_fact(90_000_000, i=2)) # 25h >24h
await s.commit()
m = await period_metrics(s, 20260901, 20260905, "wechat")
assert m["u1"].reply_2h_count == 1
assert m["u1"].reply_24h_count == 2
assert m["u2"].reply_2h_count == 0
async def test_user_ids_filter_and_calls(make_session):
"""user_ids 与 staff 求交(未知 id 剔除/空集→{});通话四列按窗 SUM
且不受 channel 参数影响(无渠道维,恒全渠道)。"""
s = make_session
await _seed_org(s)
s.add(_mday(20260901, user_id="u1", msg_sent=1))
s.add(_mday(20260901, user_id="u2", msg_sent=2))
s.add(EmployeeCallDaily(day=20260901, user_id="u1", call_count=3,
call_seconds=600, answered_call_count=2,
call_followup_count=1))
s.add(EmployeeCallDaily(day=20260902, user_id="u1", call_count=1,
call_seconds=60, answered_call_count=1,
call_followup_count=0))
s.add(EmployeeCallDaily(day=20260901, user_id="u2", call_count=9))
await s.commit()
m = await period_metrics(s, 20260901, 20260905, "wechat", user_ids=["u1", "nobody"])
assert set(m) == {"u1"}
assert m["u1"].msg_sent == 1
u1 = m["u1"]
assert u1.call_count == 4
assert u1.call_seconds == 660
assert u1.answered_call == 3
assert u1.call_followup == 1
empty = await period_metrics(s, 20260901, 20260905, "wechat", user_ids=[])
assert empty == {}
# channel="whatsapp" 时通话仍计入(恒全渠道)
mwa = await period_metrics(s, 20260901, 20260905, "whatsapp")
assert mwa["u1"].call_count == 4
def test_team_medians():
"""奇数样本取中位、偶数取均值;None 剔除;全 None → None;user_id 不参与。"""
def _pm(**kw):
base = dict(zip(PeriodMetrics._fields, [None] * len(PeriodMetrics._fields)))
base.update(user_id="x", days_with_data=0, msg_sent=0, msg_recv=0,
cust_threads=0, threads_answered=0, fr_sum_ms=0,
reply_2h_count=0, reply_24h_count=0, active_friends=0,
active_rooms_avg=0.0, new_customer=0, cooling_customer=0,
dormant_customer=0, reactivated_customer=0,
call_count=0, call_seconds=0, answered_call=0,
call_followup=0)
base.update(kw)
return PeriodMetrics(**base)
metrics = {
"u1": _pm(user_id="u1", msg_sent=10, fr_p50_ms=None),
"u2": _pm(user_id="u2", msg_sent=20, fr_p50_ms=100),
"u3": _pm(user_id="u3", msg_sent=30, fr_p50_ms=200),
}
med = team_medians(metrics)
assert med["msg_sent"] == 20
assert med["fr_p50_ms"] == 150.0 # None 剔除后 [100,200] 均值
assert "user_id" not in med
assert team_medians({})["msg_sent"] is None
assert team_medians({"u1": _pm()})["fr_p50_ms"] is None
@@ -0,0 +1,315 @@
"""管理域评分配置/引擎判别测试(批次B Task B2)。"""
import json
import sqlalchemy as sa
from app.db.models import EmployeeScoreConfig
from app.management.employee_metrics import PeriodMetrics
from app.management.metric_definitions import (
DEFAULT_SCORE_CONFIG,
METRIC_DEFINITIONS,
validate_config,
)
from app.management.scoring import normalize, score_employee
def test_config_weights_sum_and_structure():
dims = DEFAULT_SCORE_CONFIG["dimensions"]
assert sum(d["weight"] for d in dims.values()) == 100
# 一期 pending_data 的维度不参与试算
available = [d for d in dims.values() if d.get("status") != "pending_data"]
assert sum(d["weight"] for d in available) == 70 # 30+25+15
assert dims["action"]["status"] == "pending_data"
assert dims["risk"]["status"] == "pending_data"
def test_config_normalization_declared():
assert DEFAULT_SCORE_CONFIG["normalization"] == "available_only_rescale"
def test_validate_config_rejects_bad_weight_sum():
bad = json.loads(json.dumps(DEFAULT_SCORE_CONFIG)) # deep copy
bad["dimensions"]["service"]["weight"] = 26 # 总和 101
problems = validate_config(bad)
assert any("权重" in p for p in problems)
def test_metric_definitions_cover_scored_metrics():
# 每个参与评分的 metric_code 都有元数据(名称/单位/方向)
for dim in DEFAULT_SCORE_CONFIG["dimensions"].values():
for code in dim.get("metrics", {}):
# band 型指标的 key 带 _band 后缀,spec 里有 metric 字段:
spec = dim["metrics"][code]
src = spec.get("metric", code)
assert src in METRIC_DEFINITIONS
m = METRIC_DEFINITIONS[src]
assert m["name"] and m["unit"] and m["direction"] in (1, -1)
async def test_seed_and_idempotent(make_session):
from app.management.metric_definitions import _seed_score_config # 纯会话函数
s = make_session
v1 = await _seed_score_config(s)
await s.commit()
assert v1 == "v1.0"
rows = (await s.execute(sa.select(EmployeeScoreConfig))).scalars().all()
assert len(rows) == 1 and rows[0].status == "active"
# 幂等:已存在同版本返回 None 不新建
assert await _seed_score_config(s) is None
await s.commit()
assert len((await s.execute(sa.select(EmployeeScoreConfig))).scalars().all()) == 1
# ---- 评分引擎(Task B2) ----
def _pm(**overrides):
"""全零/None 默认的 PeriodMetrics 构造器(22 字段,覆盖式改)。"""
base = {
"user_id": "u1", "days_with_data": 0, "msg_sent": 0, "msg_recv": 0,
"cust_threads": 0, "threads_answered": 0, "fr_sum_ms": 0,
"fr_p50_ms": None, "fr_p90_ms": None,
"reply_2h_count": 0, "reply_24h_count": 0,
"active_friends": 0, "active_rooms_avg": 0.0,
"new_customer": 0, "cooling_customer": 0, "dormant_customer": 0,
"end_active_friends": 0, "reactivated_customer": 0,
"call_count": 0, "call_seconds": 0, "answered_call": 0,
"call_followup": 0,
}
base.update(overrides)
return PeriodMetrics(**base)
def _full_marks(**overrides):
"""三可用维度全满分:率 1.0/fr 10min/占比 0.6/两 band 区间内,样本全 ok。"""
full = dict(
cust_threads=50, reply_2h_count=50, reply_24h_count=50,
fr_p90_ms=600_000, # 10min ≤ 30min 满分线
end_active_friends=9, cooling_customer=3, dormant_customer=3, # 9/15=0.6
active_friends=50, # band [5,80] 内且样本 ok
call_count=20, # band [1,200] 内
)
full.update(overrides)
return _pm(**full)
def test_normalize_target_three_segments():
spec = {"type": "target", "floor": 0.40, "target": 0.85}
assert normalize(spec, 0.20) == 0.0 # ≤floor
assert normalize(spec, 0.40) == 0.0 # floor 处 0
assert normalize(spec, 0.625) == 50.0 # 中点 50
assert normalize(spec, 0.85) == 100.0 # target 处 100
assert normalize(spec, 0.99) == 100.0 # ≥target 截断
assert normalize(spec, None) is None # 无值≠0 分
def test_normalize_reverse_ends_and_midpoint():
spec = {"type": "reverse", "target_ms": 1_800_000, "floor_ms": 14_400_000}
assert normalize(spec, 1_800_000) == 100.0 # 30min 满分
assert normalize(spec, 600_000) == 100.0 # 更快也封顶 100
assert normalize(spec, 8_100_000) == 50.0 # (30min+4h) 中点 50
assert normalize(spec, 14_400_000) == 0.0 # 4h 0 分
assert normalize(spec, 28_800_000) == 0.0 # 更慢截断 0
assert normalize(spec, None) is None
def test_normalize_band_three_segments():
spec = {"type": "band", "metric": "active_friends", "low": 5, "high": 80,
"basis": "period_distinct"}
assert normalize(spec, 3) is None # <low 负载不足→不评分
assert normalize(spec, 4.9) is None
assert normalize(spec, 5) == 100.0 # 含下限
assert normalize(spec, 80) == 100.0 # 含上限
assert normalize(spec, 81) == 80.0 # 超上限 80 不再加
assert normalize(spec, 500) == 80.0
assert normalize(spec, None) is None
def test_zero_threads_rates_none_not_zero():
"""cust_threads=0 → 回复率无值(None),不是 0%;service 维度 pending。"""
res = score_employee(DEFAULT_SCORE_CONFIG, _pm(cust_threads=0))
for code in ("reply_2h_rate", "reply_24h_rate"):
md = res["metrics_detail"][code]
assert md["value"] is None
assert md["score"] is None
assert res["dimensions"]["service"]["status"] == "pending"
def test_insufficient_sample_employee():
"""cust_threads=5(<soft 10):service 维度 insufficient(率有值 0.0 但
不评分);customer 期末无存量 pending;workload 样本 af=0 也不足——
可用维度=0 → 整员工不评分。"""
res = score_employee(DEFAULT_SCORE_CONFIG, _pm(cust_threads=5))
assert res["dimensions"]["service"]["status"] == "insufficient"
assert res["dimensions"]["service"]["score"] is None
assert res["dimensions"]["customer"]["status"] == "pending"
assert res["dimensions"]["workload"]["status"] == "insufficient"
assert res["total_score"] is None
assert res["grade"] is None
assert res["score_status"] == "insufficient_sample"
md = res["metrics_detail"]["reply_2h_rate"]
assert md["value"] == 0.0 and md["score"] is None
assert md["sample_size"] == 5 and md["sample_status"] == "insufficient"
def test_low_confidence_still_scored():
"""cust_threads=15 ∈ [soft10, min30):照评但逐指标 low_confidence。"""
cur = _pm(cust_threads=15, reply_2h_count=15, reply_24h_count=15,
fr_p90_ms=600_000)
res = score_employee(DEFAULT_SCORE_CONFIG, cur)
svc = res["dimensions"]["service"]
assert svc["status"] == "scored" and svc["score"] == 100.0
for code in ("reply_2h_rate", "reply_24h_rate", "fr_p90_ms"):
assert svc["metrics"][code]["sample_status"] == "low_confidence"
assert svc["metrics"][code]["score"] == 100.0
# 仅 service 可用(25/25)→ 重归一后仍满分
assert res["score_status"] == "scored"
assert res["total_score"] == 100
def test_full_marks_rescale_100_of_70():
"""三可用维度(权重 30+25+15=70)全满分 → 100/70 重归一 = 100,A。"""
res = score_employee(DEFAULT_SCORE_CONFIG, _full_marks())
assert res["dimensions"]["customer"]["score"] == 100.0
assert res["dimensions"]["service"]["score"] == 100.0
assert res["dimensions"]["workload"]["score"] == 100.0
assert res["total_score"] == 100
assert res["grade"] == "A"
assert res["score_status"] == "scored"
# 部分:service 满分但 active_share=0(floor 下)→ (0+25+15)/70=57 → E
res2 = score_employee(
DEFAULT_SCORE_CONFIG,
_full_marks(end_active_friends=1, cooling_customer=10,
dormant_customer=10), # 1/21≈0.05 ≤ floor 0.20 → 0 分
)
assert res2["total_score"] == 57
assert res2["grade"] == "E"
def test_pending_dimensions_not_scored():
"""action/risk 一期 pending_data:status=pending、无 metrics、不参与归一。"""
res = score_employee(DEFAULT_SCORE_CONFIG, _full_marks())
for key in ("action", "risk"):
d = res["dimensions"][key]
assert d["status"] == "pending"
assert d["score"] is None
assert "metrics" not in d
scored_w = sum(d["weight"] for d in res["dimensions"].values()
if d["status"] == "scored")
assert scored_w == 70 # pending 的 30 权重不进分母
def test_active_share_derivation():
"""end_active 6/(6+cooling3+dormant1)=0.6 → 达 target 线 100 分。"""
cur = _pm(cust_threads=50, end_active_friends=6,
cooling_customer=3, dormant_customer=1)
res = score_employee(DEFAULT_SCORE_CONFIG, cur)
md = res["metrics_detail"]["active_share"]
assert md["value"] == 0.6
assert md["score"] == 100.0
assert res["dimensions"]["customer"]["score"] == 100.0
assert md["sample_status"] == "ok"
def test_metrics_detail_four_compare_slots():
"""value/prev/baseline/median 四对比位透出;band 明细的 median 按
源指标名(active_friends)取 team;单位/名称来自元数据。"""
cur = _pm(cust_threads=40, reply_2h_count=30, reply_24h_count=38,
fr_p90_ms=600_000, end_active_friends=6, cooling_customer=3,
dormant_customer=1, active_friends=40, call_count=10)
prev = _pm(cust_threads=40, reply_2h_count=20, reply_24h_count=30,
fr_p90_ms=3_600_000, end_active_friends=3, cooling_customer=6,
dormant_customer=1, active_friends=20, call_count=5)
base = _pm(cust_threads=40, reply_2h_count=10)
team = {"reply_2h_rate": 0.7, "active_friends": 33.0}
res = score_employee(DEFAULT_SCORE_CONFIG, cur, baseline=base,
team=team, prev=prev)
md = res["metrics_detail"]["reply_2h_rate"]
assert md["value"] == 0.75
assert md["prev"] == 0.5
assert md["baseline"] == 0.25
assert md["median"] == 0.7
assert md["unit"] == "0-1"
assert md["name"] == "2h回复率"
# band 型明细键=配置 code,median 按源指标名查 team
band = res["metrics_detail"]["active_friends_band"]
assert band["median"] == 33.0
assert band["unit"] == "人"
assert res["metrics_detail"]["fr_p90_ms"]["prev"] == 3_600_000
assert res["metrics_detail"]["active_share"]["prev"] == 0.3 # 3/(3+6+1)
def test_trial_flag_from_config():
"""试算标记透传:默认配置恒 True(v2 §0 全默认+试算)。"""
res = score_employee(DEFAULT_SCORE_CONFIG, _pm())
assert res["trial"] is True
# 标记来自 config 而非写死——正式版翻 False 只需改配置
no_trial = json.loads(json.dumps(DEFAULT_SCORE_CONFIG))
no_trial["trial"] = False
assert score_employee(no_trial, _pm())["trial"] is False
def test_validate_config_threshold_shapes():
"""阈值形状四查:type 合法/target floor<target/reverse 先后/band 区间。
默认配置自身必须通过。"""
assert validate_config(DEFAULT_SCORE_CONFIG) == []
def _bad(mutate):
cfg = json.loads(json.dumps(DEFAULT_SCORE_CONFIG))
mutate(cfg["dimensions"])
return cfg
bad_type = _bad(lambda d: d["workload"]["metrics"]["call_band"]
.update(type="linear"))
assert any("call_band" in p and "类型非法" in p
for p in validate_config(bad_type))
bad_target = _bad(lambda d: d["customer"]["metrics"]["active_share"]
.update(floor=0.70)) # floor>target 0.60
assert any("active_share" in p and "floor<target" in p
for p in validate_config(bad_target))
bad_reverse = _bad(lambda d: d["service"]["metrics"]["fr_p90_ms"]
.update(target_ms=20_000_000)) # > floor_ms 14.4M
assert any("fr_p90_ms" in p and "target_ms<floor_ms" in p
for p in validate_config(bad_reverse))
bad_band = _bad(lambda d: d["workload"]["metrics"]["call_band"]
.update(low=300)) # low>high 200
assert any("call_band" in p and "low<high" in p
for p in validate_config(bad_band))
def test_validate_config_grade_bounds_shape():
"""grade_bounds 须含 A/B/C/D 且严格降序(B4 DB 配置加载预埋)。"""
missing = json.loads(json.dumps(DEFAULT_SCORE_CONFIG)) # deep copy
del missing["grade_bounds"]["A"]
assert any("grade_bounds" in p for p in validate_config(missing))
non_desc = json.loads(json.dumps(DEFAULT_SCORE_CONFIG))
non_desc["grade_bounds"].update(A=80, B=90) # A<B 非降序
assert any("grade_bounds" in p and "严格降序" in p
for p in validate_config(non_desc))
def test_validate_config_cross_dimension_code_unique():
"""同指标 code 出现于两维度:metrics_detail 扁平 dict 会碰撞,须报问题。"""
dup = json.loads(json.dumps(DEFAULT_SCORE_CONFIG)) # deep copy
# 把 customer 维的 active_share 整个抄进 service 维 → code 跨维度重复
dup["dimensions"]["service"]["metrics"]["active_share"] = \
dup["dimensions"]["customer"]["metrics"]["active_share"]
assert any("active_share" in p and "重复" in p for p in validate_config(dup))
def test_validate_config_source_name_unique():
"""两个 code 指向同一源指标:comparisons 按源名建键会静默覆盖,须报问题。"""
dup = json.loads(json.dumps(DEFAULT_SCORE_CONFIG))
# service 里造一个不同 code 但同指向 active_friends 的 band(源名冲突)
dup["dimensions"]["service"]["metrics"]["load_band"] = {
"type": "band", "metric": "active_friends", "low": 1, "high": 50,
}
assert any("同一源指标 active_friends" in p for p in validate_config(dup))
def test_validate_config_default_config_clean():
"""默认配置自身必须全绿(空问题列表)。"""
assert validate_config(DEFAULT_SCORE_CONFIG) == []
@@ -1,4 +1,4 @@
"""_analytics_tick:滚动刷新 + 增量扫描 + 异常吞掉(数据分析板块 v1, T11)。
"""_analytics_tick:滚动刷新 + 增量扫描 + 管理域滚动段 + 异常吞掉(v1 T11;批次F F1)。
行为测试是底线——start() 的 job 注册断言在 test_sync_scheduler.py 的
注册测试里同步更新(那里 spy 断言 add_job 全集)。
@@ -15,6 +15,7 @@ from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.db.models import MsgDailyStats, SensitiveWord, StatDailyStaff
from app.storage.staff_summary import rolling_days
from app.storage.summary import day_key
from app.sync.scheduler import SyncScheduler
@@ -74,3 +75,110 @@ async def test_analytics_tick_swallows_errors(sched_engine, monkeypatch, caplog)
with caplog.at_level(logging.ERROR, logger="app.sync.scheduler"):
await sch._analytics_tick() # 不抛
assert any("analytics tick failed" in r.getMessage() for r in caplog.records)
async def _seed_today(sched_engine) -> int:
"""seed 当日一条 wechat friend 会话 + 敏感词,返回 seed 用的 day 键。"""
_, maker = sched_engine
today = day_key(int(time.time() * 1000))
async with maker() as s:
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t1", day=today,
send_cnt=1, recv_cnt=0, last_ts=1756950000000,
last_is_send=True))
s.add(SensitiveWord(word="私单"))
await s.commit()
return today
async def test_tick_calls_management_chain_in_order(sched_engine, monkeypatch):
"""两段链完整顺序:recompute_days → scan → pairs_active_in_days →
recompute_pairs → recompute_metric_days → recompute_call_days。
记录桩替换全部六个上游——_analytics_tick 段内函数延迟 import,
monkeypatch 模块属性即命中(惯例见 test_analytics_tick_swallows_errors)。
"""
engine, maker = sched_engine
calls: list[str] = []
def rec(name: str, ret):
async def _stub(*args, **kwargs):
calls.append(name)
return ret
return _stub
monkeypatch.setattr("app.storage.staff_summary.recompute_days",
rec("recompute_days", 0))
monkeypatch.setattr("app.analytics.sensitive.scanner.run_incremental",
rec("scan", {"scanned": 0, "hits": 0}))
monkeypatch.setattr("app.storage.first_reply_facts.pairs_active_in_days",
rec("pairs_active_in_days", []))
monkeypatch.setattr("app.storage.first_reply_facts.recompute_pairs",
rec("recompute_pairs", 0))
monkeypatch.setattr("app.storage.employee_metrics.recompute_metric_days",
rec("recompute_metric_days", 0))
monkeypatch.setattr("app.storage.employee_metrics.recompute_call_days",
rec("recompute_call_days", 0))
sch = SyncScheduler(engine=engine, session_maker=maker)
await sch._analytics_tick()
assert calls == [
"recompute_days", "scan", # 段1
"pairs_active_in_days", "recompute_pairs", # 段2
"recompute_metric_days", "recompute_call_days",
]
async def test_tick_management_failure_isolated(sched_engine, monkeypatch, caplog):
"""段2 失败隔离:recompute_metric_days 抛 RuntimeError → ①tick 不抛;
②记 "management metrics tick failed";③段1 已提交的 StatDailyStaff
不被段2 回滚(两段独立事务)。
"""
engine, maker = sched_engine
await _seed_today(sched_engine)
async def _boom(*args, **kwargs):
raise RuntimeError("boom")
monkeypatch.setattr("app.storage.employee_metrics.recompute_metric_days", _boom)
sch = SyncScheduler(engine=engine, session_maker=maker)
with caplog.at_level(logging.ERROR, logger="app.sync.scheduler"):
await sch._analytics_tick() # ①不抛
assert any( # ②段2 独立记日志
"management metrics tick failed" in r.getMessage() for r in caplog.records
)
async with maker() as s: # ③段1 结果已提交
n = (await s.execute(
select(func.count()).select_from(StatDailyStaff)
)).scalar_one()
assert n >= 1
async def test_tick_management_rolling_window(sched_engine, monkeypatch):
"""管理域三函数收到的 days == rolling_days(today, 3)(长度 3,当日+前
两天),且与 staff 段捕获的窗口一致(两段共用函数顶提取的同一 today)。
"""
engine, maker = sched_engine
got: dict[str, list[int]] = {}
def cap(name: str, ret):
async def _stub(session, days, *args, **kwargs):
got[name] = list(days)
return ret
return _stub
monkeypatch.setattr("app.storage.staff_summary.recompute_days",
cap("staff", 0))
monkeypatch.setattr("app.storage.first_reply_facts.pairs_active_in_days",
cap("pairs", []))
monkeypatch.setattr("app.storage.employee_metrics.recompute_metric_days",
cap("wide", 0))
monkeypatch.setattr("app.storage.employee_metrics.recompute_call_days",
cap("call", 0))
sch = SyncScheduler(engine=engine, session_maker=maker)
await sch._analytics_tick()
rd = rolling_days(day_key(int(time.time() * 1000)), 3)
assert len(rd) == 3 # 当日 + 前两天
assert got["staff"] == got["pairs"] == got["wide"] == got["call"] == rd
+20 -11
View File
@@ -572,7 +572,12 @@ async def test_safe_page_run_full_invokes_bash_full_sync(engine, monkeypatch):
async def test_safe_page_run_messages_builds_scrape_command(engine, monkeypatch):
"""messages job = friends→wa-friends→四类消息→moments 浅扫的命令序列。"""
"""messages job = accounts→friends→四类消息(活跃窗)→moments 浅扫的命令序列。
2026-09-10 调度根治:头部刷员工账号表(员工列表时间最多滞后 4h);
消息实体只扫近 24h 活跃对话(--active-window-hours 24,防序列 15h+
挤死 full);陈旧对话每日 full 兜底。
"""
sched = SyncScheduler(engine=engine, max_conversations_per_run=123)
commands: list[list[str]] = []
@@ -584,22 +589,26 @@ async def test_safe_page_run_messages_builds_scrape_command(engine, monkeypatch)
await sched._safe_page_run("messages")
# 2026-08-21: messages job 是 friends→wa-friends→四类消息的命令序列,
# 每条都调 scrape_via_page.py;2026-08-25 末尾追加 moments 浅扫
assert len(commands) == 7
assert len(commands) == 10
for args in commands:
assert args[1].endswith("scrape_via_page.py")
def entity_of(args: list[str]) -> str:
return args[args.index("--entity") + 1]
assert entity_of(commands[0]) == "friends"
assert entity_of(commands[1]) == "wa-friends"
for args in commands[2:6]:
assert entity_of(args) in (
"messages", "wa-messages", "group-messages", "wa-group-messages",
)
assert args[args.index("--max-conversations") + 1] == "99999"
# 头部:账号刷新(员工列表「数据更新/最新消息时间」来源)
assert entity_of(commands[0]) == "accounts"
assert entity_of(commands[1]) == "wa-accounts"
assert entity_of(commands[2]) == "friends"
assert entity_of(commands[3]) == "wa-friends"
# 群消息前刷群活跃(chatroom-stats 全窗——群活跃窗判定源,2026-09-10)
entities_seq = [entity_of(a) for a in commands]
assert entities_seq.index("chatroom-stats") < entities_seq.index("group-messages")
# 四类消息:24h 活跃窗(2026-09-10 根治调度堵塞)
for args in commands:
if entity_of(args) in ("messages", "wa-messages", "group-messages", "wa-group-messages"):
assert args[args.index("--max-conversations") + 1] == "99999"
assert args[args.index("--active-window-hours") + 1] == "24"
# moments 浅扫在序列末尾:每员工最多 3 页
moments_cmd = commands[-1]
assert entity_of(moments_cmd) == "moments"
+13
View File
@@ -178,6 +178,14 @@ export interface GroupQuery {
room_id?: string
/** 群名称模糊搜索(通讯录群 tab 搜索框)。 */
q?: string
/** 群备注子串(微信群统计筛选)。 */
group_remark?: string
/** 最新消息时间段(ms,含端点)。 */
last_start?: number
last_end?: number
/** 统计时段(ms,含端点)——窗内输出沟通消息数/新增/退出成员数聚合。 */
msg_start?: number
msg_end?: number
page?: number
page_size?: number
}
@@ -191,6 +199,11 @@ export function getGroups(query: GroupQuery = {}): Promise<Page<Group>> {
user_id: query.user_id,
room_id: query.room_id,
q: query.q,
group_remark: query.group_remark,
last_start: query.last_start,
last_end: query.last_end,
msg_start: query.msg_start,
msg_end: query.msg_end,
page: query.page ?? 1,
page_size: query.page_size ?? 20,
}),
+107
View File
@@ -0,0 +1,107 @@
// 经营管理板块 API(批次C)。契约以批次B终评冻结面为准:
// None≠0(无样本分位/率=null);scope.channel 回显原值;drilldown 白名单六值。
// 已对照批次B as-built(management.py/api_assembly.py)逐键核对:
// - dashboard 另有 caliber_notes(总述口径说明,Record<string,string>)
// - drilldown 各分支均返回 page/page_size/scope(与 items/total 同层)
// - first_reply_* 行含 chat_hint(owner+talker 直达聊天记录)
// - lifecycle 行 nickname 恒非空(后端 friend_nickname→remark→friend_id 兜底)
import { getJson } from './client'
import type { Channel } from './types'
export type MgmtChannel = 'all' | Channel
export type CompareMode = 'previous' | 'personal_baseline' | 'team_median'
export type DrilldownCode =
| 'first_reply_p90' | 'first_reply_p50' | 'unreplied'
| 'cooling_customers' | 'dormant_customers' | 'call_followup'
export interface MgmtScope { channel: MgmtChannel; start: number; end: number; compare?: CompareMode; view?: string; user_id?: string; metric_code?: string }
export interface Kpis {
active_customers: number; active_employees: number
msg_total: number; cust_threads: number; threads_answered: number
call_count: number; call_seconds: number; answered_call: number
lifecycle: { new: number; cooling: number; dormant: number; reactivated: number }
}
export interface CommTrendPoint { day: number; msg_total: number; active_customers: number; call_cnt: number }
export interface SvcTrendPoint { day: number; fr_p50_ms: number | null; fr_p90_ms: number | null; reply_2h_rate: number | null }
export interface DimSummary { [dim: string]: { name: string; weight: number; scored_count: number; pending_count: number; insufficient_count: number; avg_score: number | null } }
export interface AnomalyCard {
rule_code: string; severity: 'critical' | 'warning'; employee_user_id: string
// dashboard 端点 enrich(批次D,跳员工详情用);员工详情端点不带
employee_name?: string
title: string; current_value: number; baseline_value: number | null; unit: string
sample_size: number; affected_count: number; reason: string
drilldown: { type: string; query: { metric_code?: string; user_id?: string } }
suggested_action: string
}
export interface DashboardResp {
scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default'
kpis: Kpis
// 前窗环比块(批次D):prev 窗同式聚合;可选防御旧缓存响应
prev_kpis?: Pick<Kpis, 'active_customers' | 'active_employees' | 'msg_total' | 'cust_threads'>
msg_delta?: number | null // (msg_total-prev)/prev,prev=0 → null;客户端三卡自算,此键备用
communication_trend: CommTrendPoint[]
service_trend: SvcTrendPoint[]
lifecycle: Kpis['lifecycle']
dimension_summary: DimSummary
anomalies: AnomalyCard[]
action_summary: { unreplied_2h: number; churn_real_30d: number; sensitive_pending: number }
caliber_notes: Record<string, string>
}
export interface EmployeeRow {
user_id: string; name: string | null; is_on_job: boolean | null
// metrics=PeriodMetrics._asdict():数值字段外的 user_id 键是字符串(透传
// 字段,UI 取身份用顶层 row.user_id,动态取数只碰数值键)
metrics: Record<string, number | null>
comparisons: Record<string, { current: number | null; prev: number | null; baseline: number | null; median: number | null }>
score: { total_score: number | null; grade: string | null; score_status: string; trial: boolean; dimensions: Record<string, { score: number | null; status: string }> }
anomaly_count: number; anomaly_weight: number
}
export interface EmployeesResp { total: number; page: number; page_size: number; rows: EmployeeRow[]; scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default' }
export interface FactDrillRow { day: number; channel: string; owner_account_id: string; peer_id: string; thread_end_ts: number; reply_ts: number; fr_ms: number; chat_hint: { owner_account_id: string; talker_id: string } }
export interface LifecycleDrillRow { friend_id: string; channel: string; nickname: string; last_day: number; state: string }
export interface CallDrillRow { call_id: string; channel: string; call_number: string | null; start_call_time: number; call_duration: number }
export interface DrilldownResp { total: number; page: number; page_size: number; scope: MgmtScope; items: Record<string, unknown>[]; note?: string; truncated?: boolean; summary?: Record<string, number> }
export interface MetricDefsResp {
definitions: Record<string, { name: string; unit: string; direction: number; caliber?: string; formula?: string }>
score_config: { version: string; name: string; trial: boolean; normalization: string; sample_guards: Record<string, number>; grade_bounds: Record<string, number>; dimensions: Record<string, { name: string; weight: number; status?: string }> }
caliber_notes: string
config_source: 'db' | 'default'
}
export const getDashboard = (channel: MgmtChannel, start: number, end: number, compare: CompareMode = 'previous') =>
getJson<DashboardResp>('/management/dashboard', { channel, start, end, compare })
export const getEmployees = (params: Record<string, unknown>) =>
getJson<EmployeesResp>('/management/employees', params)
export const getEmployeeDrilldown = (userId: string, metricCode: DrilldownCode, start: number, end: number, channel: MgmtChannel, page = 1, pageSize = 20) =>
getJson<DrilldownResp>(`/management/employees/${userId}/drilldown`, { metric_code: metricCode, start, end, channel, page, page_size: pageSize })
export const getMetricDefs = () => getJson<MetricDefsResp>('/management/metric-definitions')
// ---- 批次D:员工详情 + 分段指标(契约已真机 8000 抽查核对,见上) ----
export interface EmployeeAccount { channel: string; account_id: string; nick_name: string | null; user_name: string | null }
export interface ScoreMetricDetail { value: number | null; prev: number | null; baseline: number | null; median: number | null; sample_size: number; sample_status: string; score: number | null; unit: string; name: string }
// formula_note 在 dimensions[dim].metrics[code] 上(scoring 维度级明细),不在 metrics_detail
export interface EmployeeDetailResp {
user_id: string; name: string | null; is_on_job: boolean | null
accounts: EmployeeAccount[]
metrics: Record<string, number | string | null>
score: {
total_score: number | null; grade: string | null; score_status: string; trial: boolean
dimensions: Record<string, { name: string; weight: number; score: number | null; status: string; metrics?: Record<string, { value: number | null; score: number | null; sample_size: number; sample_status: string; formula_note?: string }> }>
metrics_detail: Record<string, ScoreMetricDetail>
}
anomalies: AnomalyCard[]
prev: Record<string, number | string | null> | null
baseline: Record<string, number | string | null> | null
scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default'
}
export interface SectionResp {
section: string; metrics: Record<string, number | null>
trend: Record<string, number | null>[]
comparisons: Record<string, { current: number | null; prev: number | null; baseline: number | null; median: number | null }>
scope: MgmtScope; data_as_of: number | null; metric_version: string
}
export type MetricsSection = 'overview' | 'service' | 'customer' | 'workload' | 'calls'
export const getEmployeeDetail = (userId: string, channel: MgmtChannel, start: number, end: number) =>
getJson<EmployeeDetailResp>(`/management/employees/${userId}`, { channel, start, end })
export const getEmployeeMetrics = (userId: string, section: MetricsSection, channel: MgmtChannel, start: number, end: number) =>
getJson<SectionResp>(`/management/employees/${userId}/metrics`, { section, channel, start, end })
+13
View File
@@ -113,6 +113,19 @@ export interface Group {
friend_count?: number | null
/** 建群时间 ms-epoch */
create_time?: number | null
/** 群备注(微信群统计页;getChatroomList.remark,2026-09-09) */
group_remark?: string | null
/** 员工微信列(getChatroomList 同款):昵称/微信号/头像 */
staff_nickname?: string | null
staff_wx_alias?: string | null
staff_avatar?: string | null
/** 员工信息列:姓名/手机(staff 关联) */
staff_name?: string | null
staff_phone?: string | null
/** 统计时段窗内聚合(仅请求带 msg_start/msg_end 时非 null) */
window_msg_count?: number | null
window_added?: number | null
window_lost?: number | null
/**
* Verbatim upstream chatroom record (camelCase keys: chatRoomId/chatRoomName,
* memberCount/memberIds, headImageUrl, wechatId/wechatNickName, userName/
@@ -0,0 +1,136 @@
<template>
<el-table
v-loading="loading"
:data="anomalies"
:row-class-name="rowClass"
@row-click="onRowClick"
>
<el-table-column label="级别" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.severity === 'critical'" type="danger" size="small">严重</el-tag>
<el-tag v-else type="warning" size="small">关注</el-tag>
</template>
</el-table-column>
<el-table-column label="异常" min-width="240">
<template #default="{ row }">
<div class="cell-title">{{ row.title }}</div>
<div v-if="showEmployee" class="cell-emp">
<el-link
type="primary"
underline="never"
@click.stop="goEmployee(row)"
>{{ row.employee_name || row.employee_user_id.slice(0, 8) }}</el-link>
</div>
<div class="cell-sub">{{ row.reason }}</div>
</template>
</el-table-column>
<el-table-column label="当前值" width="150">
<template #default="{ row }">
<div>{{ formatValue(row.current_value, row.unit) }}</div>
<div class="cell-sub">
{{ row.baseline_value === null ? '无基线' : `基线 ${formatValue(row.baseline_value, row.unit)}` }}
</div>
</template>
</el-table-column>
<el-table-column label="样本" prop="sample_size" align="right" width="80" />
<el-table-column label="影响" align="right" width="90">
<template #default="{ row }">
<span :class="{ 'affected-hot': row.affected_count >= 50 }">{{ row.affected_count }}</span>
</template>
</el-table-column>
<el-table-column label="建议动作" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-sub">{{ row.suggested_action }}</span>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无异常" :image-size="60" />
</template>
</el-table>
</template>
<script setup lang="ts">
// 异常榜(批次C 驾驶舱):后端 AnomalyCard[] 平铺表——级别 tag/标题+员工行
// (showEmployee,批次D:employee_name 链接→员工详情,.stop 防触发行下钻)
// +原因/单位感知当前值+基线/样本/影响(≥50 红显)/建议动作。行 click 打开
// 下钻抽屉(emit drill,抽屉自治加载);critical 整行淡红底提示优先处置。
// 纯展示+事件,不取数。
import { useRoute, useRouter } from 'vue-router'
import { formatMsDuration, formatRatio } from '@/utils/format'
import type { AnomalyCard } from '@/api/management'
// showEmployee(默认 true):驾驶舱异常榜显示员工行;员工详情页复用本组件
// 传 false(自指链接无意义)
withDefaults(
defineProps<{
anomalies: AnomalyCard[]
loading?: boolean
showEmployee?: boolean
}>(),
{ showEmployee: true },
)
const emit = defineEmits<{
(e: 'drill', card: AnomalyCard): void
}>()
const route = useRoute()
const router = useRouter()
// 员工链接:带当前 URL query(channel/start/end/compare 随行,与列表
// goDetail 带窗进详情同语义);detail 页对白名单外 query 键不敏感
function goEmployee(row: AnomalyCard): void {
router.push({
path: `/management/employees/${row.employee_user_id}`,
query: route.query,
})
}
// 单位感知格式化:ms→时长,0-1→比率,其余千分位(与指标口径字典 unit 对齐)
function formatValue(v: number, unit: string): string {
if (unit === 'ms') return formatMsDuration(v)
if (unit === '0-1') return formatRatio(v)
return v.toLocaleString()
}
function rowClass({ row }: { row: AnomalyCard }): string {
return row.severity === 'critical'
? 'anomaly-critical-row yk-clickable-row'
: 'yk-clickable-row'
}
function onRowClick(row: AnomalyCard): void {
emit('drill', row)
}
</script>
<style scoped>
/* critical 行整行淡红底(优先处置);hover 深一档由 EP 行 hover 自带 */
:deep(.el-table .anomaly-critical-row) {
background-color: var(--el-color-danger-light-9);
}
:deep(.el-table .anomaly-critical-row:hover > td.el-table__cell) {
background-color: var(--el-color-danger-light-8);
}
.cell-title {
font-size: 13px;
color: var(--el-text-color-primary);
}
.cell-emp {
font-size: 12px;
margin-top: 2px;
line-height: 1.4;
}
.cell-emp .el-link {
font-size: 12px;
}
.cell-sub {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 2px;
}
.affected-hot {
color: var(--el-color-danger);
font-weight: 600;
}
</style>
@@ -0,0 +1,49 @@
<template>
<span class="data-asof">
<el-tag v-if="asOfText" size="small" type="info" effect="plain">数据截至 {{ asOfText }}</el-tag>
<el-tag v-else size="small" type="info">无数据</el-tag>
<el-tag
v-if="metricVersion"
size="small"
effect="plain"
class="asof-tag"
:title="configSource === 'db' ? '口径配置:自定义(数据库)' : '口径配置:默认'"
>口径 {{ metricVersion }}</el-tag>
<el-tag v-if="trial" size="small" type="warning" class="asof-tag">试算</el-tag>
</span>
</template>
<script setup lang="ts">
// 数据截至角标(批次C 驾驶舱/员工页共用):数据时间 + 指标版本 + 试算标记。
// 纯展示——数据由父级从 dashboard/employees 载荷透传(data_as_of/metric_version/
// trial/config_source),不自行请求。configSource 无独立标签(避免噪音),
// 挂在口径版本 tag 的 title 提示上。
import { computed } from 'vue'
import { formatTime } from '@/utils/format'
const props = defineProps<{
dataAsOf?: number | null
metricVersion?: string
trial?: boolean
configSource?: 'db' | 'default'
}>()
// ms 时间戳 → `YYYY-MM-DD HH:mm`(formatTime 去秒);缺失/非法 → 灰 tag「无数据」
const asOfText = computed(() => {
const v = props.dataAsOf
if (v == null || !Number.isFinite(v) || v <= 0) return ''
return formatTime(v).slice(0, 16)
})
</script>
<style scoped>
.data-asof {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.asof-tag {
margin-left: 0;
}
</style>
@@ -0,0 +1,169 @@
<template>
<el-drawer
:model-value="modelValue"
title="员工对比"
size="620px"
@update:model-value="emit('update:modelValue', $event)"
>
<!-- v1 §6.6:对比只呈现可验证差异,不产生优劣结论(管理者需被提醒) -->
<el-alert
type="info"
:closable="false"
title="同口径同时窗对比,仅呈现可验证差异,不产生优劣结论"
class="cmp-alert"
/>
<!-- ① 维度对比:三可用维度,每人一行横向 bar(0-100;无样本灰「--」) -->
<div v-for="dim in DIMS" :key="dim.key" class="dim-block">
<div class="dim-title">{{ dim.label }}</div>
<div v-for="r in rows" :key="r.user_id" class="dim-row">
<span class="dim-name" :title="r.user_id">{{ displayName(r) }}</span>
<template v-if="dimScore(r, dim.key) !== null">
<div class="dim-track">
<div class="dim-bar" :style="{ width: `${dimScore(r, dim.key)}%` }" />
</div>
<span class="dim-score">{{ Math.round(dimScore(r, dim.key) ?? 0) }}</span>
</template>
<span v-else class="dim-na">--</span>
</div>
</div>
<!-- ② 关键指标表:行=指标,列=每人 -->
<el-table :data="metricRows" size="small" border class="cmp-table">
<el-table-column prop="label" label="指标" min-width="96" />
<el-table-column
v-for="r in rows"
:key="r.user_id"
align="right"
min-width="88"
>
<template #header>
<span :title="r.user_id">{{ displayName(r) }}</span>
</template>
<template #default="{ row }">{{ row.values[r.user_id] }}</template>
</el-table-column>
</el-table>
</el-drawer>
</template>
<script setup lang="ts">
// 员工对比抽屉(批次D):总览页勾选 2-5 人后并排看维度分与关键指标。
// 只读展示——数据来自已选行快照(EmployeeRow),不二次请求;口径/时窗与
// 总览页完全一致(同载荷),故顶部钉 v1 §6.6 提示:仅呈现可验证差异。
import { computed } from 'vue'
import type { EmployeeRow } from '@/api/management'
import { formatMsDuration, formatRatio } from '@/utils/format'
const props = defineProps<{
modelValue: boolean
/** 已勾选行(2-5,由父级按钮门槛保证);行是勾选时的快照,翻页/重查不影响 */
rows: EmployeeRow[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
}>()
// 一期可用三维度(action/risk pending_data 不参与)
const DIMS = [
{ key: 'customer', label: '客户经营' },
{ key: 'service', label: '服务质量' },
{ key: 'workload', label: '工作负载' },
] as const
function displayName(r: EmployeeRow): string {
return r.name || r.user_id.slice(0, 8)
}
function dimScore(r: EmployeeRow, key: string): number | null {
return r.score.dimensions[key]?.score ?? null
}
// metrics 取数值键(user_id 透传键是字符串,typeof 守卫掉)
function num(r: EmployeeRow, key: string): number | null {
const v = r.metrics[key]
return typeof v === 'number' ? v : null
}
function ratio(numV: number | null, den: number | null): string {
if (!den) return '--' // 分母 0/null → 无率(严格区分 0%)
return formatRatio(numV == null ? null : numV / den)
}
interface CmpMetricRow {
label: string
values: Record<string, string>
}
const metricRows = computed<CmpMetricRow[]>(() => {
const mk = (label: string, fmt: (r: EmployeeRow) => string): CmpMetricRow => {
const values: Record<string, string> = {}
for (const r of props.rows) values[r.user_id] = fmt(r)
return { label, values }
}
return [
mk('活跃客户', (r) => String(num(r, 'active_friends') ?? '--')),
mk('消息量', (r) => String((num(r, 'msg_sent') ?? 0) + (num(r, 'msg_recv') ?? 0))),
mk('首响P90', (r) => formatMsDuration(num(r, 'fr_p90_ms'))),
mk('2h回复率', (r) => ratio(num(r, 'reply_2h_count'), num(r, 'cust_threads'))),
mk('异常数', (r) => String(r.anomaly_count)),
]
})
</script>
<style scoped>
.cmp-alert {
margin-bottom: 16px;
}
.dim-block {
margin-bottom: 16px;
}
.dim-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 6px;
}
.dim-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.dim-name {
width: 88px;
flex: none;
font-size: 13px;
color: var(--el-text-color-regular);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dim-track {
flex: 1;
height: 12px;
border-radius: 6px;
background: var(--el-fill-color-light);
overflow: hidden;
}
.dim-bar {
height: 100%;
border-radius: 6px;
background: var(--el-color-primary);
}
.dim-score {
width: 28px;
flex: none;
text-align: right;
font-size: 12px;
color: var(--el-text-color-regular);
}
.dim-na {
flex: 1;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.cmp-table {
margin-top: 4px;
}
</style>
@@ -0,0 +1,111 @@
<template>
<div class="mgmt-filter-bar">
<el-radio-group :model-value="channel" @change="onChannelChange">
<el-radio-button v-for="c in CHANNELS" :key="c.value" :value="c.value">{{ c.label }}</el-radio-button>
</el-radio-group>
<el-select
v-model="preset"
class="preset-select"
placeholder="近30天"
@change="onPresetChange"
>
<el-option-group label="常用">
<el-option label="今日" value="today" />
<el-option label="昨日" value="yesterday" />
<el-option label="近7天" value="last7" />
<el-option label="近30天" value="last30" />
</el-option-group>
<el-option-group label="日历">
<el-option label="本周" value="thisWeek" />
<el-option label="上周" value="lastWeek" />
<el-option label="本月" value="thisMonth" />
<el-option label="上月" value="lastMonth" />
</el-option-group>
<el-option label="自定义" value="custom" />
</el-select>
<!-- 自定义区间:daterange 自保证 start≤end,选完才 emit;value-format
直接给 yyyymmdd 字符串,转 number 走 URL 真源 -->
<el-date-picker
v-if="preset === 'custom'"
v-model="customRange"
type="daterange"
size="small"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYYMMDD"
:clearable="false"
style="width: 240px"
@change="onCustomRange"
/>
<div class="spacer" />
<el-button text type="primary" @click="emit('open-defs')">指标口径</el-button>
<slot name="aside" />
</div>
</template>
<script setup lang="ts">
// 管理页统一筛选栏(批次C):渠道 + 时间预设/自定义区间 + 指标口径入口。
// 受控组件——channel/start/end 来自父级(useManagementFilters 的 URL 真源
// computed),变更只 emit apply(patch) 由父写回 URL,组件不实例化 composable
// (只 import CHANNELS/presetRange 常量,避免二次 useRoute/useRouter)。
//
// 预设选中值:内部 ref 记用户最近选择,初始 null 显示 placeholder「近30天」,
// 不与 URL 双向同步(URL 直接访问无 start/end 时默认即 last30,placeholder
// 恰好如实)。非 custom 预设把 presetRange 计算出的具体日期 emit 给父——
// URL 真源可分享/刷新,且选中项与实际窗口一致(composable 的默认(last30)
// 无法表达 last7/本月/上月,只清 start/end 会静默回落 30 天窗)。
import { ref } from 'vue'
import { CHANNELS, presetRange, type Preset } from '@/composables/useManagementFilters'
defineProps<{
channel: string
start: number
end: number
/** 预留(对比模式选择器未上),父级透传保持 props 面完整 */
compare?: string
}>()
const emit = defineEmits<{
(e: 'apply', patch: { channel?: string; start?: number; end?: number }): void
(e: 'open-defs'): void
}>()
// 用户最近选择的预设;null = 未选(placeholder 近30天)。custom 选完后保持,
// 日期改动走 onCustomRange 增量 emit。
const preset = ref<Preset | null>(null)
const customRange = ref<[string, string] | null>(null)
function onChannelChange(v: string | number | boolean): void {
emit('apply', { channel: String(v) })
}
function onPresetChange(v: string): void {
if (v === 'custom') return // 等 daterange 选完区间再 emit
emit('apply', presetRange(v as Preset))
}
function onCustomRange(): void {
const r = customRange.value
if (!r?.[0] || !r?.[1]) return // 未选完区间不请求
emit('apply', { start: Number(r[0]), end: Number(r[1]) })
}
</script>
<style scoped>
.mgmt-filter-bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.preset-select {
width: 120px;
}
.spacer {
flex: 1;
}
</style>
@@ -0,0 +1,94 @@
<template>
<el-card shadow="never" class="kpi-card" :class="{ link: !!to }" @click="onClick">
<div class="kpi-label">{{ label }}</div>
<div class="kpi-value" :class="{ danger }">
{{ valueText }}<span v-if="unit && value !== null" class="kpi-unit">{{ unit }}</span>
</div>
<div v-if="deltaText" class="kpi-delta" :class="deltaClass">
环比{{ deltaText }}
</div>
<div v-if="hint" class="kpi-hint">{{ hint }}</div>
</el-card>
</template>
<script setup lang="ts">
// 经营管理 KPI 卡:label 上小灰字 + 主值大号字(千分位/unit 后缀/danger 红)
// + 环比行(批次D,delta prop)+ hint 下小灰字。to 存在时整卡可点
// (hover 手型)跳转——纯展示+导航,不取数。null 值语义(无样本)显 `--`,
// 由父级决定传 null。
import { computed } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps<{
label: string
value: number | null
unit?: string
hint?: string
to?: string
danger?: boolean
// 较前窗环比 (cur-prev)/prev:null/undefined/NaN(前窗缺数据)不显示。
// 只支持升好语义(升绿↓红)——本批接入三卡(活跃客户/活跃员工/消息量)
// 均为越高越好;将来若有降好指标(如首响时长)需先加方向语义位
delta?: number | null
}>()
const router = useRouter()
const valueText = computed(() => (props.value === null ? '--' : props.value.toLocaleString()))
const deltaText = computed(() => {
if (props.delta == null || !Number.isFinite(props.delta)) return null
const arrow = props.delta > 0 ? '↑' : props.delta < 0 ? '↓' : '→'
return `${arrow}${(Math.abs(props.delta) * 100).toFixed(1)}%`
})
const deltaClass = computed(() =>
props.delta == null || !Number.isFinite(props.delta)
? ''
: props.delta > 0 ? 'up' : props.delta < 0 ? 'down' : 'flat')
function onClick(): void {
if (props.to) router.push(props.to)
}
</script>
<style scoped>
.kpi-card.link {
cursor: pointer;
}
.kpi-label {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.kpi-value {
font-size: 26px;
font-weight: 600;
margin: 6px 0 2px;
color: var(--el-text-color-primary);
}
.kpi-value.danger {
color: var(--el-color-danger);
}
.kpi-unit {
font-size: 13px;
font-weight: 400;
color: var(--el-text-color-secondary);
margin-left: 2px;
}
.kpi-delta {
font-size: 12px;
margin-bottom: 2px;
}
.kpi-delta.up {
color: var(--el-color-success);
}
.kpi-delta.down {
color: var(--el-color-danger);
}
.kpi-delta.flat {
color: var(--el-text-color-secondary);
}
.kpi-hint {
font-size: 11px;
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,199 @@
<template>
<el-drawer
:model-value="modelValue"
title="指标口径"
direction="rtl"
size="480px"
@update:model-value="onVisible"
>
<div v-loading="loading">
<el-alert v-if="error" type="error" :title="error" :closable="false" class="defs-alert" />
<template v-if="defs">
<!-- 三方口径差异总述(管理域 vs 现有看板) -->
<el-alert type="info" :closable="false" show-icon class="defs-alert">
<div class="caliber-text">{{ defs.caliber_notes }}</div>
</el-alert>
<!-- 评分配置摘要:版本/名称/试算/来源 + 样本门槛/等级线 + 维度权重 -->
<div class="cfg-card">
<div class="cfg-head">
<b>{{ defs.score_config.name }}</b>
<el-tag size="small" effect="plain">v{{ defs.score_config.version }}</el-tag>
<el-tag v-if="defs.score_config.trial" size="small" type="warning">试算</el-tag>
<el-tag size="small" :type="defs.config_source === 'db' ? 'primary' : 'info'" effect="plain">
{{ defs.config_source === 'db' ? '自定义配置' : '默认配置' }}
</el-tag>
</div>
<div class="cfg-row">样本门槛:{{ guardsText }}</div>
<div class="cfg-row">等级线:{{ gradesText }}(低于 D 线为 E)</div>
<el-table :data="dims" size="small" class="dims-table">
<el-table-column prop="name" label="维度" min-width="110" />
<el-table-column label="权重" width="70" align="right">
<template #default="{ row }">{{ row.weight }}%</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row.status === 'pending_data'" size="small" type="info">待数据</el-tag>
<el-tag v-else size="small" type="success">可用</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<!-- 指标定义:19 项平铺(端点不按维度分组),按 code 字母序逐项展示 -->
<div v-for="[code, d] in defsSorted" :key="code" class="def-item">
<div class="def-head">
<b>{{ d.name }}</b>
<code class="def-code">{{ code }}</code>
<span class="def-unit">{{ d.unit }}</span>
<el-tag size="small" :type="d.direction === 1 ? 'success' : 'warning'">
{{ d.direction === 1 ? '越大越好' : '越小越好' }}
</el-tag>
</div>
<pre v-if="d.formula" class="def-formula">{{ d.formula }}</pre>
<div v-if="d.caliber" class="def-caliber">{{ d.caliber }}</div>
</div>
</template>
</div>
</el-drawer>
</template>
<script setup lang="ts">
// 指标口径抽屉(批次C):口径字典 + 当前评分配置。打开时拉取
// /management/metric-definitions 并缓存一次(口径变化走配置版本,会话内
// 不重拉)。definitions 为平铺 19 项(dimensions 只有名称/权重),不做按
// 维度分组,按 code 字母序逐项列:名称+单位+方向 tag+formula+caliber。
import { computed, ref, watch } from 'vue'
import { getMetricDefs, type MetricDefsResp } from '@/api/management'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
}>()
const loading = ref(false)
const error = ref('')
const defs = ref<MetricDefsResp | null>(null)
let loaded = false // 缓存一次:首次打开拉取,后续打开直接用
// 打开时取数;成功后缓存(口径变化走配置版本,会话内不重拉),
// 失败(无 defs 且有 error)则重开抽屉时再试一次
watch(
() => props.modelValue,
(visible) => {
if (visible && (!loaded || (!defs.value && error.value))) load()
},
)
async function load(): Promise<void> {
loaded = true // 请求已发出即视为已加载,失败不反复自动重试
loading.value = true
error.value = ''
try {
defs.value = await getMetricDefs()
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
loading.value = false
}
}
function onVisible(v: boolean): void {
emit('update:modelValue', v)
}
// 平铺定义按 code 字母序(中文环境 code 恒为 ASCII,localeCompare 稳定)
const defsSorted = computed(() =>
Object.entries(defs.value?.definitions ?? {}).sort(([a], [b]) => a.localeCompare(b)),
)
// 配置维度行(仅名称/权重/状态)
const dims = computed(() =>
Object.entries(defs.value?.score_config.dimensions ?? {}).map(([key, d]) => ({
key,
name: d.name,
weight: d.weight,
status: d.status,
})),
)
const guardsText = computed(() => {
const g = defs.value?.score_config.sample_guards ?? {}
return Object.entries(g).map(([k, v]) => `${k} ≥ ${v}`).join(' · ') || '--'
})
const gradesText = computed(() => {
const gb = defs.value?.score_config.grade_bounds ?? {}
return Object.entries(gb).map(([g, v]) => `${g} ≥ ${v}`).join(' · ') || '--'
})
</script>
<style scoped>
.defs-alert {
margin-bottom: 12px;
}
.caliber-text {
font-size: 12px;
line-height: 1.6;
white-space: normal;
}
.cfg-card {
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
}
.cfg-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.cfg-row {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
.dims-table {
margin-top: 8px;
}
.def-item {
padding: 10px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.def-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.def-code {
font-size: 12px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-light);
padding: 1px 6px;
border-radius: 3px;
}
.def-unit {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.def-formula {
margin: 6px 0 0;
padding: 6px 10px;
font-size: 12px;
background: var(--el-fill-color-light);
border-radius: 4px;
white-space: pre-wrap;
word-break: break-all;
}
.def-caliber {
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.6;
}
</style>
@@ -0,0 +1,369 @@
<template>
<el-drawer
:model-value="modelValue"
:title="card?.title ?? '指标下钻'"
direction="rtl"
size="760px"
@update:model-value="onVisible"
>
<!-- 无 metric_code 或 drilldown.type==='employee':员工维度异常,下钻
明细属批次D 员工分析页——先给原因/建议动作兜底 -->
<template v-if="unsupported">
<el-empty description="员工分析页批次D上线,此类异常先看建议动作" :image-size="80" />
<div v-if="card" class="fallback">
<div class="fallback-label">原因</div>
<div class="fallback-text">{{ card.reason }}</div>
<div class="fallback-label">建议动作</div>
<div class="fallback-text">{{ card.suggested_action }}</div>
</div>
</template>
<template v-else>
<el-alert v-if="error" type="error" :title="error" :closable="false" class="drill-alert" />
<el-alert v-if="resp?.note" type="info" :title="resp.note" :closable="false" class="drill-alert" />
<el-alert
v-if="resp?.truncated"
type="warning"
title="候选超500,列表可能不全"
:closable="false"
class="drill-alert"
/>
<!-- call_followup:期聚合摘要(期通话/接通/跟进) -->
<div v-if="kind === 'calls' && resp?.summary" class="call-summary">
<div class="sum-item">
<span class="sum-num">{{ resp.summary.call_count ?? 0 }}</span>
<span class="sum-label">期通话</span>
</div>
<div class="sum-item">
<span class="sum-num">{{ resp.summary.answered_call_count ?? 0 }}</span>
<span class="sum-label">接通</span>
</div>
<div class="sum-item">
<span class="sum-num">{{ resp.summary.call_followup_count ?? 0 }}</span>
<span class="sum-label">跟进</span>
</div>
</div>
<!-- 首响分位:逐串证据行(后端已按 fr_ms desc),行 click 跳聊天 -->
<el-table
v-if="kind === 'first_reply'"
v-loading="loading"
:data="frRows"
:row-class-name="clickableRow"
@row-click="onFrRowClick"
>
<el-table-column prop="peer_id" label="客户" min-width="140" show-overflow-tooltip />
<el-table-column label="渠道" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.channel === 'whatsapp' ? 'success' : 'primary'">
{{ chTag(row.channel) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="串末条时间" width="170">
<template #default="{ row }">{{ formatTime(row.thread_end_ts) }}</template>
</el-table-column>
<el-table-column label="回复时间" width="170">
<template #default="{ row }">{{ formatTime(row.reply_ts) }}</template>
</el-table-column>
<el-table-column label="首响时长" align="right" min-width="110">
<template #default="{ row }">{{ formatMsDuration(row.fr_ms) }}</template>
</el-table-column>
</el-table>
<!-- 未回串:等待中的客户,行 click 跳聊天(带 msgid 定位最后一条客户消息) -->
<el-table
v-else-if="kind === 'unreplied'"
v-loading="loading"
:data="unrepliedRows"
:row-class-name="clickableRow"
@row-click="onUnrepliedRowClick"
>
<el-table-column prop="peer_id" label="客户" min-width="140" show-overflow-tooltip />
<el-table-column label="渠道" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.channel === 'whatsapp' ? 'success' : 'primary'">
{{ chTag(row.channel) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="等待时长" width="140">
<template #default="{ row }">{{ formatMsDuration(row.wait_ms) }}</template>
</el-table-column>
<el-table-column label="预览" min-width="220" show-overflow-tooltip>
<template #default="{ row }">{{ row.preview ?? '' }}</template>
</el-table-column>
</el-table>
<!-- 降温/沉睡客户名单。lifecycle 行没有 owner_account_id(互动史按
(channel,peer) 跨 owner 合并,归属不定)——不提供跳转,批次D 员工
分析页补员工账号维度的直达入口 -->
<el-table v-else-if="kind === 'lifecycle'" v-loading="loading" :data="lifecycleRows">
<el-table-column prop="nickname" label="客户" min-width="160" show-overflow-tooltip />
<el-table-column label="渠道" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.channel === 'whatsapp' ? 'success' : 'primary'">
{{ chTag(row.channel) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="最后互动日" width="120">
<template #default="{ row }">{{ fmtDay(row.last_day) }}</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row.state === 'dormant'" size="small" type="danger">沉睡</el-tag>
<el-tag v-else-if="row.state === 'cooling'" size="small" type="warning">降温</el-tag>
<el-tag v-else size="small" type="info">{{ row.state }}</el-tag>
</template>
</el-table-column>
</el-table>
<!-- 通话跟进:窗口通话列表(通话无渠道过滤维度,后端恒全渠道) -->
<el-table v-else-if="kind === 'calls'" v-loading="loading" :data="callRows">
<el-table-column label="时间" width="180">
<template #default="{ row }">{{ formatTime(row.start_call_time) }}</template>
</el-table-column>
<el-table-column label="类型" width="120">
<template #default="{ row }">{{ callTypeLabel(row.channel) }}</template>
</el-table-column>
<el-table-column label="号码" min-width="140">
<template #default="{ row }">{{ row.call_number ?? '--' }}</template>
</el-table-column>
<el-table-column label="时长(秒)" align="right" min-width="100">
<template #default="{ row }">{{ formatCallDuration(row.call_duration) }}</template>
</el-table-column>
</el-table>
<el-pagination
:current-page="page"
:page-size="PAGE_SIZE"
:total="resp?.total ?? 0"
layout="total, prev, pager, next"
class="pager"
@current-change="onPageChange"
/>
</template>
</el-drawer>
</template>
<script setup lang="ts">
// 指标下钻抽屉(批次C):点异常卡按 metric_code 调 drilldown 白名单端点,
// 四种明细形态——首响逐串证据/未回串/降温·沉睡名单/通话跟进。抽屉自治
// 加载(打开即取数,后端分页);员工维度异常(drilldown.type==='employee')
// 无 metric_code,给批次D 占位 + 原因/建议动作兜底。行 click 跳聊天:
// WA→ChatTimeline(/chat/whatsapp/{owner}/{peer}),微信→员工聊天窗 single
// tab(/staff-chat/{owner}/single/{peer},项目惯例路由名跳转,路径等价)。
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
getEmployeeDrilldown,
type AnomalyCard,
type DrilldownCode,
type DrilldownResp,
type MgmtChannel,
} from '@/api/management'
import { callTypeLabel, formatCallDuration, formatMsDuration, formatTime } from '@/utils/format'
const props = defineProps<{
modelValue: boolean
userId: string
card: AnomalyCard | null
start: number
end: number
channel: MgmtChannel
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
}>()
const router = useRouter()
const PAGE_SIZE = 20
const page = ref(1)
const loading = ref(false)
const error = ref('')
const resp = ref<DrilldownResp | null>(null)
let seq = 0 // 竞态守卫:快速换卡/翻页时旧响应作废(项目惯例)
// drilldown 白名单(与后端 _DRILLDOWN_CODES 一致;白名单外按不支持兜底)
const DRILL_CODES: readonly string[] = [
'first_reply_p50', 'first_reply_p90', 'unreplied',
'cooling_customers', 'dormant_customers', 'call_followup',
]
const metricCode = computed<DrilldownCode | null>(() => {
const c = props.card?.drilldown.query.metric_code
return c && DRILL_CODES.includes(c) ? (c as DrilldownCode) : null
})
// 员工维度异常(无 metric_code / type==='employee')→ 批次D 占位兜底
const unsupported = computed(
() => !metricCode.value || props.card?.drilldown.type === 'employee',
)
type Kind = 'first_reply' | 'unreplied' | 'lifecycle' | 'calls'
const kind = computed<Kind | null>(() => {
switch (metricCode.value) {
case 'first_reply_p50':
case 'first_reply_p90':
return 'first_reply'
case 'unreplied':
return 'unreplied'
case 'cooling_customers':
case 'dormant_customers':
return 'lifecycle'
case 'call_followup':
return 'calls'
default:
return null
}
})
// 打开(或换卡重开)即取数;start/end/channel 变化由父级在抽屉关闭时发生,
// 下次打开经 visible 变化自然重查
watch(
() => [props.modelValue, props.card] as const,
([visible]) => {
if (visible && !unsupported.value) {
page.value = 1
load()
}
},
)
async function load(): Promise<void> {
if (!metricCode.value) return
const my = ++seq
loading.value = true
error.value = ''
try {
const r = await getEmployeeDrilldown(
props.userId, metricCode.value, props.start, props.end,
props.channel, page.value, PAGE_SIZE,
)
if (my !== seq) return
resp.value = r
} catch (e) {
if (my === seq) error.value = e instanceof Error ? e.message : String(e)
} finally {
// 过期请求不清 loading(新一轮还在飞)也不覆盖 error
if (my === seq) loading.value = false
}
}
// 分页翻页:vue-tsc strict 不推 inline-arrow 参数,单列包装(项目惯例)
function onPageChange(p: number): void {
page.value = p
load()
}
function onVisible(v: boolean): void {
emit('update:modelValue', v)
}
// ---- 各分支行类型(items 是 Record<string,unknown>[],按白名单端点形状收窄) ----
type FrRow = {
day: number; channel: string; owner_account_id: string; peer_id: string
thread_end_ts: number; reply_ts: number; fr_ms: number
}
type UnrepliedRow = {
peer_id: string; channel: string; owner_account_id: string
last_ts: number; wait_ms: number; preview?: string; msg_id?: string
}
type LifecycleRow = { friend_id: string; channel: string; nickname: string; last_day: number; state: string }
type CallRow = { call_id: string; channel: string; call_number: string | null; start_call_time: number; call_duration: number }
const frRows = computed<FrRow[]>(() =>
kind.value === 'first_reply' ? (resp.value?.items ?? []) as FrRow[] : [])
const unrepliedRows = computed<UnrepliedRow[]>(() =>
kind.value === 'unreplied' ? (resp.value?.items ?? []) as UnrepliedRow[] : [])
const lifecycleRows = computed<LifecycleRow[]>(() =>
kind.value === 'lifecycle' ? (resp.value?.items ?? []) as LifecycleRow[] : [])
const callRows = computed<CallRow[]>(() =>
kind.value === 'calls' ? (resp.value?.items ?? []) as CallRow[] : [])
// ---- 展示辅助 ----
// 渠道短标签:异常下钻列窄,微信/WA 两字(全称 channelLabel 留给宽列页面)
const CHANNEL_TAG: Record<string, string> = { wechat: '微信', whatsapp: 'WA' }
const chTag = (c: string): string => CHANNEL_TAG[c] ?? c
// yyyymmdd(20260810)→ 2026-08-10:最后互动日跨月/年,保留完整年份
const fmtDay = (d: number): string =>
`${String(d).slice(0, 4)}-${String(d).slice(4, 6)}-${String(d).slice(6, 8)}`
// ---- 行点击跳聊天 ----
function goChat(owner: string, peer: string, channel: string, msgId?: string): void {
if (channel === 'whatsapp') {
router.push({ name: 'chat', params: { channel: 'whatsapp', ownerId: owner, talkerId: peer } })
return
}
router.push({
name: 'staff-chat',
params: { ownerId: owner, tab: 'single', talkerId: peer },
query: msgId ? { msgid: msgId } : undefined,
})
}
// owner 理论恒非空(fact/未回清单均来自 owner 维度查询),防御判空不亏
function clickableRow({ row }: { row: { owner_account_id?: string } }): string {
return row.owner_account_id ? 'yk-clickable-row' : ''
}
function onFrRowClick(row: FrRow): void {
goChat(row.owner_account_id, row.peer_id, row.channel)
}
// 微信跳转带 ?msgid= 定位最后一条客户消息(同 UnrepliedView 惯例)
function onUnrepliedRowClick(row: UnrepliedRow): void {
if (!row.owner_account_id) return
goChat(row.owner_account_id, row.peer_id, row.channel, row.msg_id)
}
</script>
<style scoped>
.drill-alert {
margin-bottom: 12px;
}
.call-summary {
display: flex;
gap: 32px;
margin-bottom: 12px;
}
.sum-item {
display: flex;
flex-direction: column;
align-items: center;
}
.sum-num {
font-size: 22px;
font-weight: 600;
}
.sum-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.fallback {
margin-top: 8px;
}
.fallback-label {
font-size: 12px;
color: var(--el-text-color-secondary);
margin: 12px 0 4px;
}
.fallback-text {
font-size: 13px;
color: var(--el-text-color-primary);
}
.pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,139 @@
// useManagementFilters 单测(vitest)。本文件不进 app 构建/类型检查
// (tsconfig.app.json exclude __tests__,vitest 自行解析类型),
// `npm run test` 执行。
//
// composable 依赖 vue-router 注入,仓库无先例;node 环境(无 DOM),
// 选用 createApp + app.use(router) + app.runWithContext() 提供真实
// route/router 注入,不 mock、不挂载组件。
import { describe, expect, it, vi } from 'vitest'
import { createApp } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { presetRange, useManagementFilters } from '../useManagementFilters'
/** 建一个带 memory 路由的 filters 实例;query 为初始 URL query。 */
async function setup(query: Record<string, string> = {}) {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { render: () => null } }],
})
const app = createApp({ render: () => null })
app.use(router)
await router.push({ path: '/', query })
const filters = app.runWithContext(() => useManagementFilters())
return { router, filters }
}
const D = (ymdNum: number) => new Date(Math.floor(ymdNum / 10000), (Math.floor(ymdNum / 100) % 100) - 1, ymdNum % 100)
describe('presetRange(注入 today,确定性日期)', () => {
it('last7 → 含今天共 7 天(2026-09-10 → 20260904..20260910)', () => {
expect(presetRange('last7', D(20260910))).toEqual({ start: 20260904, end: 20260910 })
})
it('last30 → 30 天窗(2026-09-10 → 20260812..20260910)', () => {
expect(presetRange('last30', D(20260910))).toEqual({ start: 20260812, end: 20260910 })
})
it('thisMonth → 月初到今天(2026-09-10 → 20260901..20260910)', () => {
expect(presetRange('thisMonth', D(20260910))).toEqual({ start: 20260901, end: 20260910 })
})
it('lastMonth → 上月整月(2026-08-15 → 20260701..20260731,31 天月)', () => {
expect(presetRange('lastMonth', D(20260815))).toEqual({ start: 20260701, end: 20260731 })
})
it('lastMonth 30 天月(2026-07-15 → 20260601..20260630)', () => {
expect(presetRange('lastMonth', D(20260715))).toEqual({ start: 20260601, end: 20260630 })
})
it('lastMonth 跨年(2026-01-15 → 20251201..20251231)', () => {
expect(presetRange('lastMonth', D(20260115))).toEqual({ start: 20251201, end: 20251231 })
})
it('today → 今天单日(2026-09-10 → 20260910..20260910)', () => {
expect(presetRange('today', D(20260910))).toEqual({ start: 20260910, end: 20260910 })
})
it('yesterday → 昨日单日(2026-09-10 → 20260909..20260909)', () => {
expect(presetRange('yesterday', D(20260910))).toEqual({ start: 20260909, end: 20260909 })
})
it('thisWeek 周中(2026-07-02 周四 → 周一到今天 20260629..20260702)', () => {
expect(presetRange('thisWeek', D(20260702))).toEqual({ start: 20260629, end: 20260702 })
})
it('thisWeek 周一当天 → 单日(2026-06-29 周一 → 20260629..20260629)', () => {
expect(presetRange('thisWeek', D(20260629))).toEqual({ start: 20260629, end: 20260629 })
})
it('lastWeek 跨月边界(2026-07-02 周四 → 上周一..上周日 20260622..20260628)', () => {
expect(presetRange('lastWeek', D(20260702))).toEqual({ start: 20260622, end: 20260628 })
})
it('custom → 今天单日兜底(2026-09-10 → 20260910..20260910)', () => {
expect(presetRange('custom', D(20260910))).toEqual({ start: 20260910, end: 20260910 })
})
})
describe('useManagementFilters(URL query 为真源)', () => {
it('空 query → channel=all / start,end=last30 默认 / compare=previous', async () => {
const { filters } = await setup()
expect(filters.channel.value).toBe('all')
expect(filters.compare.value).toBe('previous')
// 默认值与 presetRange('last30')(真实今天)同源,同日执行必一致
expect(filters.start.value).toBe(presetRange('last30').start)
expect(filters.end.value).toBe(presetRange('last30').end)
})
it('合法 query → 原样读出(wechat / 日期 / team_median)', async () => {
const { filters } = await setup({
channel: 'wechat',
start: '20250101',
end: '20250131',
compare: 'team_median',
})
expect(filters.channel.value).toBe('wechat')
expect(filters.start.value).toBe(20250101)
expect(filters.end.value).toBe(20250131)
expect(filters.compare.value).toBe('team_median')
})
it('非法 query 值 → 回退默认(channel=xx / start=abc / end=19990101 早于下限 / compare=foo)', async () => {
const { filters } = await setup({ channel: 'xx', start: 'abc', end: '19990101', compare: 'foo' })
expect(filters.channel.value).toBe('all')
expect(filters.compare.value).toBe('previous')
expect(filters.start.value).toBe(presetRange('last30').start)
expect(filters.end.value).toBe(presetRange('last30').end)
})
it('apply({channel}) → query 更新且其余参数保留', async () => {
const { router, filters } = await setup({ compare: 'team_median' })
filters.apply({ channel: 'whatsapp' })
await vi.waitFor(() => expect(router.currentRoute.value.query.channel).toBe('whatsapp'))
expect(filters.channel.value).toBe('whatsapp')
// 未 patch 的 compare 原样保留
expect(router.currentRoute.value.query.compare).toBe('team_median')
expect(filters.compare.value).toBe('team_median')
})
it('applyPreset(预设) → 清掉 query 里的 start/end,默认值(仍为 last30)生效', async () => {
const { router, filters } = await setup({ start: '20250101', end: '20250131', channel: 'wechat' })
filters.applyPreset('last7')
await vi.waitFor(() => expect(router.currentRoute.value.query.start).toBeUndefined())
expect(router.currentRoute.value.query.end).toBeUndefined()
// channel 不在清理范围,保留
expect(filters.channel.value).toBe('wechat')
// 清掉后回退默认(last30,与真实今天同源)
expect(filters.start.value).toBe(presetRange('last30').start)
expect(filters.end.value).toBe(presetRange('last30').end)
})
it("applyPreset('custom', {...}) → query 带具体日期", async () => {
const { router, filters } = await setup()
filters.applyPreset('custom', { start: 20260101, end: 20260131 })
await vi.waitFor(() => expect(router.currentRoute.value.query.start).toBe('20260101'))
expect(router.currentRoute.value.query.end).toBe('20260131')
expect(filters.start.value).toBe(20260101)
expect(filters.end.value).toBe(20260131)
})
})
@@ -0,0 +1,91 @@
// 管理页统一筛选(URL query 为真源——刷新/分享保持)。批次C:驾驶舱用;
// 批次D 员工页复用(view 等额外参数由页面自行挂 query)。
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
export type Preset = 'today' | 'yesterday' | 'last7' | 'last30' | 'thisWeek' | 'lastWeek' | 'thisMonth' | 'lastMonth' | 'custom'
export const CHANNELS = [
{ value: 'all', label: '全部渠道' },
{ value: 'wechat', label: '微信' },
{ value: 'whatsapp', label: 'WhatsApp' },
] as const
function ymd(d: Date): number {
return d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate()
}
function addDays(d: Date, n: number): Date {
const x = new Date(d)
x.setDate(x.getDate() + n)
return x
}
/** 预设 → [start,end] yyyymmdd。 */
export function presetRange(p: Preset, today = new Date()): { start: number; end: number } {
const t0 = new Date(today.getFullYear(), today.getMonth(), today.getDate())
// 周一为一周起点(getDay 周日=0 → 偏移成周一=0..周日=6)
const dow = (t0.getDay() + 6) % 7
switch (p) {
case 'today':
return { start: ymd(t0), end: ymd(t0) }
case 'yesterday':
return { start: ymd(addDays(t0, -1)), end: ymd(addDays(t0, -1)) }
case 'thisWeek':
// 周一..今天;周一当天即单日
return { start: ymd(addDays(t0, -dow)), end: ymd(t0) }
case 'lastWeek': {
const monday = addDays(t0, -dow - 7)
return { start: ymd(monday), end: ymd(addDays(monday, 6)) }
}
case 'last7':
return { start: ymd(addDays(t0, -6)), end: ymd(t0) }
case 'last30':
return { start: ymd(addDays(t0, -29)), end: ymd(t0) }
case 'thisMonth':
return { start: ymd(new Date(t0.getFullYear(), t0.getMonth(), 1)), end: ymd(t0) }
case 'lastMonth': {
const first = new Date(t0.getFullYear(), t0.getMonth() - 1, 1)
const last = new Date(t0.getFullYear(), t0.getMonth(), 0)
return { start: ymd(first), end: ymd(last) }
}
}
// custom/未知:今天单日
return { start: ymd(t0), end: ymd(t0) }
}
export function useManagementFilters() {
const route = useRoute()
const router = useRouter()
const channel = computed<'all' | 'wechat' | 'whatsapp'>(() => {
const v = route.query.channel
return v === 'wechat' || v === 'whatsapp' ? v : 'all'
})
const start = computed(() => {
const v = Number(route.query.start)
return Number.isFinite(v) && v >= 20000101 && v <= 99991231 ? v : presetRange('last30').start
})
const end = computed(() => {
const v = Number(route.query.end)
return Number.isFinite(v) && v >= 20000101 && v <= 99991231 ? v : presetRange('last30').end
})
const compare = computed(() => {
const v = route.query.compare
return v === 'personal_baseline' || v === 'team_median' ? v : 'previous'
})
function apply(patch: Record<string, string | number | undefined>) {
const q: Record<string, string> = {}
for (const [k, v] of Object.entries({ ...route.query, ...patch })) {
if (v !== undefined && v !== null && v !== '') q[k] = String(v)
}
router.replace({ query: q })
}
// 预设切换:清掉 start/end 让默认值生效,custom 则带具体日期
function applyPreset(p: Preset, custom?: { start: number; end: number }) {
if (p === 'custom' && custom) apply({ start: custom.start, end: custom.end })
else apply({ start: undefined, end: undefined })
}
return { channel, start, end, compare, apply, applyPreset, presetRange }
}
+15
View File
@@ -11,9 +11,11 @@ const channelStore = useChannelStore()
// `el-menu` with `router` mode; we drive `default-active` from the current path
// so the active item highlights on load and on browser back/forward.
// 带参路由(路径与菜单 index 不等)按 route.name 映射父菜单路径。
const activeMenu = computed(() => {
if (route.name === 'chat') return '/customers'
if (route.name === 'group-chat') return '/groups'
if (route.name === 'management-employees-detail') return '/management/employees'
return route.path
})
@@ -99,6 +101,19 @@ function logout(): void {
全局搜索
</el-menu-item>
<!-- 经营管理 -->
<el-sub-menu index="management">
<template #title>
<span class="yk-sidenav__group">经营管理</span>
</template>
<el-menu-item index="/management/dashboard" @click="goPlain('/management/dashboard')">
管理驾驶舱
</el-menu-item>
<el-menu-item index="/management/employees" @click="goPlain('/management/employees')">
员工分析
</el-menu-item>
</el-sub-menu>
<!-- 数据分析 -->
<el-sub-menu index="analytics">
<template #title>
+25
View File
@@ -92,6 +92,31 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/analytics/SensitiveView.vue'),
meta: { title: '敏感词' },
},
{
// 经营管理板块(批次C):驾驶舱;员工分析/考核快照批次D/E再加
path: '/management/dashboard',
name: 'management-dashboard',
component: () => import('@/views/management/ManagementDashboardView.vue'),
meta: { title: '管理驾驶舱' },
},
{
// 员工分析(批次D):四视图总览;详情路由 /management/employees/{user_id}
// 由批次D后续任务补挂(D2)
path: '/management/employees',
name: 'management-employees',
component: () => import('@/views/management/EmployeeAnalyticsView.vue'),
meta: { title: '员工分析' },
},
{
// 员工详情(批次D2):总览页姓名下钻;query 真源 channel/start/end/section
// (section=当前页签,刷新回到该页签)。静态路由 /management/employees
// 先注册,带参路由在后——vue-router 按注册序精确匹配,静态优先命中。
path: '/management/employees/:userId',
name: 'management-employees-detail',
component: () => import('@/views/management/EmployeeAnalyticsDetailView.vue'),
props: true,
meta: { title: '员工分析详情' },
},
{
path: '/chat/:channel/:ownerId/:talkerId?',
name: 'chat',
+11 -2
View File
@@ -10,6 +10,9 @@
* ],
* filename: '微信客户列表',
* })
*
* Optional `preamble: string[]` writes comment lines (口径/窗口注释等) before
* the header — each line occupies the first column of its own row.
*/
export interface CsvColumn<T> {
@@ -30,6 +33,12 @@ export interface ExportOptions<T> {
pageSize?: number
/** Max rows to export (safety cap, default 10000) */
maxRows?: number
/**
* Optional comment lines written before the header row (batch D: management
* employee CSV carries caliber/window notes so the export is self-describing
* outside the app). Each line becomes a single-cell row in the first column.
*/
preamble?: string[]
}
function escapeCsv(value: string | number | null | undefined): string {
@@ -43,7 +52,7 @@ function escapeCsv(value: string | number | null | undefined): string {
}
export async function exportListToCsv<T>(options: ExportOptions<T>): Promise<{ rows: number; filename: string }> {
const { fetchPage, columns, filename, pageSize = 100, maxRows = 10000 } = options
const { fetchPage, columns, filename, pageSize = 100, maxRows = 10000, preamble } = options
// Fetch all pages
const allRows: T[] = []
@@ -68,7 +77,7 @@ export async function exportListToCsv<T>(options: ExportOptions<T>): Promise<{ r
return escapeCsv(val as string | number | null | undefined)
}).join(',')
)
const csv = '' + header + '\n' + lines.join('\n') // BOM for Excel Chinese support
const csv = '' + [...(preamble ?? []).map((p) => escapeCsv(p)), header, ...lines].join('\n') // BOM for Excel Chinese support
// Trigger download
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
+22
View File
@@ -55,6 +55,28 @@ export function formatDuration(seconds?: number | null): string {
return `${m}'${String(rem).padStart(2, '0')}"`
}
// ---------------------------------------------------------------------------
// 经营管理板块共享格式化(批次C)——自 DashboardView 本地 fmtFr/fmtPct 提升,
// 驾驶舱 KPI / 异常榜 / 下钻抽屉与旧看板共用同一展示口径。
// ---------------------------------------------------------------------------
/**
* 毫秒时长 → `<1h 显「X分Y秒」/ ≥1h 显「X小时Y分」`;null(无样本)显 `-`。
* 与 formatDuration(秒,聊天气泡口径)区分:此处是指标口径(首响等)。
*/
export function formatMsDuration(ms?: number | null): string {
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}分`
}
/** 比率(0-1) → 百分比 1 位小数(回复率/接通率等);null 显 `-`。 */
export function formatRatio(r?: number | null): string {
return r == null ? '-' : `${(r * 100).toFixed(1)}%`
}
/** Friendly label for a Channel value. */
export function channelLabel(channel?: string | null): string {
switch (channel) {
+6 -2
View File
@@ -77,7 +77,7 @@ export const GROUP_FIELD_GROUPS: GroupFieldGroup[] = [
},
{
title: '成员',
keys: ['memberCount', 'memberIds'],
keys: ['memberCount'], // memberIds 原始串由群详情成员墙替代(2026-09-10)
},
{
title: '归属微信与群主',
@@ -105,7 +105,11 @@ export const GROUP_FIELD_GROUPS: GroupFieldGroup[] = [
]
/** Every key already surfaced via a group (so 其他 can skip duplicates). */
const KNOWN_KEYS = new Set(GROUP_FIELD_GROUPS.flatMap((g) => g.keys))
const KNOWN_KEYS = new Set([
...GROUP_FIELD_GROUPS.flatMap((g) => g.keys),
// 原始 wxid 串由群详情成员墙替代,不在任何区(含「其他」)显示(2026-09-10)
'memberIds',
])
/** Resolve the 中文 label for a raw_json key (falls back to the key). */
export function fieldLabel(key: string): string {
+11 -1
View File
@@ -53,6 +53,13 @@ const channelTyped = computed(() => props.channel as Channel)
/** True when this timeline is a group chat (drives the query + title). */
const isGroup = computed(() => !!props.roomId)
/**
* 专注模式(?bare=1):群统计等列表入口的「查看详情」——隐藏会话侧栏,
* 对齐云客群详情页(无好友/群列表,纯聊天记录窗)。员工聊天窗口入口
* (不带 bare)侧栏常驻(2026-09-09 修复保持)。
*/
const isBare = computed(() => route.query.bare === '1')
/** 当前会话类型(高亮 sidebar + 驱动空态)。账号级入口(无 talkerId/roomId)→ null。 */
const activeType = computed<'friend' | 'group' | null>(() =>
isGroup.value ? 'group' : props.talkerId ? 'friend' : null,
@@ -344,8 +351,11 @@ onMounted(() => {
<div class="yk-chat__body">
<!-- 侧栏常驻(含群聊路由):好友/群列表随时可切,对齐云客聊天窗体验。
旧版 v-if="!isGroup" 在群路由下藏侧栏,点群后「好友列表/群列表」
消失(2026-09-09 用户报);onSelectSession 本就双类型导航。 -->
消失(2026-09-09 用户报);onSelectSession 本就双类型导航。
bare 模式(群统计「查看详情」入口)例外——纯聊天记录窗无侧栏,
对齐云客群详情页(2026-09-10 用户反馈)。 -->
<ChatSidebar
v-if="!isBare"
:channel="channelTyped"
:owner-id="props.ownerId"
:active-type="activeType"
+228 -56
View File
@@ -3,14 +3,12 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Download } from '@element-plus/icons-vue'
import { getGroups } from '@/api'
import { downloadBlob } from '@/api/client'
import type { Channel, Group } from '@/api/types'
import { getAccounts, getGroupMembers, getGroups } from '@/api'
import type { Account, Channel, Group, GroupMember } from '@/api/types'
import { useChannelStore } from '@/stores/channel'
import { channelLabel, formatTime } from '@/utils/format'
import { exportListToCsv } from '@/utils/csvExport'
import { buildGroupSections, readField } from '@/utils/groupFields'
import { fromTypeLabel } from '@/utils/customerFields'
/**
* 群列表 — channel-tabbed view mirroring 云客「沟通」板块的群列表.
@@ -84,10 +82,6 @@ function cellNum(row: Group, camelKey: string, fallback = 0): number {
const v = cell(row, camelKey)
return typeof v === 'number' && Number.isFinite(v) ? v : fallback
}
/** 数据来源列:fromType 代码→文案(复用 customerFields.fromTypeLabel)。 */
function fromTypeText(row: Group): string {
return fromTypeLabel(cell(row, 'fromType'))
}
function imageCell(row: Group, camelKey: string): string | undefined {
const v = cell(row, camelKey)
return typeof v === 'string' && v !== '' ? v : undefined
@@ -108,7 +102,16 @@ async function loadTab(ch: TabKey): Promise<void> {
channel: ch,
page: st.page,
page_size: st.pageSize,
owner_account_id: ownerId.value ?? undefined,
owner_account_id: ch === 'wechat' ? (staffOwner.value || ownerId.value) || undefined : ownerId.value ?? undefined,
// 微信群统计筛选(云客「微信群统计」同款,2026-09-09)
...(ch === 'wechat'
? {
q: gName.value.trim() || undefined,
group_remark: gRemark.value.trim() || undefined,
...(lastRange.value ? { last_start: dayStart(lastRange.value[0]), last_end: dayEnd(lastRange.value[1]) } : {}),
...(statRange.value ? { msg_start: dayStart(statRange.value[0]), msg_end: dayEnd(statRange.value[1]) } : {}),
}
: {}),
})
st.items = res.items
st.total = res.total
@@ -120,6 +123,47 @@ async function loadTab(ch: TabKey): Promise<void> {
st.loading = false
}
}
// ---- 微信群统计筛选(云客同款:统计时段/最新消息时间段/群名称/群备注/员工) ----
function defaultStatRange(): [Date, Date] {
// 云客默认近 7 天(截图 09-03→09-09)
const end = new Date()
const start = new Date(end.getTime() - 6 * 86_400_000)
return [start, end]
}
const statRange = ref<[Date, Date] | null>(defaultStatRange())
const lastRange = ref<[Date, Date] | null>(null)
const gName = ref('')
const gRemark = ref('')
/** 员工筛选(下拉;入口 query 的 owner_account_id 作初值,选择后接管)。 */
const staffOwner = ref<string>(ownerId.value ?? '')
const staffOptions = ref<Account[]>([])
function dayStart(d: Date): number {
const x = new Date(d)
x.setHours(0, 0, 0, 0)
return x.getTime()
}
function dayEnd(d: Date): number {
const x = new Date(d)
x.setHours(23, 59, 59, 999)
return x.getTime()
}
async function loadStaffOptions(): Promise<void> {
try {
const res = await getAccounts({ channel: 'wechat', page: 1, page_size: 100 })
staffOptions.value = res.items
if (ownerId.value && !staffOwner.value) staffOwner.value = ownerId.value
} catch {
// 静默降级:员工筛选为空
}
}
function onSearch(): void {
tabState.wechat.page = 1
loadTab('wechat')
}
function handlePageChange(ch: TabKey, p: number): void {
tabState[ch].page = p
loadTab(ch)
@@ -145,6 +189,9 @@ function viewChat(row: Group): void {
router.push({
name: 'group-chat',
params: { channel: row.channel, ownerId: row.owner_account_id, roomId: row.room_id },
// bare=专注模式:无会话侧栏,对齐云客群详情页(2026-09-10)。
// 员工聊天窗口入口不带 bare,侧栏常驻。
query: row.channel === 'wechat' ? { bare: '1' } : undefined,
})
}
/** drawer 内「查看聊天记录」入口:drawerGroup 是 ref<Group|null>,模板嵌套不保证窄化,用 wrapper 守卫。 */
@@ -154,28 +201,6 @@ function viewChatFromDrawer(): void {
viewChat(row)
}
/**
* 微信群「导出HTML聊天记录」→ 后端 /api/export/chat-html(room_id)。
* 云客群列表导出按钮直接导出,无时间选择 modal(仅微信客户有「请选择时间」)。
* WA 群 tab 无此操作(云客 WA 群列表无导出)。
*/
async function exportGroupHtml(row: Group): Promise<void> {
if (!row.owner_account_id || !row.room_id) {
ElMessage.warning('该群缺少归属账号/群标识,无法导出')
return
}
try {
await downloadBlob('/export/chat-html', {
channel: row.channel,
owner_account_id: row.owner_account_id,
room_id: row.room_id,
})
ElMessage.success('导出已开始')
} catch (e) {
ElMessage.error(`导出失败,请稍后重试:${(e as Error).message}`)
}
}
// ---- header 导出:当前 tab 渠道全量群 CSV(含当前员工筛选) ----
const exporting = ref(false)
@@ -222,9 +247,35 @@ function clearOwnerFilter(): void {
}
// ---- drawer ----
// 群成员墙(2026-09-10):点开群详情拉 members,渲染头像+昵称(消息派生群昵称
// 优先,customer 好友昵称兜底,都没有回退 wxid)。
const drawerMembers = ref<GroupMember[]>([])
const membersLoading = ref(false)
async function loadDrawerMembers(roomId: string): Promise<void> {
drawerMembers.value = []
membersLoading.value = true
try {
drawerMembers.value = await getGroupMembers(roomId)
} catch {
drawerMembers.value = [] // 静默:成员区空态
} finally {
membersLoading.value = false
}
}
function memberLabel(m: GroupMember): string {
return m.nickname || m.display_name || m.member_id
}
function openDrawer(row: Group): void {
drawerGroup.value = row
drawerOpen.value = true
if (row.channel === 'wechat') {
loadDrawerMembers(row.room_id)
} else {
drawerMembers.value = []
}
}
const drawerSections = computed(() =>
drawerGroup.value ? buildGroupSections(drawerGroup.value) : [],
@@ -262,6 +313,7 @@ watch(
)
onMounted(() => {
loadStaffOptions()
loadTab(activeTab.value)
})
</script>
@@ -287,9 +339,52 @@ onMounted(() => {
</div>
<!-- ============================================================
微信群 — 7 列,对齐云客 wechatdetail/2/{wxId}.
微信群统计 — 对齐云客「沟通→微信号管理→微信群统计」
(/cms/wechat/wechatgroup,2026-09-09 真机对齐)。
统计时段窗内:沟通消息数=本地日汇总;新增/退出成员数=按天进出表
求和(上线日起积累,历史日期为 0)。
============================================================ -->
<template v-if="activeTab === 'wechat'">
<div class="yk-groups__statfilters">
<div class="yk-groups__filterrow">
<span class="yk-groups__flabel">数据统计时段:</span>
<el-date-picker
v-model="statRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="→"
start-placeholder="开始日期"
end-placeholder="结束日期"
size="small"
style="width: 260px"
/>
<span class="yk-groups__flabel" style="margin-left: 16px">最新消息时间段:</span>
<el-date-picker
v-model="lastRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="→"
start-placeholder="开始日期"
end-placeholder="结束日期"
size="small"
style="width: 260px"
clearable
/>
</div>
<div class="yk-groups__filterrow">
<el-input v-model="gName" placeholder="请输入群名称" size="small" clearable style="width: 180px" @keyup.enter="onSearch" />
<el-input v-model="gRemark" placeholder="请输入群备注" size="small" clearable style="width: 180px" @keyup.enter="onSearch" />
<el-select v-model="staffOwner" placeholder="员工微信昵称" size="small" clearable filterable style="width: 200px">
<el-option
v-for="a in staffOptions"
:key="a.account_id"
:label="a.nickname || a.user_name || a.account_id"
:value="a.account_id"
/>
</el-select>
<el-button type="primary" size="small" @click="onSearch">查 询</el-button>
</div>
</div>
<el-alert
v-if="tabState.wechat.errorMsg"
:title="tabState.wechat.errorMsg"
@@ -309,40 +404,55 @@ onMounted(() => {
empty-text="暂无微信群数据"
@row-click="openDrawer"
>
<el-table-column label="群信息" min-width="180">
<template #default="{ row }">
<div class="yk-staffinfo__nick" :title="cellText(row, 'chatRoomName')">{{ cellText(row, 'chatRoomName') }}</div>
</template>
</el-table-column>
<el-table-column label="数据来源" min-width="110">
<template #default="{ row }">{{ fromTypeText(row) }}</template>
</el-table-column>
<el-table-column label="群主信息" min-width="180">
<el-table-column label="群信息" min-width="200">
<template #default="{ row }">
<div class="yk-group-owner">
<el-avatar :size="36" :src="imageCell(row, 'ownerImage') || undefined" shape="circle">
{{ (cellText(row, 'ownerName') || '?').slice(0, 1) }}
<el-avatar :size="34" :src="row.head_image_url || undefined" shape="circle">
{{ (row.room_name || '?').slice(0, 1) }}
</el-avatar>
<div class="yk-group-owner__meta">
<div class="yk-staffinfo__nick">{{ cellText(row, 'ownerName') }}</div>
<div class="yk-staffinfo__muted">{{ cellText(row, 'realName') }}</div>
<div class="yk-staffinfo__nick">{{ row.room_name || '--' }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="群成员数量" width="110" align="right">
<template #default="{ row }">{{ cellNum(row, 'memberCount') }}</template>
<el-table-column label="群备注" min-width="110">
<template #default="{ row }">{{ row.group_remark || '-' }}</template>
</el-table-column>
<el-table-column label="群成员好友数量" width="130" align="right">
<template #default="{ row }">{{ cellNum(row, 'friendCount') }}</template>
</el-table-column>
<el-table-column label="消息数量" width="100" align="right">
<template #default="{ row }">{{ cellNum(row, 'messageCount') }}</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right" align="center">
<el-table-column label="员工微信" min-width="170">
<template #default="{ row }">
<el-button link type="primary" size="small" @click.stop="viewChat(row)">查看聊天记录</el-button>
<el-button link type="primary" size="small" @click.stop="exportGroupHtml(row)">导出HTML聊天记录</el-button>
<div class="yk-group-owner">
<el-avatar :size="34" :src="row.staff_avatar || undefined" shape="square">
{{ (row.staff_nickname || '?').slice(0, 1) }}
</el-avatar>
<div class="yk-group-owner__meta">
<div class="yk-staffinfo__nick">{{ row.staff_nickname || '--' }}</div>
<div class="yk-staffinfo__muted">{{ row.staff_wx_alias || row.owner_account_id || '' }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="员工信息" min-width="150">
<template #default="{ row }">
<div class="yk-staffinfo__nick">{{ row.staff_name || '--' }}</div>
<div class="yk-staffinfo__muted">{{ row.staff_phone || '' }}</div>
</template>
</el-table-column>
<el-table-column label="群成员数量" width="105" align="right">
<template #default="{ row }">{{ row.member_count ?? '-' }}</template>
</el-table-column>
<el-table-column label="新增成员数" width="105" align="right">
<template #default="{ row }">{{ row.window_added ?? '-' }}</template>
</el-table-column>
<el-table-column label="退出成员数" width="105" align="right">
<template #default="{ row }">{{ row.window_lost ?? '-' }}</template>
</el-table-column>
<el-table-column label="沟通消息数" width="105" align="right">
<template #default="{ row }">{{ row.window_msg_count ?? '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="110" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click.stop="viewChat(row)">查看详情</el-button>
</template>
</el-table-column>
</el-table>
@@ -465,6 +575,26 @@ onMounted(() => {
</div>
</div>
</div>
<!-- 群成员墙:头像+昵称(群昵称>好友昵称兜底>wxid),替代原始 memberIds 串 -->
<div v-if="drawerGroup.channel === 'wechat'" class="yk-drawer__section">
<el-divider content-position="left">
群成员({{ drawerMembers.length || (drawerGroup.member_count ?? 0) }})
</el-divider>
<div v-loading="membersLoading" class="yk-members">
<div v-if="!membersLoading && drawerMembers.length === 0" class="yk-members__empty">
暂无成员数据(群消息派生,发过言/是本司好友的成员可识别)
</div>
<div v-for="m in drawerMembers" :key="m.member_id" class="yk-members__item">
<el-avatar :size="34" :src="m.avatar || undefined">
{{ memberLabel(m).slice(0, 1) }}
</el-avatar>
<div class="yk-members__meta">
<div class="yk-members__name" :title="memberLabel(m)">{{ memberLabel(m) }}</div>
<div v-if="memberLabel(m) !== m.member_id" class="yk-members__id">{{ m.member_id }}</div>
</div>
</div>
</div>
</div>
<div v-for="section in drawerSections" :key="section.title" class="yk-drawer__section">
<el-divider content-position="left">{{ section.title }}</el-divider>
<dl class="yk-drawer__kv">
@@ -513,4 +643,46 @@ onMounted(() => {
.yk-group-owner__meta {
min-width: 0;
}
/* 群成员墙:头像+昵称网格(2026-09-10) */
.yk-members {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
max-height: 320px;
overflow-y: auto;
min-height: 48px;
}
.yk-members__empty {
grid-column: 1 / -1;
color: var(--el-text-color-secondary);
font-size: 12px;
padding: 8px 0;
}
.yk-members__item {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.yk-members__meta {
min-width: 0;
display: flex;
flex-direction: column;
line-height: 1.35;
}
.yk-members__name {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.yk-members__id {
font-size: 11px;
color: var(--el-text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+6 -14
View File
@@ -143,16 +143,16 @@
<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>
<template #default="{ row }">{{ formatMsDuration(row.fr_avg_ms) }}</template>
</el-table-column>
<el-table-column label="回复率" align="right">
<template #default="{ row }">{{ fmtPct(row.reply_rate) }}</template>
<template #default="{ row }">{{ formatRatio(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>
<template #default="{ row }">{{ formatRatio(row.answer_rate) }}</template>
</el-table-column>
<el-table-column label="删粉/拉黑" align="right">
<template #default="{ row }">{{ fmtDelBlocked(row) }}</template>
@@ -187,7 +187,7 @@ import {
type StaffRankingRow,
} from '@/api/analytics'
import { useChart } from '@/composables/useChart'
import { formatTimeShort } from '@/utils/format'
import { formatMsDuration, formatRatio, formatTimeShort } from '@/utils/format'
const router = useRouter()
@@ -295,16 +295,8 @@ const fmt = (n: number | undefined | null) => (n ?? 0).toLocaleString()
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)}%`)
// 首响/百分比用共享版(formatMsDuration/formatRatio,utils/format——批次C
// 驾驶舱组件同口径,原本地 fmtFr/fmtPct 已提升去重)
// 通话时长(秒):<1h 显「X分」,否则「X小时Y分」;0/null 显 '-'
const fmtDur = (s: number | null) => {
if (!s) return '-'
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,726 @@
<template>
<div class="emp-analytics">
<!-- 筛选栏:渠道/时间(URL 真源)+口径入口;aside=对比模式选择器。
compare 仅客户端显示态:employees 行恒带四对比(current/prev/
baseline/median),后端 employees 端点也无 compare 入参 -->
<ManagementFilterBar
:channel="channel"
:start="start"
:end="end"
:compare="compare"
@apply="apply"
@open-defs="defsVisible = true"
>
<template #aside>
<el-select
:model-value="compare"
class="compare-select"
@change="onCompareChange"
>
<el-option label="对比:上期" value="previous" />
<el-option label="对比:个人基线" value="personal_baseline" />
<el-option label="对比:团队中位数" value="team_median" />
</el-select>
</template>
</ManagementFilterBar>
<!-- 工具行:关键词(防抖)/在职状态/四视图;口径入口在筛选栏自带 -->
<div class="toolbar">
<el-input
v-model="keywordInput"
placeholder="姓名/ID"
clearable
:prefix-icon="Search"
class="kw-input"
/>
<el-select :model-value="status" class="status-select" @change="onStatusChange">
<el-option label="全部" value="all" />
<el-option label="在职" value="on_job" />
<el-option label="离线" value="offline" />
</el-select>
<el-radio-group :model-value="view" @change="onViewChange">
<el-radio-button value="management">管理概览</el-radio-button>
<el-radio-button value="workload">工作负载</el-radio-button>
<el-radio-button value="service">服务质量</el-radio-button>
<el-radio-button value="customer">客户经营</el-radio-button>
</el-radio-group>
<div class="spacer" />
<el-button
type="primary"
:disabled="selectedRows.length < 2 || selectedRows.length > 5"
@click="compareVisible = true"
>对比已选 ({{ selectedRows.length }})</el-button>
<el-button :loading="exporting" @click="exportCsv">导出CSV</el-button>
<!-- trial 常显:载荷无页面级 trial 键,批次C/D 评分处于试算期(同驾驶舱) -->
<DataAsOfBadge
:data-as-of="resp?.data_as_of ?? null"
:metric-version="resp?.metric_version"
:trial="true"
:config-source="resp?.config_source"
/>
</div>
<!-- 错误态:alert+重试;resp 不清空,保留上次成功数据 -->
<el-alert v-if="loadError" type="error" :closable="false" class="top-alert">
<template #title>
<span>{{ loadError }}</span>
<el-button size="small" type="primary" class="retry-btn" @click="load">重试</el-button>
</template>
</el-alert>
<!-- 主表:服务端排序/分页(sortable=custom,数据恒随 URL 查询);
列组随视图切换;多选(跨页保留)供对比抽屉。非排序列头 hover 提示,
关键列头「?」点击开指标口径抽屉 -->
<el-table
ref="tableRef"
v-loading="loading"
:data="rows"
row-key="user_id"
:default-sort="defaultSort"
@selection-change="onSelectionChange"
@sort-change="onSortChange"
>
<el-table-column type="selection" width="42" reserve-selection />
<el-table-column label="姓名" min-width="150">
<template #default="{ row }">
<!-- 带窗进详情:goDetail 随行 channel/start/end/compare(D3) -->
<el-link type="primary" underline="never" @click="goDetail(row)">
{{ row.name || row.user_id }}
</el-link>
<div class="uid">{{ row.user_id }}</div>
</template>
</el-table-column>
<el-table-column width="64">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>在职</span></el-tooltip>
</template>
<template #default="{ row }">
<el-tag v-if="row.is_on_job === true" size="small" type="success">在职</el-tag>
<el-tag v-else-if="row.is_on_job === false" size="small" type="info">离职</el-tag>
<span v-else class="dim">--</span>
</template>
</el-table-column>
<!-- ── 管理概览:评分/异常/规模/维度 ── -->
<template v-if="view === 'management'">
<el-table-column
prop="total_score"
width="120"
sortable="custom"
:sort-orders="['descending', 'ascending']"
>
<template #header>
<span>综合分
<el-tooltip content="查看指标口径" placement="top">
<el-icon class="hd-q" @click.stop="defsVisible = true"><QuestionFilled /></el-icon>
</el-tooltip>
</span>
</template>
<template #default="{ row }">
<el-tooltip
v-if="row.score.score_status === 'insufficient_sample'"
content="样本不足·不参与考核"
placement="top"
>
<span class="dim">--</span>
</el-tooltip>
<span v-else class="score-cell">
{{ Math.round(row.score.total_score ?? 0) }}
<el-tag
v-if="row.score.grade"
size="small"
:type="gradeType(row.score.grade)"
class="grade-tag"
>{{ row.score.grade }}</el-tag>
<span v-if="row.score.trial" class="trial-mark" title="试算期评分">试</span>
</span>
</template>
</el-table-column>
<el-table-column
prop="anomaly_severity"
label="异常"
width="90"
sortable="custom"
:sort-orders="['descending', 'ascending']"
>
<template #default="{ row }">
<span :class="row.anomaly_count > 0 ? 'anomaly-hot' : 'dim'">{{ row.anomaly_count }}</span>
<div v-if="row.anomaly_count > 0" class="cell-sub">权重 {{ row.anomaly_weight }}</div>
</template>
</el-table-column>
<el-table-column
prop="active_friends"
width="100"
sortable="custom"
:sort-orders="['descending', 'ascending']"
>
<template #header>
<span>活跃客户
<el-tooltip content="查看指标口径" placement="top">
<el-icon class="hd-q" @click.stop="defsVisible = true"><QuestionFilled /></el-icon>
</el-tooltip>
</span>
</template>
<template #default="{ row }">{{ fmtInt(num(row, 'active_friends')) }}</template>
</el-table-column>
<el-table-column min-width="100">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>消息量</span></el-tooltip>
</template>
<template #default="{ row }">{{ fmtInt(msgTotal(row)) }}</template>
</el-table-column>
<el-table-column min-width="110">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>串/被回</span></el-tooltip>
</template>
<template #default="{ row }">
{{ fmtInt(num(row, 'cust_threads')) }} / {{ fmtInt(num(row, 'threads_answered')) }}
</template>
</el-table-column>
<el-table-column min-width="90">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>客户经营</span></el-tooltip>
</template>
<template #default="{ row }">
<span :class="dimScore(row, 'customer') == null ? 'dim' : ''">{{ dimScoreText(row, 'customer') }}</span>
</template>
</el-table-column>
<el-table-column min-width="90">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>服务质量</span></el-tooltip>
</template>
<template #default="{ row }">
<span :class="dimScore(row, 'service') == null ? 'dim' : ''">{{ dimScoreText(row, 'service') }}</span>
</template>
</el-table-column>
<el-table-column min-width="90">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>工作负载</span></el-tooltip>
</template>
<template #default="{ row }">
<span :class="dimScore(row, 'workload') == null ? 'dim' : ''">{{ dimScoreText(row, 'workload') }}</span>
</template>
</el-table-column>
</template>
<!-- ── 工作负载:规模/消息/通话/串 ── -->
<template v-else-if="view === 'workload'">
<el-table-column
prop="active_friends"
width="100"
sortable="custom"
:sort-orders="['descending', 'ascending']"
>
<template #header>
<span>活跃客户
<el-tooltip content="查看指标口径" placement="top">
<el-icon class="hd-q" @click.stop="defsVisible = true"><QuestionFilled /></el-icon>
</el-tooltip>
</span>
</template>
<template #default="{ row }">{{ fmtInt(num(row, 'active_friends')) }}</template>
</el-table-column>
<el-table-column min-width="100">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>活跃群(日均)</span></el-tooltip>
</template>
<template #default="{ row }">{{ fmtAvg(num(row, 'active_rooms_avg')) }}</template>
</el-table-column>
<el-table-column min-width="130">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>消息发/收</span></el-tooltip>
</template>
<template #default="{ row }">
{{ fmtInt(num(row, 'msg_sent')) }} / {{ fmtInt(num(row, 'msg_recv')) }}
</template>
</el-table-column>
<el-table-column min-width="130">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>通话量/时长</span></el-tooltip>
</template>
<template #default="{ row }">
{{ fmtInt(num(row, 'call_count')) }} / {{ fmtSec(num(row, 'call_seconds')) }}
</template>
</el-table-column>
<el-table-column
prop="cust_threads"
label="串"
width="80"
sortable="custom"
:sort-orders="['descending', 'ascending']"
>
<template #default="{ row }">{{ fmtInt(num(row, 'cust_threads')) }}</template>
</el-table-column>
</template>
<!-- ── 服务质量:首响分位/回复率/被回串 ── -->
<template v-else-if="view === 'service'">
<el-table-column min-width="110">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>首响P50</span></el-tooltip>
</template>
<template #default="{ row }">{{ formatMsDuration(num(row, 'fr_p50_ms')) }}</template>
</el-table-column>
<el-table-column min-width="110">
<template #header>
<span>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>首响P90</span></el-tooltip>
<el-tooltip content="查看指标口径" placement="top">
<el-icon class="hd-q" @click.stop="defsVisible = true"><QuestionFilled /></el-icon>
</el-tooltip>
</span>
</template>
<template #default="{ row }">{{ formatMsDuration(num(row, 'fr_p90_ms')) }}</template>
</el-table-column>
<el-table-column min-width="100">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>2h回复率</span></el-tooltip>
</template>
<template #default="{ row }">{{ replyRate(row, 'reply_2h_count') }}</template>
</el-table-column>
<el-table-column min-width="110">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>24h回复率</span></el-tooltip>
</template>
<template #default="{ row }">{{ replyRate(row, 'reply_24h_count') }}</template>
</el-table-column>
<el-table-column min-width="110">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>被回/串</span></el-tooltip>
</template>
<template #default="{ row }">
{{ fmtInt(num(row, 'threads_answered')) }} / {{ fmtInt(num(row, 'cust_threads')) }}
</template>
</el-table-column>
</template>
<!-- ── 客户经营:生命周期四态+活跃占比 ── -->
<template v-else>
<el-table-column min-width="80">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>新客</span></el-tooltip>
</template>
<template #default="{ row }">{{ fmtInt(num(row, 'new_customer')) }}</template>
</el-table-column>
<el-table-column min-width="80">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>降温</span></el-tooltip>
</template>
<template #default="{ row }">{{ fmtInt(num(row, 'cooling_customer')) }}</template>
</el-table-column>
<el-table-column min-width="100">
<template #header>
<span>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>沉睡(期末)</span></el-tooltip>
<el-tooltip content="查看指标口径" placement="top">
<el-icon class="hd-q" @click.stop="defsVisible = true"><QuestionFilled /></el-icon>
</el-tooltip>
</span>
</template>
<template #default="{ row }">{{ fmtInt(num(row, 'dormant_customer')) }}</template>
</el-table-column>
<el-table-column min-width="100">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>回流(期内)</span></el-tooltip>
</template>
<template #default="{ row }">{{ fmtInt(num(row, 'reactivated_customer')) }}</template>
</el-table-column>
<el-table-column min-width="100">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>活跃占比</span></el-tooltip>
</template>
<template #default="{ row }">{{ activeShare(row) }}</template>
</el-table-column>
</template>
<template #empty>
<el-empty :image-size="72" description="无匹配员工——调整关键词/状态" />
</template>
</el-table>
<el-pagination
:current-page="page"
:page-size="PAGE_SIZE"
:total="total"
layout="total, prev, pager, next"
class="pager"
@current-change="onPageChange"
/>
<!-- 对比抽屉(2-5 行)与指标口径抽屉(自缓存) -->
<EmployeeCompareDrawer v-model="compareVisible" :rows="selectedRows" />
<MetricDefsDrawer v-model="defsVisible" />
</div>
</template>
<script setup lang="ts">
// 员工总览(经营管理批次D):四视图列组共用 /management/employees 行集,
// 服务端排序(仅 综合分/异常/活跃客户/串 四列)+ 服务端分页,query 全量
// 真源(view/keyword/status/sort_by/sort_order/page),刷新/分享保持。
// 多选 2-5 人开对比抽屉;CSV 导出当前视图列并带口径注释前置行。
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, type TableInstance } from 'element-plus'
// QuestionFilled 全局已注册(main.ts 全量挂载),显式导入与项目其他页一致
import { QuestionFilled, Search } from '@element-plus/icons-vue'
import {
getEmployees,
type EmployeeRow,
type EmployeesResp,
} from '@/api/management'
import { useManagementFilters } from '@/composables/useManagementFilters'
import { exportListToCsv, type CsvColumn } from '@/utils/csvExport'
import { formatMsDuration, formatRatio } from '@/utils/format'
import ManagementFilterBar from '@/components/management/ManagementFilterBar.vue'
import DataAsOfBadge from '@/components/management/DataAsOfBadge.vue'
import MetricDefsDrawer from '@/components/management/MetricDefsDrawer.vue'
import EmployeeCompareDrawer from '@/components/management/EmployeeCompareDrawer.vue'
const PAGE_SIZE = 20
type EmpView = 'management' | 'workload' | 'service' | 'customer'
type SortBy = 'anomaly_severity' | 'total_score' | 'active_friends' | 'cust_threads'
const VIEW_VALUES: readonly EmpView[] = ['management', 'workload', 'service', 'customer']
const SORT_BY_VALUES: readonly SortBy[] = ['anomaly_severity', 'total_score', 'active_friends', 'cust_threads']
const route = useRoute()
const router = useRouter()
const { channel, start, end, compare, apply } = useManagementFilters()
// ── 页面级 query 真源(白名单外回默认;与后端 Literal 校验同域) ──
const view = computed<EmpView>(() =>
VIEW_VALUES.includes(route.query.view as EmpView) ? (route.query.view as EmpView) : 'management')
const keyword = computed(() => (typeof route.query.keyword === 'string' ? route.query.keyword.trim() : ''))
const status = computed<'all' | 'on_job' | 'offline'>(() =>
route.query.status === 'on_job' || route.query.status === 'offline' ? route.query.status : 'all')
const sortBy = computed<SortBy>(() =>
SORT_BY_VALUES.includes(route.query.sort_by as SortBy) ? (route.query.sort_by as SortBy) : 'anomaly_severity')
const sortOrder = computed<'asc' | 'desc'>(() => (route.query.sort_order === 'asc' ? 'asc' : 'desc'))
const page = computed(() => {
const n = Number(route.query.page)
return Number.isInteger(n) && n >= 1 ? n : 1
})
// ── 加载(seq 守卫沿用驾驶舱/ChurnView 模式) ──
const resp = ref<EmployeesResp | null>(null)
const loading = ref(false)
const loadError = ref<string | null>(null)
let seq = 0
function queryParams(over: Record<string, unknown> = {}): Record<string, unknown> {
return {
channel: channel.value,
start: start.value,
end: end.value,
view: view.value,
keyword: keyword.value || undefined,
status: status.value,
sort_by: sortBy.value,
sort_order: sortOrder.value,
page: page.value,
page_size: PAGE_SIZE,
...over,
}
}
async function load(): Promise<void> {
const my = ++seq
loading.value = true
loadError.value = null
try {
const r = await getEmployees(queryParams())
if (my !== seq) return
resp.value = r
} catch (e) {
// 失败保留上次成功数据,仅标记 error 供 alert+重试
if (my === seq) loadError.value = e instanceof Error ? e.message : String(e)
} finally {
if (my === seq) loading.value = false
}
}
watch([channel, start, end, view, keyword, status, sortBy, sortOrder, page], load, { immediate: true })
const rows = computed<EmployeeRow[]>(() => resp.value?.rows ?? [])
const total = computed(() => resp.value?.total ?? 0)
// ── 工具行交互(全部写回 URL;筛选变化重置分页) ──
const keywordInput = ref(keyword.value)
let kwTimer: ReturnType<typeof setTimeout> | undefined
watch(keywordInput, (v) => {
if (kwTimer) clearTimeout(kwTimer)
kwTimer = setTimeout(() => {
if ((v.trim() || '') !== keyword.value) apply({ keyword: v.trim() || undefined, page: undefined })
}, 300)
})
// 外部导航(回退/直链)同步输入框显示
watch(keyword, (v) => {
if (v !== keywordInput.value.trim()) keywordInput.value = v
})
function onStatusChange(v: string | number | boolean): void {
apply({ status: v === 'all' ? undefined : String(v), page: undefined })
}
// 切视图:列组变化,排序回默认(异常降序)、回第一页
function onViewChange(v: string | number | boolean): void {
apply({ view: String(v), sort_by: undefined, sort_order: undefined, page: undefined })
}
function onCompareChange(v: string | number | boolean): void {
apply({ compare: v === 'previous' ? undefined : String(v) })
}
function onPageChange(p: number): void {
apply({ page: p === 1 ? undefined : p })
}
// ── 服务端排序:el-table custom 模式(全仓首个),prop 即后端 sort_by 值 ──
const tableRef = ref<TableInstance>()
const defaultSort = computed(() => ({
prop: sortBy.value,
order: sortOrder.value === 'asc' ? ('ascending' as const) : ('descending' as const),
}))
function onSortChange({ prop, order }: { prop: string; order: 'ascending' | 'descending' | null }): void {
const by = SORT_BY_VALUES.find((v) => v === prop)
if (!by || !order) return // :sort-orders 两态,order 不会 null,防御
apply({ sort_by: by, sort_order: order === 'ascending' ? 'asc' : 'desc', page: undefined })
}
// URL 驱动的排序/视图变化(回退/切视图重置/直链)同步表头指示:sort() 对
// custom 列只设指示不本地排序(EP watcher sortData 对字符串 sortable 直接
// 返回原数据);prop 在当前视图无该列时为无害 no-op
watch([view, sortBy, sortOrder], ([, by, order]) => {
tableRef.value?.sort(by, order === 'asc' ? 'ascending' : 'descending')
}, { flush: 'post' })
// ── 取数/格式化(metrics 数值键;user_id 透传键是字符串,typeof 守卫) ──
function num(r: EmployeeRow, key: string): number | null {
const v = r.metrics[key]
return typeof v === 'number' ? v : null
}
function fmtInt(v: number | null | undefined): string {
return v == null ? '--' : v.toLocaleString()
}
function fmtAvg(v: number | null | undefined): string {
return v == null ? '--' : v.toLocaleString('zh-CN', { maximumFractionDigits: 1 })
}
function fmtSec(s: number | null): string {
return s && s > 0 ? formatMsDuration(s * 1000) : '-'
}
function msgTotal(r: EmployeeRow): number {
return (num(r, 'msg_sent') ?? 0) + (num(r, 'msg_recv') ?? 0)
}
function replyRate(r: EmployeeRow, countKey: 'reply_2h_count' | 'reply_24h_count'): string {
const den = num(r, 'cust_threads')
if (!den) return '--' // 分母 0/null → 无率(严格区分 0%)
return formatRatio((num(r, countKey) ?? 0) / den)
}
function activeShare(r: EmployeeRow): string {
const ea = num(r, 'end_active_friends') ?? 0
const den = ea + (num(r, 'cooling_customer') ?? 0) + (num(r, 'dormant_customer') ?? 0)
return den > 0 ? formatRatio(ea / den) : '--'
}
function dimScore(r: EmployeeRow, key: string): number | null {
return r.score.dimensions[key]?.score ?? null
}
function dimScoreText(r: EmployeeRow, key: string): string {
const s = dimScore(r, key)
return s == null ? '--' : String(Math.round(s))
}
const GRADE_TYPES: Record<string, 'success' | 'primary' | 'warning' | 'danger' | 'info'> = {
A: 'success', B: 'primary', C: 'warning', D: 'danger', E: 'info',
}
function gradeType(g: string | null): 'success' | 'primary' | 'warning' | 'danger' | 'info' {
return (g && GRADE_TYPES[g]) || 'info'
}
// 带窗进详情(D3):channel/start/end/compare 随行——列表口径延续到详情页;
// view/keyword/status/sort_by/sort_order/page 是列表态,不带(详情页只认
// channel/start/end/compare/section,其余键即使带上也被白名单忽略)
function goDetail(row: EmployeeRow): void {
const query: Record<string, string> = {}
for (const key of ['channel', 'start', 'end', 'compare'] as const) {
const v = route.query[key]
if (typeof v === 'string' && v !== '') query[key] = v
}
router.push({ name: 'management-employees-detail', params: { userId: row.user_id }, query })
}
// ── 多选 → 对比抽屉(reserve-selection 跨页/重查保留;行是勾选时快照) ──
const selectedRows = ref<EmployeeRow[]>([])
const compareVisible = ref(false)
// 指标口径抽屉(列头「?」与筛选栏「指标口径」共用)
const defsVisible = ref(false)
function onSelectionChange(sel: EmployeeRow[]): void {
selectedRows.value = sel
}
// ── CSV:当前视图列全量翻页导出,preamble 写口径/窗口注释(Excel 首列) ──
const exporting = ref(false)
const csvColumns = computed<CsvColumn<EmployeeRow>[]>(() => {
const base: CsvColumn<EmployeeRow>[] = [
{ key: 'name', label: '姓名', format: (r) => r.name ?? r.user_id },
{ key: 'user_id', label: 'ID' },
{
key: 'is_on_job',
label: '在职',
format: (r) => (r.is_on_job === true ? '在职' : r.is_on_job === false ? '离职' : '--'),
},
]
const dimCol = (key: string, label: string): CsvColumn<EmployeeRow> => ({
key: `dim_${key}`, label, format: (r) => dimScoreText(r, key),
})
if (view.value === 'management') {
return base.concat([
{
key: 'total_score',
label: '综合分',
format: (r) =>
r.score.score_status === 'insufficient_sample' || r.score.total_score == null
? '--(样本不足)'
: String(Math.round(r.score.total_score)),
},
{ key: 'grade', label: '等级', format: (r) => r.score.grade ?? '--' },
{ key: 'anomaly_count', label: '异常数', format: (r) => String(r.anomaly_count) },
{ key: 'anomaly_weight', label: '异常权重', format: (r) => String(r.anomaly_weight) },
{ key: 'active_friends', label: '活跃客户', format: (r) => fmtInt(num(r, 'active_friends')) },
{ key: 'msg_total', label: '消息量', format: (r) => fmtInt(msgTotal(r)) },
{ key: 'cust_threads', label: '串', format: (r) => fmtInt(num(r, 'cust_threads')) },
{ key: 'threads_answered', label: '被回', format: (r) => fmtInt(num(r, 'threads_answered')) },
dimCol('customer', '客户经营分'),
dimCol('service', '服务质量分'),
dimCol('workload', '工作负载分'),
])
}
if (view.value === 'workload') {
return base.concat([
{ key: 'active_friends', label: '活跃客户', format: (r) => fmtInt(num(r, 'active_friends')) },
{ key: 'active_rooms_avg', label: '活跃群(日均)', format: (r) => fmtAvg(num(r, 'active_rooms_avg')) },
{ key: 'msg_sent', label: '消息发', format: (r) => fmtInt(num(r, 'msg_sent')) },
{ key: 'msg_recv', label: '消息收', format: (r) => fmtInt(num(r, 'msg_recv')) },
{ key: 'call_count', label: '通话量', format: (r) => fmtInt(num(r, 'call_count')) },
{ key: 'call_seconds', label: '通话秒', format: (r) => String(num(r, 'call_seconds') ?? 0) },
{ key: 'cust_threads', label: '串', format: (r) => fmtInt(num(r, 'cust_threads')) },
])
}
if (view.value === 'service') {
return base.concat([
{ key: 'fr_p50_ms', label: '首响P50', format: (r) => formatMsDuration(num(r, 'fr_p50_ms')) },
{ key: 'fr_p90_ms', label: '首响P90', format: (r) => formatMsDuration(num(r, 'fr_p90_ms')) },
{ key: 'reply_2h_rate', label: '2h回复率', format: (r) => replyRate(r, 'reply_2h_count') },
{ key: 'reply_24h_rate', label: '24h回复率', format: (r) => replyRate(r, 'reply_24h_count') },
{ key: 'threads_answered', label: '被回', format: (r) => fmtInt(num(r, 'threads_answered')) },
{ key: 'cust_threads', label: '串', format: (r) => fmtInt(num(r, 'cust_threads')) },
])
}
return base.concat([
{ key: 'new_customer', label: '新客', format: (r) => fmtInt(num(r, 'new_customer')) },
{ key: 'cooling_customer', label: '降温', format: (r) => fmtInt(num(r, 'cooling_customer')) },
{ key: 'dormant_customer', label: '沉睡(期末)', format: (r) => fmtInt(num(r, 'dormant_customer')) },
{ key: 'reactivated_customer', label: '回流(期内)', format: (r) => fmtInt(num(r, 'reactivated_customer')) },
{ key: 'active_share', label: '活跃占比', format: (r) => activeShare(r) },
])
})
async function exportCsv(): Promise<void> {
if (!total.value) {
ElMessage.warning('当前列表为空,无数据可导出')
return
}
exporting.value = true
try {
const result = await exportListToCsv<EmployeeRow>({
// 适配器:getEmployees 返回 {rows,total} → csvExport 期望 {items,total}
fetchPage: (p, size) =>
getEmployees(queryParams({ page: p, page_size: size })).then((r) => ({ items: r.rows, total: r.total })),
columns: csvColumns.value,
filename: `员工分析_${view.value}_${channel.value}`,
preamble: [
'口径:1:1私聊·跨日精确串(与数据看板混合口径不同)',
`窗口:${start.value}-${end.value} 渠道:${channel.value}`,
`评分为试算(${resp.value?.metric_version ?? 'v1.0'})·样本不足员工不计分`,
],
})
ElMessage.success(`已导出 ${result.rows} 条`)
} catch (e) {
ElMessage.error(`导出失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
exporting.value = false
}
}
</script>
<style scoped>
.toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-top: 12px;
}
.kw-input {
width: 200px;
}
.compare-select {
width: 150px;
}
.status-select {
width: 90px;
}
.spacer {
flex: 1;
}
.top-alert {
margin-top: 12px;
}
.retry-btn {
margin-left: 12px;
}
.uid {
font-size: 12px;
color: var(--el-text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dim {
color: var(--el-text-color-placeholder);
}
.score-cell {
white-space: nowrap;
}
.grade-tag {
margin-left: 4px;
}
.trial-mark {
margin-left: 2px;
font-size: 10px;
color: var(--el-color-warning);
vertical-align: super;
}
.anomaly-hot {
color: var(--el-color-danger);
font-weight: 600;
}
.cell-sub {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.hd-q {
margin-left: 2px;
color: var(--el-text-color-secondary);
cursor: pointer;
vertical-align: -2px;
}
.hd-q:hover {
color: var(--el-color-primary);
}
.pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,537 @@
<template>
<div class="mgmt-dash">
<!-- 筛选栏:渠道/时间(URL query 真源)+口径入口;aside=数据截至角标 -->
<ManagementFilterBar
:channel="channel"
:start="start"
:end="end"
:compare="compare"
@apply="apply"
@open-defs="defsVisible = true"
>
<template #aside>
<!-- trial 常显:dashboard 载荷无 trial 键(评分配置层字段),批次C/D
评分处于试算期(v2 §0 全默认+试算),批次E 转正时随配置联动 -->
<DataAsOfBadge
:data-as-of="resp?.data_as_of ?? null"
:metric-version="resp?.metric_version"
:trial="true"
:config-source="resp?.config_source"
/>
</template>
</ManagementFilterBar>
<!-- 错误态:alert+重试;resp 不清空,保留上次成功数据 -->
<el-alert v-if="loadError" type="error" :closable="false" class="dash-alert">
<template #title>
<span>{{ loadError }}</span>
<el-button size="small" type="primary" class="retry-btn" @click="load">重试</el-button>
</template>
</el-alert>
<!-- 空态:窗口内无任何业务数据(数据截至角标同步显「无数据」) -->
<el-alert
v-if="noData"
type="info"
title="该窗口无数据——试试调整时间范围"
:closable="false"
class="dash-alert"
/>
<!-- 六 KPI 卡:两行三卡(≥768px 三列/窄屏两列);后三卡行动入口直达处置页 -->
<el-row :gutter="12" class="block-row kpi-row" v-loading="loading">
<el-col :xs="12" :sm="8">
<ManagementKpiCard
label="活跃客户"
:value="kpis?.active_customers ?? null"
:delta="deltaOf('active_customers')"
unit="人"
:hint="threadsHint"
/>
</el-col>
<el-col :xs="12" :sm="8">
<ManagementKpiCard
label="活跃员工"
:value="kpis?.active_employees ?? null"
:delta="deltaOf('active_employees')"
unit="人"
hint="期内有数据天数>0 口径"
/>
</el-col>
<el-col :xs="12" :sm="8">
<!-- tooltip 说明混合 vs 1:1 口径差异,hover 才展开,卡面 hint 保持一行 -->
<el-tooltip
placement="top"
content="管理域消息量仅统计 1:1 私聊消息;「数据分析·数据看板」为私聊+群聊混合口径,两者数值不可直接对比"
>
<ManagementKpiCard
label="消息量"
:value="kpis?.msg_total ?? null"
:delta="deltaOf('msg_total')"
unit="条"
hint="1:1口径·与数据看板口径不同"
/>
</el-tooltip>
</el-col>
<el-col :xs="12" :sm="8">
<ManagementKpiCard
label="待回复"
:value="actions?.unreplied_2h ?? null"
unit="人"
hint="当前时点指标·不随所选窗口"
:danger="(actions?.unreplied_2h ?? 0) > 0"
to="/analytics/unreplied"
/>
</el-col>
<el-col :xs="12" :sm="8">
<ManagementKpiCard
label="即将流失"
:value="actions?.churn_real_30d ?? null"
unit="人"
to="/analytics/churn"
/>
</el-col>
<el-col :xs="12" :sm="8">
<ManagementKpiCard
label="风险待处理"
:value="actions?.sensitive_pending ?? null"
unit="条"
hint="当前时点指标·不随所选窗口"
to="/analytics/sensitive"
/>
</el-col>
</el-row>
<!-- 双趋势图:左沟通量(消息线+通话柱双 y 轴),右服务质量(首响分位线+回复率右轴) -->
<el-row :gutter="12" class="block-row">
<el-col :xs="24" :md="12">
<el-card shadow="never" class="chart-card" v-loading="loading">
<template #header>客户沟通趋势</template>
<div ref="commEl" class="chart"></div>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card shadow="never" class="chart-card" v-loading="loading">
<template #header>服务质量趋势</template>
<div ref="svcEl" class="chart"></div>
</el-card>
</el-col>
</el-row>
<!-- 双条形图:左生命周期四态(横向),右员工维度均分(0-100,无样本灰) -->
<el-row :gutter="12" class="block-row">
<el-col :xs="24" :md="12">
<el-card shadow="never" class="chart-card" v-loading="loading">
<template #header>生命周期分布</template>
<div ref="lifeEl" class="chart"></div>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card shadow="never" class="chart-card" v-loading="loading">
<template #header>员工维度分布</template>
<div ref="dimsEl" class="chart"></div>
</el-card>
</el-col>
</el-row>
<!-- 异常榜:critical 在前(后端已排);点行开下钻抽屉,两次点击到事实 -->
<el-card shadow="never" class="block-row anomaly-card" v-loading="loading">
<template #header>
异常榜
<span class="anomaly-sub">按严重度排序 · 点行查看明细</span>
</template>
<AnomalyList :anomalies="resp?.anomalies ?? []" :loading="loading" @drill="onDrill" />
</el-card>
<!-- 下钻抽屉:metric 类异常逐串/逐行明细,行点击跳聊天;employee 类批次D 占位 -->
<MetricDrilldownDrawer
v-model="drillVisible"
:card="drillCard"
:user-id="drillCard?.employee_user_id ?? ''"
:start="start"
:end="end"
:channel="channel"
/>
<!-- 指标口径抽屉:19 项定义+评分配置,自缓存 -->
<MetricDefsDrawer v-model="defsVisible" />
</div>
</template>
<script setup lang="ts">
// 管理驾驶舱(经营管理板块批次C首页):筛选(URL query 真源)→ 六 KPI →
// 双趋势(沟通量/服务质量)→ 生命周期四态 + 员工维度均分 → 异常榜(下钻
// 抽屉两次点击到事实)。管理者 5 分钟看整体:先 KPI 判断总体,异常卡
// 直达事实。载荷 /management/dashboard 单往返;compare 仅回显(对比值
// 批次D 员工表格呈现)。seq 守卫/双图 useChart/格式化均沿用项目惯例。
import { computed, ref, watch } from 'vue'
import {
getDashboard,
type AnomalyCard,
type DashboardResp,
} from '@/api/management'
import { useManagementFilters } from '@/composables/useManagementFilters'
import { useChart } from '@/composables/useChart'
import { formatMsDuration, formatRatio } from '@/utils/format'
import ManagementFilterBar from '@/components/management/ManagementFilterBar.vue'
import ManagementKpiCard from '@/components/management/ManagementKpiCard.vue'
import DataAsOfBadge from '@/components/management/DataAsOfBadge.vue'
import AnomalyList from '@/components/management/AnomalyList.vue'
import MetricDrilldownDrawer from '@/components/management/MetricDrilldownDrawer.vue'
import MetricDefsDrawer from '@/components/management/MetricDefsDrawer.vue'
// ── 筛选(URL query 为真源,刷新/分享保持)与加载 ──
const { channel, start, end, compare, apply } = useManagementFilters()
const resp = ref<DashboardResp | null>(null)
const loading = ref(false)
const loadError = ref<string | null>(null)
let seq = 0 // 竞态守卫:筛选快速切换时旧响应作废(项目惯例)
async function load(): Promise<void> {
const my = ++seq
loading.value = true
loadError.value = null
try {
const r = await getDashboard(channel.value, start.value, end.value, compare.value)
if (my !== seq) return
resp.value = r
} catch (e) {
// 失败保留上次成功数据(resp 不清空),仅标记 error 供 alert+重试
if (my === seq) loadError.value = e instanceof Error ? e.message : String(e)
} finally {
// 过期请求不清 loading(新一轮还在飞)也不覆盖 error
if (my === seq) loading.value = false
}
}
// immediate 首帧即取数,替代 onMounted;筛选任一变化重查
watch([channel, start, end, compare], load, { immediate: true })
const kpis = computed(() => resp.value?.kpis ?? null)
const actions = computed(() => resp.value?.action_summary ?? null)
// 三 KPI 卡环比(批次D):客户端统一按 (cur-prev)/prev 自 prev_kpis 计算
// (数据同载荷都在;后端 msg_delta 备用未用)。prev_kpis 缺失(旧缓存
// 响应)或 prev=0(前窗无数据)→ null 不显示
function deltaOf(key: 'active_customers' | 'active_employees' | 'msg_total'): number | null {
const k = resp.value?.kpis
const p = resp.value?.prev_kpis
if (!k || !p || !p[key]) return null
return (k[key] - p[key]) / p[key]
}
// 活跃客户辅助行:串/被回(口径与 KPI 同载荷,管理域跨日精确串)
const threadsHint = computed(() => {
const k = kpis.value
if (!k) return undefined
return `串 ${k.cust_threads.toLocaleString()} · 被回 ${k.threads_answered.toLocaleString()}`
})
// 空态:核心 KPI 全 0(趋势按日脚手架恒非空,以 KPI 判空才可靠)
const noData = computed(() => {
const k = kpis.value
if (!k || loadError.value) return false
return !k.active_customers && !k.active_employees && !k.msg_total && !k.call_count
})
// ── 异常下钻 ──
const drillVisible = ref(false)
const drillCard = ref<AnomalyCard | null>(null)
const defsVisible = ref(false)
function onDrill(card: AnomalyCard): void {
drillCard.value = card
drillVisible.value = true
}
// ── 图表(四张,容器恒在 DOM——useChart 只 init 一次,不 v-if) ──
const commEl = ref<HTMLElement | null>(null)
const svcEl = ref<HTMLElement | null>(null)
const lifeEl = ref<HTMLElement | null>(null)
const dimsEl = ref<HTMLElement | null>(null)
const { render: renderComm } = useChart(commEl)
const { render: renderSvc } = useChart(svcEl)
const { render: renderLife } = useChart(lifeEl)
const { render: renderDims } = useChart(dimsEl)
// yyyymmdd(20260810)→ 08-10 短格式(轴/tooltip 共用)
const shortDay = (d: number): string => `${String(d).slice(4, 6)}-${String(d).slice(6)}`
// 大数压缩:轴刻度统一 1.1万 口径(与 DashboardView 一致)
const compact = (n: number): string =>
n >= 10000 ? `${(n / 10000).toFixed(1)}万` : n.toLocaleString()
// 服务质量左轴刻度:ms → 紧凑时长(轴上省秒,tooltip 仍用 formatMsDuration)
const fmtMsAxis = (v: number): string =>
v >= 3_600_000 ? `${(v / 3_600_000).toFixed(1)}时` : `${Math.round(v / 60_000)}分`
// X 轴 ~6 个刻度(30 个日期全排会挤成一串不可读)
const axisInterval = (n: number): number => Math.max(0, Math.ceil(n / 6) - 1)
const AXIS_LABEL = { color: '#909399' }
const SPLIT_LINE = { lineStyle: { type: 'solid' as const, color: '#f0f1f3' } }
function drawComm(): void {
const t = resp.value?.communication_trend
if (!t || !t.length) return
const labels = t.map((p) => shortDay(p.day))
renderComm({
grid: { left: 56, right: 56, top: 40, bottom: 28 },
tooltip: { trigger: 'axis', valueFormatter: (v: unknown) => compact(Number(v)) },
legend: { data: ['消息量', '通话量'], top: 4, itemWidth: 14, textStyle: { color: '#606266' } },
xAxis: {
type: 'category',
data: labels,
axisLabel: { interval: axisInterval(labels.length), hideOverlap: true, ...AXIS_LABEL },
axisLine: { lineStyle: { color: '#dcdfe6' } },
axisTick: { show: false },
},
yAxis: [
{
type: 'value',
name: '消息量',
axisLabel: { formatter: (v: number) => compact(v), ...AXIS_LABEL },
splitLine: SPLIT_LINE,
},
{
type: 'value',
name: '通话量',
axisLabel: { formatter: (v: number) => compact(v), ...AXIS_LABEL },
splitLine: { show: false },
},
],
series: [
{
name: '消息量',
type: 'line',
smooth: true,
yAxisIndex: 0,
lineStyle: { width: 2, color: '#409EFF' },
itemStyle: { color: '#409EFF' },
areaStyle: { opacity: 0.1, color: '#409EFF' },
showSymbol: false,
data: t.map((p) => p.msg_total),
},
{
name: '通话量',
type: 'bar',
yAxisIndex: 1,
barMaxWidth: 12,
itemStyle: { color: '#67C23A', borderRadius: [3, 3, 0, 0] },
data: t.map((p) => p.call_cnt),
},
],
})
}
// 服务质量 tooltip:ms→时长/率→百分比(null 显 '-',与断点语义一致)
interface TipParam { marker?: string; seriesName?: string; value?: number | null; axisValueLabel?: string }
function svcTooltip(ps: unknown): string {
const arr = (Array.isArray(ps) ? ps : [ps]) as TipParam[]
if (!arr.length) return ''
const rows = arr.map((p) => {
const v = typeof p.value === 'number' ? p.value : null
const text = v === null ? '-' : p.seriesName === '2h回复率' ? formatRatio(v) : formatMsDuration(v)
return `<div>${p.marker ?? ''}${p.seriesName}: ${text}</div>`
})
return `<div>${arr[0].axisValueLabel ?? ''}</div>${rows.join('')}`
}
function drawSvc(): void {
const t = resp.value?.service_trend
if (!t || !t.length) return
const labels = t.map((p) => shortDay(p.day))
renderSvc({
grid: { left: 56, right: 56, top: 40, bottom: 28 },
tooltip: { trigger: 'axis', formatter: svcTooltip },
legend: {
data: ['首响中位', '首响九分位', '2h回复率'],
top: 4, itemWidth: 14, textStyle: { color: '#606266' },
},
xAxis: {
type: 'category',
data: labels,
axisLabel: { interval: axisInterval(labels.length), hideOverlap: true, ...AXIS_LABEL },
axisLine: { lineStyle: { color: '#dcdfe6' } },
axisTick: { show: false },
},
yAxis: [
{
type: 'value',
name: '首响',
axisLabel: { formatter: fmtMsAxis, ...AXIS_LABEL },
splitLine: SPLIT_LINE,
},
{
// 右轴 0-1 率:不单列双轴则率线贴底不可读
type: 'value',
name: '2h回复率',
min: 0,
max: 1,
axisLabel: { formatter: (v: number) => formatRatio(v), ...AXIS_LABEL },
splitLine: { show: false },
},
],
series: [
// null(无样本日)=断点:connectNulls:false 显式钉死,不跨缺口连线
{
name: '首响中位',
type: 'line', smooth: true, showSymbol: false, connectNulls: false,
lineStyle: { width: 2 },
data: t.map((p) => p.fr_p50_ms),
},
{
name: '首响九分位',
type: 'line', smooth: true, showSymbol: false, connectNulls: false,
lineStyle: { width: 2 },
data: t.map((p) => p.fr_p90_ms),
},
{
name: '2h回复率',
type: 'line', smooth: true, showSymbol: false, connectNulls: false,
yAxisIndex: 1,
lineStyle: { width: 2, color: '#67C23A' },
itemStyle: { color: '#67C23A' },
data: t.map((p) => p.reply_2h_rate),
},
],
})
}
// 生命周期四态:横向条形(名义类别,状态色——新客蓝/降温橙/沉睡红/回流绿)
const LIFE_META = [
{ key: 'new', label: '新客', color: '#409EFF' },
{ key: 'cooling', label: '降温', color: '#E6A23C' },
{ key: 'dormant', label: '沉睡', color: '#F56C6C' },
{ key: 'reactivated', label: '回流', color: '#67C23A' },
] as const
function drawLife(): void {
const lc = resp.value?.lifecycle
if (!lc) return
renderLife({
grid: { left: 8, right: 48, top: 8, bottom: 8, containLabel: true },
tooltip: { trigger: 'item', valueFormatter: (v: unknown) => compact(Number(v)) },
xAxis: {
type: 'value',
axisLabel: { formatter: (v: number) => compact(v), ...AXIS_LABEL },
splitLine: SPLIT_LINE,
},
yAxis: {
type: 'category',
inverse: true, // 新客在顶部(生命周期顺序)
data: LIFE_META.map((m) => m.label),
axisTick: { show: false },
axisLine: { show: false },
axisLabel: { color: '#606266' },
},
series: [
{
type: 'bar',
barWidth: 14,
itemStyle: { borderRadius: [0, 4, 4, 0] },
label: {
show: true,
position: 'right',
formatter: (p: { value: unknown }) => compact(Number(p.value)),
color: '#606266',
fontSize: 11,
},
data: LIFE_META.map((m) => ({ value: lc[m.key], itemStyle: { color: m.color } })),
},
],
})
}
// 员工维度分布:pending_data 维(action/risk)恒无 scored/insufficient 计数,
// 以计数>0 过滤,只留真实参与评分的维度(customer/service/workload)
const dimRows = computed(() =>
Object.entries(resp.value?.dimension_summary ?? {})
.filter(([, d]) => d.scored_count > 0 || d.insufficient_count > 0)
.map(([key, d]) => ({ key, ...d })),
)
function dimsTooltip(p: { dataIndex?: number }): string {
const r = p.dataIndex != null ? dimRows.value[p.dataIndex] : undefined
if (!r) return ''
const avg = r.avg_score === null ? '-' : Math.round(r.avg_score)
return (
`<div>${r.name}</div>` +
`<div>均分 ${avg}(权重 ${r.weight}%)</div>` +
`<div>已评分 ${r.scored_count} · 样本不足 ${r.insufficient_count} · 待数据 ${r.pending_count}</div>`
)
}
function drawDims(): void {
const rows = dimRows.value
if (!rows.length) return
renderDims({
grid: { left: 40, right: 16, top: 24, bottom: 28, containLabel: true },
tooltip: { trigger: 'item', formatter: dimsTooltip },
xAxis: {
type: 'category',
data: rows.map((r) => r.name),
axisTick: { show: false },
axisLine: { lineStyle: { color: '#dcdfe6' } },
axisLabel: { color: '#606266' },
},
yAxis: {
type: 'value',
min: 0,
max: 100, // 维度分定义域 0-100
axisLabel: AXIS_LABEL,
splitLine: SPLIT_LINE,
},
series: [
{
type: 'bar',
barWidth: 28,
itemStyle: { borderRadius: [3, 3, 0, 0] },
label: {
show: true,
position: 'top',
formatter: (p: { dataIndex: number; value: unknown }) =>
(rows[p.dataIndex]?.avg_score ?? null) === null ? '-' : compact(Number(p.value)),
color: '#606266',
fontSize: 11,
},
data: rows.map((r) =>
r.avg_score === null
// 无样本维度(全员未评):0 值灰条——有位置但不冒充分数
? { value: 0, itemStyle: { color: '#c0c4cc' } }
: { value: Math.round(r.avg_score), itemStyle: { color: '#409EFF' } },
),
},
],
})
}
// post:等 DOM 更新后再画,保证首帧容器已挂载;resp 单源,四图同刷
watch(resp, () => { drawComm(); drawSvc(); drawLife(); drawDims() }, { flush: 'post' })
</script>
<style scoped>
.dash-alert {
margin-top: 12px;
}
.retry-btn {
margin-left: 12px;
}
.block-row {
margin-top: 16px;
}
.kpi-row {
min-height: 100px;
}
.chart-card :deep(.el-card__body) {
padding: 12px 16px 8px;
}
.chart {
height: 300px;
}
.anomaly-sub {
margin-left: 8px;
font-size: 12px;
font-weight: 400;
color: var(--el-text-color-secondary);
}
</style>