Compare commits

...

75 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
浅念 5bfcebf20c fix(wa-chat): 群聊路由侧栏常驻——点群后「好友列表/群列表」不再消失
用户报 WA 聊天页点群列表里的群,跳到聊天框后侧栏(好友列表/群列表)
没了。根因:ChatTimeline 模板里 ChatSidebar 挂了 v-if="!isGroup"——
群路由(roomId 有值)下直接不渲染,是早期群聊独立页的设计遗留;
onSelectSession 本就支持好友/群双类型导航,侧栏组件自带好友/群过滤。

修:去掉 v-if 侧栏常驻,群聊中可随时切换好友/群会话(对齐云客体验)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 14:34:03 +08:00
浅念 2bb4cee76d feat(management): 员工指标宽表——1:1口径/串类与fact同源/生命周期列/幽灵过滤/纯生命周期日出行
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 14:19:25 +08:00
浅念 a38170d205 test(management): 通话followup判别补强——未接通反例/渠道收窄正反例/任一账号
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 14:09:21 +08:00
浅念 08614ddb3f fix(wa-customers): link 路径 LEFT JOIN 档案带 raw_json——WA 好友列表列空白
用户报 WA 员工列表点「好友列表」数据都不显示。根因:owner 过滤切
customer_account_link 后,link 快照只有客户侧字段;而 WA 客户表格大量列
只存在于档案 raw_json(userName/salePhone/salesNickName/createTime 等
员工侧 camelCase 字段),link 行 raw_json=None → cellText 全部落空,整表
只剩号码可看。

修:_list_owner_links 按 friend_id LEFT JOIN customer 带出 raw_json
(+version)。raw_json 全局一人一份、挂谁名下内容相同;已知口径:共享
客户的员工侧字段是「最后写入员工」的记录(1,967/12,578),客户自身字段
不受影响——完美对齐需 link 补员工快照列,另行评估。

647 测试绿(新增 raw_json 透传测试)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 14:08:44 +08:00
浅念 b2e1b699eb feat(management): 通话日聚合小表——无渠道维/接通=duration>0/24h任一账号跟进(渠道按通话类型收窄)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 13:51:11 +08:00
浅念 99773f69b9 fix(staff-stats): 统计tab新客户/删除客户/红包昵称切 link——共享好友不再漏计
全项目同型审计结论的收尾项:统计分析 tab 按 owner 查全局单行档案表,
共享好友(档案挂别的员工名下)不计入——

- _new_customers(新客户):主体 Customer → CustomerAccountLink(add_time
  窗+首条消息兜底 JOIN 平移到 link.owner);数字变大=对齐云客按员工口径
- _del_customers(删除客户):_flag_customers 参数化 model,del 走 link
  (delete_flag per-relation);blocked 保留档案口径(is_block 未镜像进
  link,已知偏差=他人员工名下拉黑不计,注释标明)
- _red_transfer 昵称:link 优先档案兜底(同 sessions._meta 模式)

审计同时确认无需修:群侧(chat_group 复合主键天然多对多)、搜索/长语音
(按 friend 全局直查)、analytics(全局口径)、WA(与微信共用同批修复,
12,578 行 link 已回填,1,967 共享客户在册;无昵称行=WA 联系人本身没存
名字,与云客一致显示号码)。

644 测试绿(新增共享好友新客户判别测试;存量测试种子补 link 行镜像
生产写路径)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 13:45:42 +08:00
浅念 9dc22cd1e6 feat(management): 生命周期判定纯函数——距今天数口径/7天新客/30天沉睡/历史门槛5条
TDD(批次A Task4):classify/day_add/days_between 纯函数,供 Task6 宽表
生命周期 4 列计算。口径已确认:新客=首次互动≤7 天(含当天共 7 天)、
cooling [7,30)、沉睡≥30 天且历史≥5 条(对齐 churn real min_msgs=5)、
回流=当日互动(last_day==as_of)且前次间隔>30 天(严格大于,恰好 30
天不算);未激活或历史不足的长期静默返回 None。analytics 现有页暂不
切换(v2 §5 口径差异另标),不动 636 既有测试。

模块为纯叶子节点,禁止 import app.storage(防 storage→analytics 成环)。
7 例测试覆盖:new 窗口边界(as_of-6 含/不含)、回流 35/9/31 天与恰好
30 天、last_day==as_of 门槛、6/7/29/30 天落位、历史门槛 4/5、
day_add 跨月。全量 643 绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 13:37:22 +08:00
浅念 58d274096d fix(management): first_reply_fact 同毫秒并列主键冲突+定序抖动——reply_msg_id 进 PK/按(ts,msg_id)排序
评审修正(代码质量评审 Critical#1 + Important#2/#3):
- reply_msg_id 进复合 PK(模型+migration b7c8d9e0f1a2 原地改,本地
  down/up 重建,表空未部署):同毫秒 F,T,F,T 交换的两个样本
  (thread_end_ts, reply_ts) 完全并列不再撞主键(原会中断整个
  recompute_all 事务);兼作下钻证据定位回复消息。
- load_conversation 增查 msg_id 并按 (ts, msg_id) 排序,
  compute_conversation 协议改 3 元组——SQL 等键顺序未定义,原实现
  同毫秒 [F,T] 归属随查询抖动(0/1 answered 摇摆)。
- recompute_pairs 每 500 会话显式 flush+进度日志(内存上界对齐
  summary.recompute_all 惯例,不再依赖隐式 autoflush)。
- 测试:旧 9 个更新协议;新增同毫秒 F,T,F,T 回归/并列确定性/
  空会话幂等清空,fr_sum 总量断言并入双串测试;
  test_management_models 构造补新主键列。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 13:18:08 +08:00
浅念 0b0dd0808b feat(management): 首响事实表跨日精确算法——跨夜串不丢/串末条归属日/同源日聚合
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 12:53:33 +08:00
浅念 373fb266a6 feat(management): 批次A建表迁移——六表幂等守卫/索引独立补齐/downgrade只删新表 2026-09-09 12:33:59 +08:00
浅念 2b1f91762b fix(management): 评审修正——config_json注解对齐NOT NULL+补3个约束测试
- EmployeeScoreConfig.config_json 去掉 | None,注解与 DDL(nullable=False)一致
- 删测试死 import datetime as dt
- 补约束测试: metric_daily 复合PK防重/ call_daily PK防重/ config_json NOT NULL
  (JSON 列 Python None 序列化为 'null' 字符串,测 NOT NULL 须 sa.null())
- EmployeeScoreConfig docstring 补 JSON 列整列重赋值注记

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 12:24:02 +08:00
浅念 e0c764997f feat(management): 批次A六表模型——fact/宽表/通话小表+评分配置/快照/辅导
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 12:04:54 +08:00
浅念 399ecc680f docs(management): Task0核实修正——微信peer主体为无后缀裸ID(96.3%)+未命中全为群通话
偏差#5 原三后缀列表会误导 Task3 peer 建模:实测 customer 微信侧 6,779/7,040
为裸 ID(96.3%),@openim 仅 261;message.talker_id 行数口径裸 ID 844,287。
⑤ 未命中成因精确化:voice_call 126 条未命中恰为 126 条 @chatroom 群通话,
剔除后 1:1 命中率 100%;wa 未命中 5 条全部为 @g.us 群通话。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 11:55:52 +08:00
浅念 2b516362cd fix(names): 会话/统计弹窗/zip 文件名的共享好友昵称读 link 快照
用户报 Lily 聊天窗口「聊天好友」列表里 A**郑 显示成 wxid_ir6uxy2li8no22
——A**郑是 14 个员工共享的客户,档案行(customer 全局单行 last-wins)挂在
别的员工名下,按 owner 查档案查空,三个显示位回退 wxid:

- sessions._meta(会话列表名字/头像/delete_flag)
- staff_chat_stats._customer_names(五数字弹窗明细)
- chat-html-zip 文件名

统一改为优先 customer_account_link per-owner 快照(link 未覆盖回退档案,
空表过渡安全)。staff_stats(统计分析新客户等)仍是档案口径,属分析语义
另行评估。613 测试绿(新增会话共享好友昵称 2 例)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 11:44:58 +08:00
浅念 b8c2bef6df docs(management): 批次A Task0核实记录——phone_call/customer/staff/群占比/回测名单
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 11:42:53 +08:00
浅念 5f6bfe1ff6 docs(management): 批次A计划评审修正——通话幽灵过滤+alembic head对齐
评审发现2真问题+5条cosmetic全修:
- recompute_call_days 补 known_user_ids 幽灵过滤(否则自家测试就挂)
- down_revision e6f7a8b9c0d1→f7a8b9c0d1e2(d428953 customer_account_link
  已推head,照抄会双head报错)
- 测试计数9/文件数4/新增30/import声明/删占位SQL
2026-09-09 10:42:19 +08:00
浅念 27f91e560d docs(management): 批次A数据底座实施计划——Task0-8全码级拆解
五项核实→六表模型/migration→跨日精确fact算法→生命周期纯函数→
通话小表→宽表(1:1口径/同源/幽灵过滤/纯生命周期日出行)→两条回填CLI→
本地全量实测对账。判别测试覆盖v2 §9全部防线(跨夜串/friend口径/
同源/超集/幂等)。批次B-F待批次A合入后另出计划
2026-09-09 10:34:32 +08:00
浅念 a866ea8abd docs(management): v2补通话小表决议——phone_call.channel是通话类型非消息渠道,渠道维宽表会双计
计划阶段发现:PhoneCall.channel ∈ {phone_call,voice_call}(models.py docstring
自证 calls are not tied to a messaging channel),v2把通话四列放进
(day,channel,user)宽表在channel=all聚合时双计。拆出employee_call_daily
(day,user_id)独立小表;§1.2补#9,P3重排#10-#14,§3改6张表,§4链路更新
2026-09-09 10:34:32 +08:00
浅念 d42895337d feat(link): customer_account_link 账号↔客户多对多——共享好友员工维度对齐云客
用户报「Lily 好友数 36,点进员工客户页只有 12」。真机探明根因:那 24 个
是共享客户(同一人加多个员工),云客 per-employee 各算一个;而 customer
表 PK=friend_id 全局单行、归属 last-wins,其他员工名下必然「消失」。
抓取器本身每员工都拿全了(日志 declared=36 rows=36),丢在入库模型层。

- 新表 customer_account_link(渠道×账号×客户一行,per-relation 快照:
  昵称/备注/头像/手机号/收发计数/最近沟通/add_time/fromType/isDel)
- 写:repos.upsert_customer 随档案同事务维护(所有好友抓取路径一处挂全);
  新增 prune_customer_links 对账
- 读:/api/customers?owner_account_id= 改读 link(与云客 contacts_num 口径
  一致);该账号 link 为空时回退档案路径(过渡安全);与 dept/user 等
  过滤组合时维持旧行为;CustomerOut 透传 from_type
- 抓取器:friends/wa-friends 逐员工整批 upsert 后按名单对账(取满声明
  total 才删,防半份名单误删)
- 前端:StaffFriends 数据来源列优先 link 的 from_type

611 测试绿(link 新 6 例);本地验证空表回退/造数接管/from_type 透传。
上线后需重跑 friends+wa-friends 回填 link(存量关系无法从档案反推)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-08 17:17:06 +08:00
浅念 4ad239597e docs(management): v2计划复核收尾——评审确认后修4处文档级问题
评审代理逐案例推演确认P1#5同源修正算法成立(Approval维持),遗留修正:
- §1.1#2修正列更新(串类三列由fact同源输出,不再说全量自算)
- §1.1#5去掉'>100%'夸大表述(单向低估,不可达)
- §9对账点限定消息计数列(串类列禁止相等断言)
- Task0口径只记录文档,配置落库归Task3种子CLI
- P2/P3行号全局重排(#6-#13),交叉引用同步
- §3.2负载列补数据源标注;时效说明措辞精确化

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-08 16:46:40 +08:00
浅念 8257086921 docs(management): v2计划第五轮复查——回复率分子分母同源修正(P1#5)+配置种子等补充
- P1#5:宽表串类三列改由fact计算模块同源输出(串末条归属日),
  不再取msg_daily_stats日切聚合——混口径会使多日未回串膨胀分母,
  回复率低估甚至>100%;msg计数/负载列与串算法无关仍取msg_daily_stats
- P2/P3:Task3补默认配置v1.0种子落库;时效说明;metric_version默认v1;
  call_followup多账号语义进Task0核实;继承索引补v1 §12.1/12.2;
  测试补分子分母同源判据(reply_rate<=1/threads_answered≡fact行数)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-08 16:40:35 +08:00
浅念 7a7eba766d docs(management): v2计划评审修正——引用定位/删减清单补全/口径表补活跃员工行
评审结论 Approved + 4 条 advisory 全部采纳:
- 降温频次定义/异常裁剪原则的引用从 v1 修正为上游整合计划
- 删减清单补 effective_sessions/activated_customer_count/cust_threads 更名说明
- 口径决议表补活跃员工(幽灵过滤)差异行

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-08 16:35:55 +08:00
浅念 c7b7df5011 docs(management): 经营管理后台v2实施计划——查漏补缺修订版
对 v1 逐项对照现有代码重新设计技术底座:
- P1修正:fact表跨日精确算法独立计算(跨夜串丢失/写钩子零改动);
  宽表1:1口径全量自算(stat_daily_staff混合口径不可拷贝);
  生命周期统一距今天数口径;对账判据预写死
- P2修正:分位数SQL窗口函数;full_sync 12→14步
- P3:核实清单/幽灵员工过滤/功能开关降级/五维图3+2占位

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-08 16:30:36 +08:00
浅念 2e526cd056 feat(staff-friends): 员工客户列表独立页——对齐云客员工详情,好友数/微信群数下钻
员工列表点「好友数」原先跳全局 /customers?owner_account_id=x,布局与全局
列表无异且计数/分页绑全局声明口径(同日 dfa4fdd 修的显示 bug),与云客
「员工名下客户列表」体验割裂。新页 /staff-friends/:ownerId/:tab:

- 微信客户/微信群两 tab(对齐云客截图);标题=员工显示名(入口直带,
  直链经 /api/accounts 解析),返回箭头回员工列表
- 搜索(昵称/微信号/备注名/手机号,服务端 q)+ 查询/导出 CSV
- 发送/接收条数=Customer.send_num/receive_num 累计值(CustomerOut 补
  两列,同步自云客 friend 记录,与截图同源)
- 勾选批量「导出聊天记录」:新端点 GET /api/export/chat-html-zip,复用
  chat-html 渲染器每会话一个 HTML 打 zip(文件名=备注>昵称>friend_id,
  非法字符清洗;单会话 5000 行截断告警;一次上限 50 会话防误选打爆)
- 行内:查看聊天记录→staff-chat;导出HTML聊天记录(时间范围弹窗)
- WA 行保持原入口(该页暂仅微信)

后端 605 测试绿(zip 新 4 例);本地浏览器端到端验证:过滤 9 条/勾选
激活/群 tab/返回箭头;zip 产物 application/zip 双会话双气泡。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-08 16:14:44 +08:00
浅念 dfa4fdd420 fix(customers): 好友数跳转分页用真实过滤数——声明口径仅无过滤时展示
员工列表点「好友数」跳 /customers?owner_account_id=x 后,头部计数与
el-pagination 的 :total 都绑 declaredTotals(云客口径全局声明值,Σ员工
好友数,不吃任何过滤)——列表只有该员工 9 条,分页却按全部客户 8956 条/
1792 页展示,用户报「不是员工下面的好友列表」。

修:新增 displayTotal()——无过滤时保持声明口径(页脚对齐云客的产品
定调不变),department/owner 任一过滤生效时回到 tabState 真实 total。
头部计数与微信/WA 两处分页器三处统一走它。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-08 14:27:25 +08:00
81 changed files with 16386 additions and 266 deletions
+26
View File
@@ -228,6 +228,32 @@ alembic 链自当前线上版本起新增 3 支迁移:`a7d3e9f1c2b4` msg_daily_s
幂等自动补种,无需手插;后续加新词默认只扫增量,要扫历史消息需重跑 幂等自动补种,无需手插;后续加新词默认只扫增量,要扫历史消息需重跑
`--full`。 `--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. 本地收尾(部署验证通过后) ## 8. 本地收尾(部署验证通过后)
```bash ```bash
@@ -0,0 +1,411 @@
# 批次A Task 0 核实记录:五项核实 + 回测名单
- **日期**:2026-09-09
- **方式**:`docker exec -i yunke-mysql-1 mysql -uyunke -pyunke yunke -e "<SQL>"`(容器名以 `docker ps` 实测为准,确认为 `yunke-mysql-1`)
- **用途**:本记录的全部结论直接约束批次A(数据底座)后续任务的实现细节;偏差处显式标注「影响 Task N」。任务编号引用 [2026-09-08-management-console-batch-a-data-foundation.md](./2026-09-08-management-console-batch-a-data-foundation.md)。
- **数据快照范围**:`stat_daily_staff` / `msg_daily_stats` 覆盖 20251024 ~ 20260907;`phone_call.start_call_time` 覆盖 2025-10-18 ~ 2026-09-05。
---
## ① 员工主数据(staff 表)
| 指标 | 实测 |
|---|---|
| staff 总数 | **41**(与计划假设 ≈41 一致) |
| `is_on_job=1` | 41(全部在职) |
| `wechat_account` DISTINCT user_id | 41 |
| `whatsapp_account` DISTINCT user_id | 20 |
| 微信幽灵账号(user_id 不在 staff) | **0** |
| WA 幽灵账号(补充核实) | **0** |
| `stat_daily_staff` DISTINCT user_id | 40(其 user_id 全部在 staff 内;员工 `FD685A5620184156BE75A8D73FB72F64` 无任何消息统计行) |
**结论**:账号表 → staff 无幽灵映射,`known_user_ids` 过滤(v2 §1.3 #11)零损耗,不会意外丢行。两个渠道的 account_map 产出 user_id 均能落到 staff。**无偏差,Task 5/6 按计划实现即可。**
---
## ② phone_call 列与单位
表结构关键列:`call_id`(PK)、`channel`、`start_call_time`(bigint)、`call_duration`(int)、`call_status`(int)、`incoming_call`(int)、`user_id`、`call_number`、`fetched_at`、`updated_at`。
### channel 分布
| channel | 行数 |
|---|---|
| `voice_call` | 2,504 |
| `wa_voice_call` | 686 |
| 合计 | **3,190**(与已知 3,190 条一致) |
### call_duration 单位:**秒**(确认)
| 指标 | 实测 |
|---|---|
| 总和 | 263,651 秒 = **73.24 小时**(对照已知约 73 小时,吻合) |
| 均值 | 82.65 秒(对照预期均值 ≈82 秒,吻合) |
### call_status × duration 交叉
| call_status | 行数 | duration=0 | duration>0 | 语义判定 |
|---|---|---|---|---|
| 1 | 1,508 | 31 | 1,477 | **接通**(含 31 条 0 秒即挂) |
| 2 | 1,682 | 1,682 | 0 | **未接通**(未接/拒接/取消,与方向无关) |
### incoming_call 分布
| incoming_call | 行数 | 语义 |
|---|---|---|
| 1 | 1,764 | 呼入 |
| 0 | 1,426 | 呼出 |
### start_call_time:**毫秒 epoch**(确认)
13 位整数;`FROM_UNIXTIME(x/1000)` 解出 max = 2026-09-05 16:00:55(合理近期),按秒解释则超范围。min = 2025-10-18。与 `message.timestamp` 毫秒同口径。
### call_number 形态(按 channel 完全可分)
- `voice_call` → 微信 ID:`wxid_*` 或自定义微信号(`csf7169708`、`yuanye751380`、`Maple_1988` 等)
- `wa_voice_call` → WA JID:`2348182388888@s.whatsapp.net` 等
### 与计划默认假设的偏差(重要)
1. **计划假设 #4「PhoneCall.channel 是通话类型(phone_call/voice_call)非消息渠道」不完全成立**:实测无 `phone_call`(蜂窝)值,且 `wa_voice_call` **能**从 channel 列判别 WA。计划 Task 5 注释「voice_call 行无法从列判别微信/WA」不成立。**影响 Task 5**:`_followup_exists` 可按渠道收窄——`voice_call` 只查该员工 wechat 账号的 1:1 消息、`wa_voice_call` 只查 whatsapp 账号(当前双渠道任一匹配的写法也不会错——wxid 不会撞 WA JID,仅多一次无效查询;收窄属优化非必需)。
2. `call_duration` 单位=秒、`start_call_time`=毫秒 epoch:**与计划默认一致,Task 5 无需换算**(`day_bounds_ms` 直接用)。
3. 接通判定:计划默认 `duration>0`。实测 `call_status=1 AND duration>0` = 1,477 行,`duration>0` 口径与之差 31 行(31 条接通但 0 秒,占 1.0%)。**影响 Task 5**:`answered=dur>0` 口径可用;若追求语义精确可改 `call_status=1`(含 0 秒接通),差异 ≤1%,实现时任选其一并在表注释写明。
---
## ③ customer 归属字段填充率
| 指标 | 实测 |
|---|---|
| 总行数 | 16,339 |
| `user_id` 填充 | 16,339(**100%**) |
| `owner_account_id` 填充 | 16,339(**100%**) |
| `delete_flag=1` | 53 |
分渠道:wechat 7,040(owner 填充 7,040)/ whatsapp 9,299(owner 填充 9,299)。
### 两条归属路径一致性(全表校验,非抽样)
`customer.user_id` vs `owner_account_id → wechat_account.wx_id / whatsapp_account.uid → user_id`:
| 渠道 | 校验行数 | 一致 |
|---|---|---|
| whatsapp | 9,299 | 9,299 |
| wechat | 7,040 | 7,040 |
**结论**:两条路径 100% 等价、零冲突。生命周期归属覆盖面完整。**影响 Task 6**:默认 `owner_account_id → account_map → user_id` 路径确认可用;`customer.user_id` 列可直接当冗余等价路径使用(如需省一次 join,实测安全)。
补充观察(**影响 Task 3/6 的 peer 对齐**):**微信侧主体是无后缀裸 ID,后缀形态是少数**。`customer.friend_id` 实测形态分布(全表):
| 渠道 | 形态 | 行数 | 占比 |
|---|---|---|---|
| wechat | **无后缀裸 ID**(`a_515855826`、`a053136a` 等) | 6,779 | **96.3%** |
| wechat | `@openim` 后缀 | 261 | 3.7% |
| whatsapp | `@s.whatsapp.net` | 9,266 | 99.6% |
| whatsapp | `@lid` 后缀 | 33 | 0.4% |
`message.talker_id` 侧同查(行数口径):wechat 裸 ID 844,287 / `@openim` 16,873 / `@chatroom` 17,003(群);whatsapp `@s.whatsapp.net` 72,357 / `@lid` 4,284 / `@g.us` 9,439(群)。即:微信 peer 主键以裸 ID 为准、`@openim` 为少数共存;WA 侧基本全带后缀。Task 3 实现 peer 匹配时按实测形态对齐(两边原值相等才命中),不做猜测性截断/补后缀。
---
## ④ 群占比(msg_daily_stats,量化混合口径偏差)
| channel | peer_kind | 行数 | 消息量(send+recv) | 会话线程(cust_threads) |
|---|---|---|---|---|
| wechat | friend | 38,343 | 269,074 | 82,879 |
| wechat | room | 16,234 | 609,089 | 26,220 |
| whatsapp | friend | 11,673 | 77,994 | 14,178 |
| whatsapp | room | 866 | 8,086 | 1,402 |
| **合计** | | **67,116** | **964,243** | **124,679** |
占比汇总:
| 维度 | room 占比 | 说明 |
|---|---|---|
| 微信消息量 | **69.4%**(609,089/878,163) | 微信消息近七成来自群 |
| WA 消息量 | 9.4%(8,086/86,080) | WA 几乎纯 1:1 |
| 总消息量 | **64.0%**(617,175/964,243) | |
| 总会话线程 | **22.2%**(27,622/124,679) | |
**结论**:若现有看板首响/回复率不分 `peer_kind`,消息量维度被群污染 64%、线程维度 22%——污染主要来自微信侧(微信消息 69% 是群消息)。批次A `first_reply_fact` / `employee_metric_daily` 强制 `peer_kind='friend'` 过滤是必要的,不能沿用混合口径。
---
## ⑤ call_followup join 键(call_number ↔ message.talker_id,1:1 消息)
| channel | 分母(call_number 非空) | 命中(talker_id 匹配且非群消息) | 命中率 |
|---|---|---|---|
| `voice_call` | 2,504 | 2,378 | **95.0%** |
| `wa_voice_call`(补充核实) | 686 | 681 | **99.3%** |
**结论**:双渠道命中率均远超 50% 阈值,`call_followup_count` 一期**高置信**,无需降级标记、无需在口径中注记低置信。**影响 Task 5**:24h 跟进判定按计划实现。未命中成因已精确解释(复核补充):`voice_call` 的 126 条未命中**恰好等于** `call_number` 带 `@chatroom` 后缀的 126 条群通话(实测这 126 条对 1:1 消息全部 0 命中——join 限定 `room_id` 为空,群通话本就不该匹配);剔除群通话后 1:1 可匹配通话命中率为 **2,378/2,378 = 100%**。`wa_voice_call` 侧后缀分布为 `@s.whatsapp.net` 676 / `@g.us`(群)9 / `@lid` 1,未命中 5 条**全部为** `@g.us` 群通话(逐条列出验证)。
---
## 回测员工名单
选取依据:消息量/WA 量/活跃群排名 + 低样本端(见文末各候选分渠道数据)。覆盖:微信为主、WA 为主、群运营为主、高负载、低样本(前三类与高负载有合并)。
| # | user_id | 名字 | 覆盖类型 | 关键数据(20251024~20260907) |
|---|---|---|---|---|
| 1 | `369D257EEC9A4357935FDE19DDD99CAA` | 王云青 | **高负载 + 群运营为主 + 微信为主**(合并) | 消息量 #1(162,740:微信 162,371 / WA 369);活跃群 #1(4,036);通话 431+9 |
| 2 | `9C0B195A2C234C829FF507E011B4B460` | 田瑞志 | **WA 为主** | WA 消息 9,986 / 总 11,597 = 86%;WA 通话 28 |
| 3 | `F99F6557C2904A7E8F4C5A3BD8A597A1` | 张士蓓 | **双通道混合**(WA 量 #1,群 #2) | 微信 91,936 + WA 12,529;活跃群 2,273;通话最多(471 微信 + 97 WA) |
| 4 | `DC48798A5EA24DBB8B31A89CB858E08B` | 15639270965 | **纯微信无 WA 行**(验证 WA 缺失路径) | 微信 78,680,`stat_daily_staff` 无 whatsapp 行;通话 3 |
| 5 | `946C08639BF84DFEBBF5EC2A607AE1FA` | 18539430257 | **低样本**(验证空数据路径) | 消息 8 条,无群、无通话、无 WA |
> 名单中的 #1/#3 建议作为 Task 8「本地全量回填实测」的 2 名回测员工(数据最厚、双通道+通话+群全齐)。
---
## 偏差汇总(对后续任务的约束清单)
| # | 偏差/确认 | 影响 |
|---|---|---|
| 1 | `phone_call.channel` 实测仅 `voice_call`(微信)/`wa_voice_call`(WA)两值,**无蜂窝 `phone_call` 值**;channel 可判别渠道,计划「无法从列判别微信/WA」结论修正 | Task 5(followup 可按渠道收窄,属优化非必需) |
| 2 | `call_duration` 单位=**秒**(263,651s=73.24h≈已知 73h);`start_call_time`=**毫秒 epoch** | Task 5(默认口径正确,无需换算) |
| 3 | 接通判定 `dur>0` vs `call_status=1` 差 31 行(1.0%),前者少计 31 条 0 秒接通 | Task 5(任选其一,注释写明) |
| 4 | `customer.owner_account_id` 填充 **100%**,与 `user_id` 列全表 100% 等价 | Task 6(默认 owner 路径确认;可直接用 user_id 列省 join) |
| 5 | 微信侧 peer ID **主体为无后缀裸 ID**(customer.friend_id 6,779/7,040=96.3%;message.talker_id 行数口径 844,287 裸 vs 16,873 `@openim`),后缀形态(`@openim` 等)为少数;WA 侧基本全带后缀(`@s.whatsapp.net`/`@lid`) | Task 3/6(peer 对齐按实测形态原值匹配,勿截断/勿补后缀) |
| 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/敏感词扫描的真实活跃量级须在服务器侧(夜间链已跑)观察 |
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,318 @@
# 云客经营管理后台实施计划 v2(贴合代码修订版)
- **日期**:2026-09-08
- **版本**:v2.0
- **状态**:待评审后进入开发
- **与 v1 的关系**:替代 `2026-09-08-yunke-management-console-implementation.md`(plans/ 与 specs/ 两份副本内容相同)的**技术设计部分**;产品范围、页面内容细则、验收标准沿用 v1,本文不重复,冲突处以本文为准
- **上游方案**:[云客数据智能管理平台完整实施计划](./2026-09-08-yunke-data-intelligence-management-plan.md)
- **修订方式**:对 v1 逐项做「现有代码 ↔ 文档要求」查漏补缺,钉在真实代码事实上重新设计底座
---
## 0. 已确认的决策记录
| 决策项 | 结论 |
|---|---|
| 一期范围 | 按 v1 原计划全量:管理驾驶舱 + 员工总览 + 员工详情 + 考核快照(Task 0-13) |
| 业务口径 | 全按 v1 §21 默认值:自然时间 SLA、2h+24h 双 SLA、30 天沉睡、综合分标「试算」、CSV 导出、仅管理层可见、个人基线=过去 4 周 |
| 底座方案 | 方案 B:新增首响事实表 + 收敛版员工宽表(v1 架构骨架不变) |
| 关键修正 | 见 §1 修订总览——fact 表独立计算(跨日精确算法)、宽表 1:1 口径全量自算、生命周期沿用距今天数口径 |
---
## 1. 相对 v1 的修订总览
### 1.1 P1 级(正确性/口径,必须修正)
| # | 问题 | 证据 | 修正 |
|---|---|---|---|
| 1 | **跨夜串丢失**:现有日切算法下「客户 23:00 发→员工次日 9:00 回」的首响样本整条丢失(Day1 串未闭合只计 threads;Day2 重起算时无客户消息不成串,员工回复落空段)。P90/24h 回复率系统性低估,而评分重度依赖这两项 | `app/storage/summary.py` `_thread_stats` 日切近似,docstring 自述「跨夜串归属日偏差」实为样本丢失 | fact 表**不挂写钩子**,由 4h 滚动+夜间全量任务用**跨日精确串算法**(按会话全历史计算)独立生成;写钩子零改动,`_thread_stats` 不改 |
| 2 | **stat_daily_staff 是 friend+room 混合口径**:`sent/recv/threads/answered/fr` 五列聚合无 kind 过滤,群消息污染首响/回复率。v1 说「从 stat_daily_staff 拷贝」不可行 | `app/storage/staff_summary.py` `recompute_days` 聚合循环 | 宽表消息计数列从 msg_daily_stats 按 `peer_kind='friend'` 自算(account_map 归并模式复用);串类三列由 fact 模块同源输出(见 #5);管理页与现有看板排行的数字差异属口径不同,各自标注 |
| 3 | **「降温」定义冲突**:上游整合计划(`2026-09-08-yunke-data-intelligence-management-plan.md` §8.1/§15)降温=频次低于个人基线;现有 churn/客户资产分层=距今天数区间。两套「降温」会并存打架 | `app/analytics/metrics.py` churn 三分法 | 一期生命周期 4 列统一用**现有距今天数口径**;频次基线降温后置二期;差异写入 metric-definitions |
| 4 | **对账口径必须预写死**:fact(跨日精确)与 msg_daily_stats 三列(日切近似)即使 friend 口径也不相等,差集=跨夜闭合串 | 同 #1 | Task 2 验收断言:fact 样本集 ⊇ 日切被回串,差集=跨夜串;禁止相等断言 |
| 5 | **回复率分子分母必须同源**:v2 初稿宽表串类列(cust_threads 等)取自 msg_daily_stats 日切聚合,而回复率分子取自 fact(跨日)——多日未回串使日切分母膨胀,回复率被系统性低估 | 本轮复查发现(#2 修正的连带遗漏) | 宽表串类三列改由 fact 计算模块**同源输出**(按串末条归属日的 threads/answered/fr_sum);msg_sent/recv 与 active_friends/rooms 与串算法无关,仍取 msg_daily_stats |
### 1.2 P2 级(性能/实现修正)
| # | 问题 | 修正 |
|---|---|---|
| 6 | 周期分位数若 Python 拉全窗口 fact 行(月窗最坏 ~8 万行 ≈10MB 传输)不可取 | 改 SQL 窗口函数 `PERCENT_RANK() OVER (PARTITION BY user_id ORDER BY fr_ms)`(MySQL 8.4 / SQLite 3.25+ 双方言支持),传输降为员工数×2 行 |
| 7 | fact 4h 增量成本 | 滚动 3 天=近 3 天活跃会话**全历史重算**(最大 friend 会话 5.2 万条 ≈1-2s × 几十活跃会话,4h 一次可接受);一期取简单正确,预留「尾部增量」优化(历史已闭合串不变) |
| 8 | full_sync_all.sh 步数变化 | 12→**14** 步:第 13 步 fact 全量重建、第 14 步宽表全量重建(宽表必须在 fact 之后) |
| 9 | **通话列渠道错位**:v2 初稿把通话四列放进 (day, channel, user) 宽表,但 `phone_call.channel` 是**通话类型**(phone_call/voice_call)非消息渠道(models.py docstring 自证「calls are not tied to a messaging channel」)——channel=all 聚合时通话四列双计 | `app/db/models.py` PhoneCall 定义(批次A计划定案) | 通话拆出独立小表 `employee_call_daily`(day, user_id 主键,无渠道维),宽表删去通话四列 |
### 1.3 P3 级(补充明确/简化)
| # | 项 | 结论 |
|---|---|---|
| 10 | 员工主数据来源未核实 | 列入 Task 0 核实(默认 = `staff` 表,user_id 主键 + is_on_job,models.py 已有) |
| 11 | 幽灵员工 | account_map 映射不上的 owner(离职/异常账号),宽表生成时直接**过滤**,不进管理域员工列表(评分场景不适用 analytics 的「读侧标注」策略) |
| 12 | 功能开关降级 | 现状单 admin 账号无角色可分,v1 Task 13 的「功能开关」是过度设计;改为**上线顺序控制**(本地先行→服务器后上),RBAC 二/三期(与 v1 §15 一致) |
| 13 | fr_ms 边界 | 按 ts 排序保证非负;超长样本(>30 天)保留在 fact,评分按异常裁剪原则处理(上游整合计划 §6.5「极端值不直接放大分数」):>30 天样本参与分布展示,但不作为 0 分依据,单独列入超时明细 |
| 14 | 五维分布图 | 一期只画 3 个可用维度(客户经营/服务质量/有效负载)+ 2 个「待数据」占位(行动执行/风险合规),不暗示完整五维 |
---
## 2. 查漏补缺:复用映射总表
| v1 能力项 | 现有可复用 | 本次动作 |
|---|---|---|
| 员工日指标底座 | `stat_daily_staff`(8 列,混合口径)、`msg_daily_stats`(首响三列,日切近似) | 两表**均不动**;新增 `first_reply_fact` + `employee_metric_daily`(§3) |
| 时间窗/环比口径 | `app/analytics/metrics.py` `scope_day_keys`/`_prev_scope_days`(5 种窗+同期对齐) | management 层直接 import,不复制时区逻辑 |
| 生命周期分层 | `metrics.py` 客户资产分层 / churn 三分法(距今天数口径) | 提炼共享函数,日聚合任务与 analytics 共用;analytics 调用点保持不变(包装层) |
| 首响均值/回复率算法 | `staff_ranking` 的 fr_avg/reply_rate | 周期值改为宽表 SUM + fact 表精确分位 |
| 异常检测雏形 | `anomaly`(昨日 vs 前 7 日均值偏离>50%) | `management/anomalies.py` 扩展为规则集:回复恶化(fact P90)、待回复增长(B2)、活动骤降/过载(宽表) |
| 驾驶舱页面 | `DashboardView.vue` 的图表模式、`useChart`、筛选栏、骨架屏/空态分语 | 新建 ManagementDashboardView,复用全部 composables/utils;旧页并存过渡后重定向 |
| 流失/该回未回/敏感词 | 三个现有页面 | 不动,作为下钻目标 + 驾驶舱行动入口聚合 |
| 数据截至/新鲜度 | `app/analytics/health.py` 的 data_as_of 逻辑 | 管理页「数据截至/完整性」角标直接复用 |
| 调度 | `analytics_tick`(4h:staff 汇总+敏感扫描) | 追加第三步:fact 滚动重算 + 宽表滚动重算 |
| 回填/自愈 | `full_sync_all.sh` 第 11/12 步 | 追加第 13/14 步(fact/宽表全量重建)+ 两条回填 CLI |
| 员工事实下钻 | `/staff-chat`、`/chat`、`/groups`、`/calls` | 不动,管理页只做链接跳转 |
| CSV 导出 | `csvExport.ts`(全量翻页+中文 BOM) | 直接复用 |
---
## 3. 数据库设计:6 张新表
### 3.1 `first_reply_fact` 首响事实表(本设计新增)
**用途**:任意时间窗精确 P50/P90、2h/24h 回复率分子、指标解释抽屉的构成明细下钻证据。
**算法(跨日精确串口径,区别于 msg_daily_stats 的日切近似)**:
- 串 = 同一会话内客户连续消息(is_send=False)不被员工消息(is_send=True)打断的段;is_send=None 跳过;
- 每个被回串一个样本:`fr_ms = 员工首条回复 ts − 串末条客户消息 ts`;
- 样本归属 = 串末条消息所在**上海自然日**(跨夜串不再丢失);
- 只写 `peer_kind='friend'`(1:1)行——群串语义不成立且占串数大头,不写;
- 模块同时输出**每日串聚合**(threads/answered/fr_sum,同按串末条归属日)供宽表消费——回复率分子分母同源(§1.1 #5);
- 按会话全历史计算,幂等模式=按会话 delete+insert。
**表结构**:
```text
唯一键: (channel, owner_account_id, peer_id, thread_end_ts, reply_ts)
列: day int 串末条所在上海日 yyyymmdd(冗余物化,索引用)
thread_end_ts BigInteger 串末条客户消息 ts(ms)
reply_ts BigInteger 员工首条回复 ts(ms)
fr_ms BigInteger
索引: (day, channel) 趋势/窗口查询;唯一键自身覆盖会话下钻
```
**生成链路(不挂写钩子)**:
- 4h tick:对近 3 天有消息的会话(从 msg_daily_stats 近 3 天行取 pairs)做全历史重算;
- 夜间全量:full_sync_all.sh 第 13 步,遍历全部 friend 会话(复用 `recompute_all` 的 pairs 遍历模式);
- 回填 CLI:`python -m app.sync.cli first-reply-facts`,分批可重跑可记水位;
- 量级:日均几百行、全年几十万行,远小于 message 表。
### 3.2 `employee_metric_daily` 员工指标宽表(v1 表的收敛版)
**口径总则**:消息串类列 = 1:1(friend-only)口径,从 msg_daily_stats 自算;与现有看板(friend+room 混合)的数字差异属口径不同。
```text
PK: (day, channel, user_id, metric_version)
消息计数列(msg_daily_stats 自算,friend 过滤 + account_map 归并;与串算法无关):
msg_sent, msg_recv
串类列(fact 计算模块同源输出,跨日精确、按串末条归属日——与回复率分子同口径):
cust_threads, threads_answered, fr_sum_ms
负载列(msg_daily_stats,friend/room 分行天然区分):
active_friends(1:1 distinct 客户), active_rooms(distinct 群)
首响日值(趋势图用,从 fact 算):
first_reply_p50_ms, first_reply_p90_ms
生命周期 4 列(距今天数口径,当日持有客户状态分布,按 customer 当前归属):
new_customer_count, cooling_customer_count,
dormant_customer_count, reactivated_customer_count
元数据:
data_complete, computed_at
```
**通话小表 `employee_call_daily`**(§1.2 #9 拆出,无渠道维):
```text
PK: (day, user_id)
列: call_count, call_seconds, answered_call_count, call_followup_count, computed_at
数据源: phone_call 按 user_id+start_call_time 日聚合;接通=call_duration>0;
followup=通话后 24h 内该员工任一账号对该客户有消息(Task 0 核实 join 键)
```
**相对 v1 的删减与更名**:
- 6 列可派生不落:`reply_2h/24h_count`、`unreplied_2h/24h_count`(fact 表 fr_ms≤阈值计数 + cust_threads 派生)、`first_reply_count/first_reply_sum_ms`(≡ threads_answered/fr_sum_ms);
- `effective_sessions` 不单列:v1 §6.3 工作负载视图的「有效会话」≡ cust_threads(1:1 有客户消息串的天级会话数),页面字段名从 cust_threads 映射;
- `activated_customer_count` 一期不做:「有效激活」定义是 v1 §21 #4 未决口径(几轮互动/几天窗口),随二期频次基线生命周期一起定义;
- `customer_threads` 更名为 `cust_threads`(与 msg_daily_stats/stat_daily_staff 现有列名对齐)。
**metric_version 语义**:只影响派生列(生命周期/通话跟进的算法口径);消息计数与串类事实列与版本无关。默认值 `v1`,算法演进时新版本并行落表不覆盖旧数据。
**时效说明**:消息计数列的源数据(msg_daily_stats)随写钩子近实时更新,串类列的源数据(fact)随 4h/夜间重算更新——两类列的上游时效不同,但宽表行统一随 4h/夜间重算落库;computed_at 取宽表生成时间,data_complete 标注 fact 是否已覆盖当日。
**评分用周期值的计算来源**(不在宽表存储):周期 P50/P90、2h/24h 回复率一律从 fact 表精确计算(SQL 窗口函数),宽表日值仅供趋势展示。
### 3.3 `employee_score_config` / `employee_score_snapshot` / `management_followup`
按 v1 §11.1 原样实施,无修改:
- config:version 唯一、status、effective_from/to、config_json(维度/指标/权重/方向/阈值/样本门槛),激活前权重总和与阈值校验;
- snapshot:snapshot_batch_id、period_type/period、score_config_version、metric_version、total_score、grade、score_status(scored/insufficient_sample/incomplete_data)、dimension_scores_json、metric_details_json、data_as_of、frozen_at/frozen_by、supersedes_id;同批次员工唯一;冻结后业务层禁改;
- followup:source_type/code/period/value、reason、action_type、owner_manager_id、status(open/in_progress/resolved/cancelled)、due_at、result、employee_feedback;索引 `(employee_user_id, status, due_at)`、`(owner_manager_id, status)`。
### 3.4 迁移要求
- 一个 Alembic migration 建 5 张表;migration 只建表和索引,**不扫描消息**;
- 数据回填走独立 CLI 分批执行(可暂停/重跑/记水位),先在本地 Docker 快照验证耗时与一致性;
- downgrade 只删本次新增对象;SQLite(测试)/MySQL(生产)双方言;
- 升级前确认当前 head(服务器注意:backend 容器**不自动跑 alembic**,须手动 `exec backend alembic upgrade head`)。
---
## 4. 计算链路与调度
```text
message 入库(写钩子,全路径覆盖——现状,零改动)
→ msg_daily_stats [summary.py]
→ stat_daily_staff [staff_summary.py,现状]
message(按会话全历史,跨日精确算法) [新 storage/first_reply_facts.py]
→ first_reply_fact
│
└─→ employee_metric_daily [新 storage/employee_metrics.py:
msg 计数(msg_daily_stats,friend) + 串类/分位(fact 同源)
+ customer 生命周期]
phone_call → employee_call_daily [通话小表,无渠道维,§1.2 #9]
→ 期聚合/四对比(上期/个人基线4周/团队中位数)
→ 试算评分 + 异常规则 [management/]
→ 周/月快照(不可变) [management/snapshots]
```
- **4h**:`analytics_tick` 追加第三步:fact 滚动重算(近 3 天活跃会话)→ 宽表重算(当日+滚动 3 天);失败只记日志不中断(沿用现有异常隔离模式);
- **夜间**:full_sync_all.sh 第 13 步 fact 全量重建、第 14 步宽表全量重建(顺序:必须在 msg_daily_stats/stat_daily_staff 之后);
- **生命周期历史快照**:补同步触及历史日期时不能只重算那几天(那天的「距上次互动天数」依赖更早历史),统一由夜间全量按会话日序列重放覆盖;4h tick 只算当日;
- **通话后跟进**(call_followup_count):3,190 条通话量级小,逐通话窄查 24h 内员工对该客户的消息(join 键以 Task 0 核实结果为准),聚合任务内完成;
- **性能原则**:管理页面禁止实时扫描 96 万条原始消息;趋势和员工表读宽表+fact;原始消息只用于小范围下钻。
---
## 5. 口径决议表(管理域 vs 现有看板)
| 指标 | 管理域口径 | 现有看板口径 | 差异原因 |
|---|---|---|---|
| 消息量/串/首响均值 | 1:1(friend-only) | friend+room 混合 | 服务质量语义要求 1:1 |
| 首响分位 | 跨日精确串(fact) | 日切近似(跨夜丢失) | P90 正确性 |
| 降温客户 | 距今天数区间(同 churn 页) | 同 | 一期统一,频次基线降温二期 |
| 活跃员工 | 宽表口径(幽灵员工已过滤,§1.3 #10) | `_active_staff` 含映射不上的账号并读侧标注 | 评分场景不接受幽灵员工 |
| 交互天数/日桶 | Asia/Shanghai 自然日 | 同 | 一致 |
三方差异(管理页 vs 数据看板 vs 云客原版)在 metric-definitions 端点与页面口径说明中如实标注,不做静默差异。
---
## 6. API 设计(`/api/management/*`)
沿用 v1 §12.3 的 11 个端点与通用响应约定(比例 0~1、时长带单位后缀、空值≠0、稳定 error_code、data_as_of+metric_version+sample_status 必返),实现落点:
| 端点 | 关键实现落点 |
|---|---|
| `GET /dashboard` | 一次返回 KPI/双趋势/生命周期/维度分布/异常/行动聚合;data_as_of 复用 health.py |
| `GET /employees` | 服务端分页排序;默认按异常严重度排(不做总分榜);每员工带四对比值+sample_status |
| `GET /employees/{user_id}` | 首屏只回头部+维度分+趋势摘要+异常+辅导摘要 |
| `GET /employees/{user_id}/metrics?section=` | service/customer/workload/calls_groups 四页签懒加载;行动/风险页签一期不建 |
| `GET /employees/{user_id}/drilldown?metric_code=` | **白名单**:`first_reply_p90`→fact 逐串明细(含跳聊天定位)、`unreplied`→复用 B2、`cooling_customers`→客户列表带筛选、`call_followup`→通话列表。禁止前端传 SQL 字段/表达式 |
| `GET /metric-definitions` | 指标元数据(口径/单位/方向/样本门槛/公式/版本),含 §5 差异标注 |
| `GET/POST /reviews` | 草稿幂等生成(同 period+config 已有 draft 返回既有批次);`data_complete=false` 拒绝冻结 |
| `POST /reviews/{batch_id}/freeze` | 仅 draft→frozen;MVP 不做 published(RBAC 前置) |
| `GET/POST/PATCH /followups` | 状态机白名单流转 |
**周期分位数**:SQL 窗口函数 `PERCENT_RANK() OVER (PARTITION BY user_id ORDER BY fr_ms)` 取最接近 0.5/0.9 分位的行,双方言一致。
**一期评分**:综合分 = 可用维度(客户经营 30 + 服务质量 25 + 有效负载 15 = 70%)按 `100/70` 归一化 + **「试算」角标**;行动执行/风险维度显示「待数据」,不补 0 不补满分(v1 §10.1 默认)。
---
## 7. 前端设计
- **路由**:`/management/dashboard`、`/management/employees`、`/management/employees/:userId`(activeMenu 高亮父级)、`/management/reviews`;MainLayout 新增「经营管理」分组(二期菜单不渲染、不放空页面);`/analytics/dashboard` 过渡期并存、稳定后重定向;
- **目录**:`views/management/` 四页 + `components/management/`(ManagementFilterBar/ManagementKpiCard/ComparisonValue/DataAsOfBadge/ScoreBar/ScoreBreakdown/EmployeeMetricsTable/MetricDefinitionDrawer/MetricDrilldownDrawer/AnomalyList/FollowupDrawer)+ `composables/useManagementFilters.ts`(URL query 为真源)+ `api/management.ts`;
- **复用清单**:useChart(维度分布用分组条形图,BarChart 已注册,**不引雷达图**)、axios client 的 token/401 处理、`utils/format.ts`、`csvExport.ts`、seq 守卫防旧响应覆盖、DashboardView 的骨架屏/空态分语模式;
- **展示规范**:样本不足显示 `--` 与「样本不足」(非 0 分);「越低越好」指标反转趋势色;1366px 主表可用;导出与当前筛选排序一致。
---
## 8. 任务拆分(v1 Task 0-13 修正版)
| 任务 | 内容 | 相对 v1 的变化 | 估时 |
|---|---|---|---|
| Task 0 | 口径冻结:回测员工名单(3-5 名覆盖微信为主/WA 为主/群为主/高负载/低样本)+ 默认口径记录成文档(配置落库在 Task 3 种子 CLI,本任务不改代码)+ **五项核实**(phone_call 表结构与客户标识 join 键、call_followup 多账号语义——员工任一账号跟进即算、customer 归属字段与换人规则、员工主数据来源、群占比抽样量化混合口径偏差) | 默认值已确认,工作量缩减 | 0.5 天 |
| Task 1 | 5 张表模型 + migration + 模型冒烟测试 | 6→5 张表;核实项前置完成 | 1-1.5 天 |
| Task 2 | fact 独立计算模块(跨日精确算法,同源输出每日串聚合供宽表)+ 宽表生成(消息计数 friend 自算+串类取 fact+生命周期+通话)+ 生命周期共享函数提炼(analytics 调用点不动)+ 两条回填 CLI | **删除「_thread_stats 兼容扩展」**(不再需要);验收含对账判据(§1.1 #4/#5)+ 写钩子零改动确认 | 2.5-3.5 天 |
| Task 3 | scoring.py:归一化/四对比/样本保护 + 配置校验 + **默认配置 v1.0 种子落库**(受控 CLI,Task 11 快照的前置依赖) | 含 100/70 归一试算逻辑 | 2 天 |
| Task 4 | anomalies.py 规则集 | 数据源:fact(P90 恶化)、B2(待回复增长)、宽表(骤降/过载) | 1 天 |
| Task 5 | 管理 API(dashboard/employees/detail/metrics/drilldown/metric-definitions) | 窗口函数分位 + 性能目标 | 2-3 天 |
| Task 6 | 前端公共组件与路由(api/management.ts、useManagementFilters、components、router、MainLayout) | 按文档 | 1.5-2 天 |
| Task 7 | ManagementDashboardView(KPI/双趋势/生命周期/异常榜/下钻) | 复用图表模式,估时较 v1 略降 | 2-2.5 天 |
| Task 8 | EmployeeAnalyticsView(一期 4 视图+分页排序+指标抽屉+员工对比) | 行动/风险视图不建 | 2-3 天 |
| Task 9 | EmployeeAnalyticsDetailView(概览/服务质量/客户经营/工作负载/通话与群五页签) | 按文档 | 3-4 天 |
| Task 10 | followups 闭环(查询/创建/更新/状态流转测试) | 按文档 | 1.5-2 天 |
| Task 11 | 考核快照(草稿/详情/冻结/重算替代/列表/CSV) | 按文档 | 2-3 天 |
| Task 12 | 调度接入:analytics_tick 第三步 + full_sync 第 13/14 步 + 失败隔离 + 手动重跑入口 | 步数 12→14;**服务器 scheduler 镜像重建遗留**(9d3cc8e 部署时未重建)一并处理 | 1-1.5 天 |
| Task 13 | 回测校准 + 上线顺序控制(本地→服务器)+ 试运行记录 | 功能开关→部署顺序控制 | 2-3 天 |
**批次划分**(每批独立验收,不夹带无关重构):
| 批次 | 任务 | 可见结果 |
|---|---|---|
| A | Task 1-2 | 表+fact+宽表可在测试与脚本中查询,回填可重跑 |
| B | Task 3-5 | OpenAPI 可验证全部只读管理接口 |
| C | Task 6-7 | 新导航+驾驶舱可用,异常两次点击到事实 |
| D | Task 8-9 | 员工总览+详情可比较可下钻 |
| E | Task 10-11 | 辅导闭环+冻结周期结果 |
| F | Task 12-13 | 持续运行+内部试运行 |
**总工期**:单链 18-24 个工作日;前后端并行 12-16 天。口径确认(已完成)、数据回测和试运行不可省略。
---
## 9. 测试与验收(v1 §16 基础上新增判别项)
**后端单元/API 测试**(新增文件按 v1 §16.1 清单):
- **跨夜串判别**(P1#1 防线):seed 客户 23:00 消息+员工次日 9:00 回复,断言 fact 产生 fr_ms≈10h 的行——正是日切口径丢失的场景;
- **口径判别**(P1#2 防线):seed 同员工 friend+room 两类会话,断言宽表 1:1 列不含 room 串;
- **分位数精确性**:构造已知 fr_ms 集合,断言周期 P50/P90 精确值;窗口函数 SQLite/MySQL 双方言一致;
- **fact 超集断言**(P1#4):fact ⊇ 日切被回串,差集=跨夜串,不做相等断言;
- **分子分母同源**(P1#5):任意窗口 reply_rate ≤ 1;宽表 threads_answered ≡ fact 行数(同窗口);存在多日未回串时宽表 cust_threads < 日切口径值(分母不再膨胀);
- **fact 回填幂等**:重跑不重不漏;
- **快照不可变**:冻结后 PATCH 拒绝(后端强制);
- 评分边界:正/反向指标、阈值边界、权重合计、样本不足不评分、空值≠0、缺失维度不默认 0/100;
- 下钻白名单:非法 metric_code 拒绝;
- 幽灵员工过滤:映射不上的 owner 不进宽表。
**数据核验**(3-5 名回测员工,覆盖微信为主/WA 为主/群为主/高负载/低样本):抽查 3 日期消息与活跃客户、20 个首响串(对照 fact 明细)、10 个待回复客户、5 条通话后跟进、生命周期变化、评分展开明细;对账点增加宽表消息计数列(msg_sent/msg_recv)↔ msg_daily_stats friend 聚合——**串类列禁止相等断言**(口径差异见 §1.1 #4/#5)。
**性能验收**(v1 §16.5 全部目标基础上追加):员工列表服务质量视图 30 天窗(含 fact 窗口函数分位)p95 ≤ 1.5s。
**前端**:`npm run build` + Vitest(格式化/趋势方向/样本状态/筛选 query/旧响应不覆盖)。
---
## 10. 部署注记(写入 DEPLOY.md)
1. 服务器 backend 容器**不自动跑 alembic**——升级带迁移必须手动 `sudo docker exec <backend> alembic upgrade head`;
2. 两条回填 CLI(`first-reply-facts` / `employee-metrics`)部署后执行一次,DEPLOY.md §7 注记(含耗时参考与避抓取窗口建议);
3. scheduler 镜像重建(含 9d3cc8e 遗留):`cd /opt/yunke && sudo docker compose -f docker-compose.server.yml --env-file .env up -d --build scheduler`——重建前 fr 三列/第 11-14 步不自愈;
4. fact 表索引创建量级小(几十万行),无需特殊避峰;
5. 上线顺序:本地 Docker 全量回填对账 → 本地试用 → 服务器升级+回填 → 管理者只读试用。
---
## 11. 沿用 v1 不变的部分(索引)
以下内容直接按 v1 执行,本文不重复:
- 页面内容细则:v1 §5-§9(驾驶舱布局/KPI 卡清单、员工总览六视图、员工详情页签、快照页、二三期概要);
- 评分标准化公式与等级:v1 §10.2/§10.4;
- 后端模块目录与分层职责:v1 §12.1/§12.2(其中 storage 两模块以本文 §3/§4 为准);
- API 通用响应约定全文:v1 §12.4;
- 前端可视化规范全文:v1 §13.4;
- 验收标准:v1 §19;
- 灰度顺序/影子考核/回滚:v1 §20(其中「功能开关」按本文 §1.3 #11 替换为上线顺序控制)。
@@ -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)。
+71
View File
@@ -0,0 +1,71 @@
"""客户生命周期判定(管理域,批次A)——距今天数口径(v2 §1.1 #3 决议)。
阈值取已确认默认(v1 §21,用户拍板):新客 7 天、沉睡 30 天;历史互动
门槛 MIN_HISTORY_MSGS=5 对齐 analytics churn real 的 min_msgs=5。
analytics 现有页(churn/客户资产)**暂不切换**到此模块——两套数字的
差异在 metric-definitions 标注(v2 §5);后续统一时再接,避免动既有
测试。
本模块为纯叶子节点,禁止 import app.storage(防止 storage→analytics
消费时成环)。
"""
from __future__ import annotations
import datetime as dt
NEW_DAYS = 7
DORMANT_DAYS = 30
MIN_HISTORY_MSGS = 5
STATES = ("new", "active", "cooling", "dormant", "reactivated")
def _to_date(day: int) -> dt.date:
return dt.date(day // 10000, (day // 100) % 100, day % 100)
def day_add(day: int, delta_days: int) -> int:
"""yyyymmdd 键加减天数。"""
d = _to_date(day) + dt.timedelta(days=delta_days)
return d.year * 10000 + d.month * 100 + d.day
def days_between(day_a: int, day_b: int) -> int:
"""b − a 的天数(输入 yyyymmdd 键)。"""
return (_to_date(day_b) - _to_date(day_a)).days
def classify(
first_day: int | None,
last_day: int | None,
prev_day: int | None,
as_of: int,
hist_msgs: int,
) -> str | None:
"""as_of 日的客户状态;输入均为互动日键(yyyymmdd,上海自然日)。
- first_day: 该客户全历史首次互动日;last_day: ≤as_of 的最近互动日;
prev_day: last_day 之前的一次互动日(无则 None);hist_msgs: 历史总消息数。
- 返回 None = 未激活(first/last 缺)或历史不足的长期静默(不计入任何列)。
判定优先级:new → reactivated → active → cooling → dormant。
"""
if first_day is None or last_day is None:
return None
if first_day >= day_add(as_of, -(NEW_DAYS - 1)):
return "new"
if (
last_day == as_of
and prev_day is not None
and days_between(prev_day, last_day) > DORMANT_DAYS
):
return "reactivated"
gap = days_between(last_day, as_of)
if gap < NEW_DAYS:
return "active"
if gap < DORMANT_DAYS:
return "cooling"
if hist_msgs >= MIN_HISTORY_MSGS:
return "dormant"
return None
+2
View File
@@ -49,6 +49,7 @@ from app.api.routers import (
export, export,
groups, groups,
login, login,
management,
media, media,
messages, messages,
moments, moments,
@@ -101,6 +102,7 @@ app.include_router(sessions.router)
app.include_router(export.router) app.include_router(export.router)
app.include_router(voice_clips.router) app.include_router(voice_clips.router)
app.include_router(analytics.router) app.include_router(analytics.router)
app.include_router(management.router)
@app.get("/api/health") @app.get("/api/health")
+103 -1
View File
@@ -7,6 +7,12 @@ Read-only query over the mirrored ``customer`` table. Supports filtering by
nickname / remark / friend_id / phone, ``%``/``_`` escaped as literals — the nickname / remark / friend_id / phone, ``%``/``_`` escaped as literals — the
通讯录 search box). Results are ordered by ``last_chat_time desc`` (nulls 通讯录 search box). Results are ordered by ``last_chat_time desc`` (nulls
last) then ``friend_id`` so the most-recently-active contacts surface first. last) then ``friend_id`` so the most-recently-active contacts surface first.
**owner_account_id 维度走 ``customer_account_link``**(2026-09-08):customer
档案 PK=friend_id 全局单行、归属 last-wins,共享客户在其他员工名下会漏
(实测 36 vs 12);link 表每个「渠道×账号×客户」一行,员工维度列表/计数与
云客 per-employee 口径一致。带 department/user/add_time/last_chat 等其余
过滤时回退档案路径(组合场景罕见,保持旧行为)。
""" """
from __future__ import annotations from __future__ import annotations
@@ -19,7 +25,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session from app.api.deps import get_session
from app.api.schemas import CustomerOut, Page from app.api.schemas import CustomerOut, Page
from app.db.models import Customer, Staff, WechatAccount from app.db.models import Customer, CustomerAccountLink, Staff, WechatAccount
router = APIRouter(prefix="/api", tags=["customers"]) router = APIRouter(prefix="/api", tags=["customers"])
@@ -61,6 +67,74 @@ async def customers_declared_total(session: SessionDep) -> dict:
} }
async def _list_owner_links(
session: AsyncSession,
*,
page: int,
page_size: int,
channel: str | None,
owner_account_id: str,
friend_id: str | None,
q: str | None,
) -> Page[CustomerOut]:
"""员工维度客户列表:读 ``customer_account_link``(渠道×账号×客户一行)。
排序/搜索语义与档案路径一致(last_chat_time desc、四列模糊);link 自带
per-relation 快照(备注/收发计数按员工各一份,与云客 per-employee 列表
相同)。CustomerOut 的档案专属列(raw_json/version/send_today 等)为 None。
"""
conditions = [CustomerAccountLink.owner_account_id == owner_account_id]
if channel is not None:
conditions.append(CustomerAccountLink.channel == channel)
if friend_id is not None:
conditions.append(CustomerAccountLink.friend_id == friend_id)
if q:
# LIKE 通配符转义(同档案路径):昵称/备注/微信号/手机号四列任一命中
escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
like = f"%{escaped}%"
conditions.append(
sa.or_(
CustomerAccountLink.friend_nickname.like(like, escape="\\"),
CustomerAccountLink.friend_remark.like(like, escape="\\"),
CustomerAccountLink.friend_id.like(like, escape="\\"),
CustomerAccountLink.phone.like(like, escape="\\"),
)
)
total = (
await session.execute(
sa.select(sa.func.count())
.select_from(CustomerAccountLink)
.where(*conditions)
)
).scalar_one()
# LEFT JOIN 档案带 raw_json(2026-09-09):WA 客户列表大量列只存在于
# raw_json(userName/salePhone/createTime 等员工侧字段),link 快照没存;
# raw_json 按 friend_id 全局一份,挂谁名下内容相同。注:共享客户的员工
# 侧字段是「最后写入员工」的记录,可能非本员工(客户自身字段不受影响)。
rows = (
await session.execute(
sa.select(CustomerAccountLink, Customer.raw_json, Customer.version)
.outerjoin(Customer, Customer.friend_id == CustomerAccountLink.friend_id)
.where(*conditions)
.order_by(
sa.nullslast(CustomerAccountLink.last_chat_time.desc()),
CustomerAccountLink.friend_id.asc(),
)
.limit(page_size)
.offset((page - 1) * page_size)
)
).all()
items = []
for link, raw_json, version in rows:
out = CustomerOut.model_validate(link)
out.raw_json = raw_json
if version is not None:
out.version = version
items.append(out)
return Page(items=items, total=total, page=page, page_size=page_size)
@router.get("/customers") @router.get("/customers")
async def list_customers( async def list_customers(
session: SessionDep, session: SessionDep,
@@ -76,6 +150,34 @@ async def list_customers(
q: Annotated[str | None, Query()] = None, q: Annotated[str | None, Query()] = None,
) -> Page[CustomerOut]: ) -> Page[CustomerOut]:
"""List customers with optional filters and pagination.""" """List customers with optional filters and pagination."""
# 员工维度优先走 link 表(共享好友每账号一行,见模块 docstring);该账号
# link 还无数据时回退档案路径(过渡安全:回填前页面不至于空;也兼容
# 只写档案的理论路径)。与其余仅档案路径支持的过滤组合时同样回退。
if (
owner_account_id is not None
and department_id is None
and user_id is None
and add_time_since is None
and last_chat_since is None
):
link_exists = (
await session.execute(
sa.select(sa.func.count())
.select_from(CustomerAccountLink)
.where(CustomerAccountLink.owner_account_id == owner_account_id)
)
).scalar_one()
if link_exists:
return await _list_owner_links(
session,
page=page,
page_size=page_size,
channel=channel,
owner_account_id=owner_account_id,
friend_id=friend_id,
q=q,
)
conditions = [] conditions = []
if channel is not None: if channel is not None:
conditions.append(Customer.channel == channel) conditions.append(Customer.channel == channel)
+114 -3
View File
@@ -1,21 +1,25 @@
"""GET /api/export/chat-html — 导出一段 1:1 / 群聊天为独立 HTML 文件。 """GET /api/export/chat-html — 导出一段 1:1 / 群聊天为独立 HTML 文件。
GET /api/export/chat-html-zip — 勾选的多个 1:1 会话打成一个 zip(每会话一个 HTML)。
对齐云客微信客户列表「导出HTML聊天记录」:按 (channel, owner, talker_id|room_id) 对齐云客微信客户列表「导出HTML聊天记录」:按 (channel, owner, talker_id|room_id)
拉消息(可选 start/end 时间范围),渲染自包含 HTML 聊天页(is_send 决定左右气泡), 拉消息(可选 start/end 时间范围),渲染自包含 HTML 聊天页(is_send 决定左右气泡),
作为 attachment 返回下载。 作为 attachment 返回下载。zip 版对齐员工客户页的批量「导出聊天记录」(2026-09-08)。
""" """
from __future__ import annotations from __future__ import annotations
import io
import re
import zipfile
from datetime import datetime, timezone from datetime import datetime, timezone
from html import escape from html import escape
from typing import Annotated from typing import Annotated
import sqlalchemy as sa import sqlalchemy as sa
from fastapi import APIRouter, Depends, Query, Response from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session from app.api.deps import get_session
from app.db.models import Message from app.db.models import Customer, CustomerAccountLink, Message
router = APIRouter(prefix="/api/export", tags=["export"]) router = APIRouter(prefix="/api/export", tags=["export"])
@@ -170,3 +174,110 @@ async def export_chat_html(
media_type="text/html; charset=utf-8", media_type="text/html; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}, headers={"Content-Disposition": f'attachment; filename="{filename}"'},
) )
# 员工客户页批量「导出聊天记录」:一次勾选的会话数上限(防误点全选把服务端打爆;
# 50 个会话 × 每会话 EXPORT_MAX_ROWS 已是内存上界内的最坏情况)。
ZIP_MAX_CONVS = 50
def _safe_zip_name(name: str, fallback: str) -> str:
"""会话显示名 → zip 内文件名:去路径/Windows 非法字符,限长,空回退。"""
cleaned = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name).strip("._ ")
return cleaned[:40] or fallback
@router.get("/chat-html-zip")
async def export_chat_html_zip(
session: SessionDep,
channel: Annotated[str, Query()],
owner_account_id: Annotated[str, Query()],
talker_ids: Annotated[str, Query()] = "",
start: Annotated[int | None, Query(ge=0)] = None,
end: Annotated[int | None, Query(ge=0)] = None,
) -> Response:
"""勾选客户的 1:1 聊天批量导出为一个 zip(每会话一个 HTML,复用单导出渲染器)。
``talker_ids`` 逗号分隔的 friend_id 列表;文件名取客户备注/昵称(缺省回退
friend_id),加序号前缀保唯一;单会话行数沿用 EXPORT_MAX_ROWS 截断告警。
"""
ids = [t.strip() for t in talker_ids.split(",") if t.strip()]
if not ids:
raise HTTPException(status_code=422, detail="talker_ids 不能为空")
if len(ids) > ZIP_MAX_CONVS:
raise HTTPException(
status_code=422, detail=f"一次最多导出 {ZIP_MAX_CONVS} 个会话"
)
# 文件名显示名:批量取一次(备注 > 昵称 > friend_id)。优先
# customer_account_link per-owner 快照——共享客户的档案行可能挂在别的
# 员工名下,按 owner 查档案会查空、zip 文件名回退成 wxid(2026-09-09,
# 同 sessions._meta 修复);link 没覆盖的回退档案。
link_rows = (await session.execute(
sa.select(
CustomerAccountLink.friend_id,
CustomerAccountLink.friend_nickname,
CustomerAccountLink.friend_remark,
).where(
CustomerAccountLink.channel == channel,
CustomerAccountLink.owner_account_id == owner_account_id,
CustomerAccountLink.friend_id.in_(ids),
)
)).all()
names = {
r.friend_id: (r.friend_remark or r.friend_nickname or r.friend_id)
for r in link_rows
}
missing = [t for t in ids if t not in names]
if missing:
cust_rows = (await session.execute(
sa.select(
Customer.friend_id,
Customer.friend_nickname,
Customer.friend_remark,
).where(
Customer.channel == channel,
Customer.owner_account_id == owner_account_id,
Customer.friend_id.in_(missing),
)
)).all()
for r in cust_rows:
names[r.friend_id] = (r.friend_remark or r.friend_nickname or r.friend_id)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for i, tid in enumerate(ids, 1):
q = sa.select(Message).where(
Message.channel == channel,
Message.owner_account_id == owner_account_id,
Message.talker_id == tid,
# DM:限定 room_id 为空,避免误捞群消息(同单导出)
(Message.room_id == None) | (Message.room_id == ""), # noqa: E711
)
if start is not None:
q = q.where(Message.timestamp >= start)
if end is not None:
q = q.where(Message.timestamp <= end)
q = q.order_by(
sa.nullslast(Message.timestamp.asc()), Message.msg_id.asc()
).limit(EXPORT_MAX_ROWS)
rows = list((await session.execute(q)).scalars().all())
capped = len(rows) >= EXPORT_MAX_ROWS
warning = (
f'<div class="export-warning">⚠️ 结果超过 {EXPORT_MAX_ROWS} 条,'
f"仅导出前 {EXPORT_MAX_ROWS} 条。请缩小日期范围。</div>"
if capped
else ""
)
html = _render_chat_html(rows, channel, owner_account_id, tid, warning=warning)
safe = _safe_zip_name(names.get(tid) or tid, f"conv{i}")
zf.writestr(f"{i:02d}_{safe}.html", html)
return Response(
content=buf.getvalue(),
media_type="application/zip",
headers={
"Content-Disposition": (
f'attachment; filename="chat-records-{channel}-{owner_account_id}.zip"'
)
},
)
+133 -2
View File
@@ -5,6 +5,10 @@
``owner_user_id`` column) and ``q`` (substring match on ``room_name``, ``owner_user_id`` column) and ``q`` (substring match on ``room_name``,
``%``/``_`` escaped as literals — the 通讯录群 tab search box), ordered by ``%``/``_`` escaped as literals — the 通讯录群 tab search box), ordered by
``last_chat_time desc`` (nulls last) so recently-active groups surface first. ``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 * ``GET /api/groups/{room_id}/members`` -> ``list[GroupMemberOut]`` — the
members of a single group (small set, no pagination needed). 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.deps import get_session
from app.api.schemas import GroupMemberOut, GroupOut, Page 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"]) router = APIRouter(prefix="/api", tags=["groups"])
@@ -38,6 +50,11 @@ async def list_groups(
user_id: Annotated[str | None, Query()] = None, user_id: Annotated[str | None, Query()] = None,
room_id: Annotated[str | None, Query()] = None, room_id: Annotated[str | None, Query()] = None,
q: 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]: ) -> Page[GroupOut]:
"""List chat groups with optional ``channel`` / ``owner_account_id`` / """List chat groups with optional ``channel`` / ``owner_account_id`` /
``user_id`` / ``room_id`` / ``q`` filters. ``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 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 (composite PK: the same room may return one row per owner; chat page
group-name resolution). ``q`` is a substring match on ``room_name``. 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 = [] conditions = []
if channel is not None: if channel is not None:
@@ -57,10 +81,17 @@ async def list_groups(
# LIKE 通配符转义: %/_ 按字面匹配(镜像 customers/messages 的 q 处理) # LIKE 通配符转义: %/_ 按字面匹配(镜像 customers/messages 的 q 处理)
escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
conditions.append(ChatGroup.room_name.like(f"%{escaped}%", escape="\\")) 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: if room_id is not None:
conditions.append(ChatGroup.room_id == room_id) conditions.append(ChatGroup.room_id == room_id)
if user_id is not None: if user_id is not None:
conditions.append(ChatGroup.owner_user_id == user_id) 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) total_stmt = sa.select(sa.func.count()).select_from(ChatGroup)
if conditions: if conditions:
@@ -70,6 +101,29 @@ async def list_groups(
list_stmt = sa.select(ChatGroup) list_stmt = sa.select(ChatGroup)
if conditions: if conditions:
list_stmt = list_stmt.where(*conditions) list_stmt = list_stmt.where(*conditions)
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 = (
list_stmt.order_by( list_stmt.order_by(
sa.nullslast(ChatGroup.last_chat_time.desc()), sa.nullslast(ChatGroup.last_chat_time.desc()),
@@ -79,7 +133,84 @@ async def list_groups(
.offset((page - 1) * page_size) .offset((page - 1) * page_size)
) )
rows = (await session.execute(list_stmt)).scalars().all() 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) 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,
}
+32 -3
View File
@@ -22,7 +22,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session from app.api.deps import get_session
from app.api.schemas import SessionListOut, SessionOut from app.api.schemas import SessionListOut, SessionOut
from app.db.models import ChatGroup, Customer, Message, MsgDailyStats from app.db.models import (
ChatGroup,
Customer,
CustomerAccountLink,
Message,
MsgDailyStats,
)
from app.storage.summary import day_key, has_rows as _summary_has_rows from app.storage.summary import day_key, has_rows as _summary_has_rows
router = APIRouter(prefix="/api", tags=["sessions"]) router = APIRouter(prefix="/api", tags=["sessions"])
@@ -117,19 +123,42 @@ async def _summary_rows(
async def _meta(session: AsyncSession, owner: str, channel: str, friend_ids: list[str], room_ids: list[str]): async def _meta(session: AsyncSession, owner: str, channel: str, friend_ids: list[str], room_ids: list[str]):
"""查 customer/chat_group 表合并昵称/头像;好友行额外带 delete_flag(is_del 过滤用)。 """查 customer_account_link(主)/customer(兜底)/chat_group 合并昵称/头像。
好友名优先读 ``customer_account_link`` 的 per-owner 快照——共享客户的
档案行(customer 全局单行,last-wins)可能挂在别的员工名下,按 owner 查
档案会查空,会话列表就回退显示 wxid(2026-09-09 用户报「有昵称却显示
微信号」);link 表每账号一行,回填后都有昵称。link 没覆盖的(空表过渡/
老数据)回退档案路径。
friends 值为 ``(name, avatar, delete_flag)``;群无删除标记,groups 值仍为 friends 值为 ``(name, avatar, delete_flag)``;群无删除标记,groups 值仍为
``(name, avatar)``。 ``(name, avatar)``。
""" """
friends: dict[str, tuple[str, str | None, int | None]] = {} friends: dict[str, tuple[str, str | None, int | None]] = {}
if friend_ids: if friend_ids:
for fid, remark, nick, real, avatar, delete_flag in (await session.execute(
sa.select(
CustomerAccountLink.friend_id, CustomerAccountLink.friend_remark,
CustomerAccountLink.friend_nickname, CustomerAccountLink.real_name,
CustomerAccountLink.head_image_url, CustomerAccountLink.delete_flag,
)
.where(
CustomerAccountLink.channel == channel,
CustomerAccountLink.owner_account_id == owner,
CustomerAccountLink.friend_id.in_(friend_ids),
)
)).all():
friends[fid] = (remark or nick or real or fid, avatar, delete_flag)
# link 未覆盖的好友回退档案(按 owner 过滤;共享好友在此仍可能查空,
# 上层以 wxid 兜底显示)
missing = [fid for fid in friend_ids if fid not in friends]
if missing:
for fid, remark, nick, real, avatar, delete_flag in (await session.execute( for fid, remark, nick, real, avatar, delete_flag in (await session.execute(
sa.select( sa.select(
Customer.friend_id, Customer.friend_remark, Customer.friend_nickname, Customer.friend_id, Customer.friend_remark, Customer.friend_nickname,
Customer.real_name, Customer.head_image_url, Customer.delete_flag, Customer.real_name, Customer.head_image_url, Customer.delete_flag,
) )
.where(Customer.channel == channel, Customer.owner_account_id == owner, Customer.friend_id.in_(friend_ids)) .where(Customer.channel == channel, Customer.owner_account_id == owner, Customer.friend_id.in_(missing))
)).all(): )).all():
friends[fid] = (remark or nick or real or fid, avatar, delete_flag) friends[fid] = (remark or nick or real or fid, avatar, delete_flag)
groups: dict[str, tuple[str, str | None]] = {} groups: dict[str, tuple[str, str | None]] = {}
@@ -20,7 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session from app.api.deps import get_session
from app.api.schemas import StaffChatCustomerOut, StaffChatStatsOut, StaffChatStatsSummary from app.api.schemas import StaffChatCustomerOut, StaffChatStatsOut, StaffChatStatsSummary
from app.db.models import Customer, Message, MsgDailyStats from app.db.models import Customer, CustomerAccountLink, Message, MsgDailyStats
from app.storage.summary import day_key, has_rows as _summary_has_rows from app.storage.summary import day_key, has_rows as _summary_has_rows
router = APIRouter(prefix="/api", tags=["staff-chat-stats"]) router = APIRouter(prefix="/api", tags=["staff-chat-stats"])
@@ -144,8 +144,30 @@ async def _aggregate_summary(
async def _customer_names(session: AsyncSession, owner: str, talker_ids: list[str]) -> dict[str, tuple[str, str, str]]: async def _customer_names(session: AsyncSession, owner: str, talker_ids: list[str]) -> dict[str, tuple[str, str, str]]:
"""客户名/头像解析:优先 customer_account_link per-owner 快照,档案兜底。
共享客户的档案行(customer 全局单行,last-wins)可能挂在别的员工名下,
按 owner 查档案会查空、明细列表回退显示 wxid(2026-09-09,同 sessions
._meta 修复)。
"""
if not talker_ids: if not talker_ids:
return {} return {}
info: dict[str, tuple[str, str, str]] = {}
link_stmt = sa.select(
CustomerAccountLink.friend_id,
CustomerAccountLink.friend_remark,
CustomerAccountLink.friend_nickname,
CustomerAccountLink.real_name,
CustomerAccountLink.head_image_url,
).where(
CustomerAccountLink.channel == "wechat",
CustomerAccountLink.owner_account_id == owner,
CustomerAccountLink.friend_id.in_(talker_ids),
)
for fid, remark, nick, real, head in (await session.execute(link_stmt)).all():
info[fid] = (remark or nick or real or fid, real or "", head or "")
missing = [t for t in talker_ids if t not in info]
if missing:
stmt = sa.select( stmt = sa.select(
Customer.friend_id, Customer.friend_id,
Customer.friend_remark, Customer.friend_remark,
@@ -155,9 +177,8 @@ async def _customer_names(session: AsyncSession, owner: str, talker_ids: list[st
).where( ).where(
Customer.channel == "wechat", Customer.channel == "wechat",
Customer.owner_account_id == owner, Customer.owner_account_id == owner,
Customer.friend_id.in_(talker_ids), Customer.friend_id.in_(missing),
) )
info: dict[str, tuple[str, str, str]] = {}
for fid, remark, nick, real, head in (await session.execute(stmt)).all(): for fid, remark, nick, real, head in (await session.execute(stmt)).all():
info[fid] = (remark or nick or real or fid, real or "", head or "") info[fid] = (remark or nick or real or fid, real or "", head or "")
return info return info
+88 -37
View File
@@ -40,7 +40,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session from app.api.deps import get_session
from app.api.schemas import StaffStatsCountsOut, StaffStatsDetailOut from app.api.schemas import StaffStatsCountsOut, StaffStatsDetailOut
from app.db.models import ChatGroup, Customer, Message, PhoneCall, WechatAccount from app.db.models import (
ChatGroup,
Customer,
CustomerAccountLink,
Message,
PhoneCall,
WechatAccount,
)
router = APIRouter(prefix="/api", tags=["staff-stats"]) router = APIRouter(prefix="/api", tags=["staff-stats"])
@@ -96,50 +103,57 @@ async def _new_customers(
) -> tuple[int, list[dict[str, Any]]]: ) -> tuple[int, list[dict[str, Any]]]:
"""新客户:add_time 窗内;add_time IS NULL 用首条消息时间兜底。 """新客户:add_time 窗内;add_time IS NULL 用首条消息时间兜底。
读 ``customer_account_link``(2026-09-09):customer 档案全局单行、
归属 last-wins,共享客户在其他员工名下会漏计;link 每账号一行,与
云客按员工统计的口径一致(数字会比档案口径大)。
无 ``detail`` 分支——计数模式有意复用明细物化(窗内新增客户通常 无 ``detail`` 分支——计数模式有意复用明细物化(窗内新增客户通常
几十行,``len()`` 即可;COUNT 需 direct+fallback 双查询,不值)。 几十行,``len()`` 即可;COUNT 需 direct+fallback 双查询,不值)。
""" """
base = [Customer.channel == "wechat", Customer.owner_account_id == wxid] base = [
CustomerAccountLink.channel == "wechat",
CustomerAccountLink.owner_account_id == wxid,
]
# 兜底口径:每个 add_time 为空的客户,取其与该员工首条消息 min(timestamp) # 兜底口径:每个 add_time 为空的客户,取其与该员工首条消息 min(timestamp)
# (LEFT JOIN 走 ix_message_channel_owner_talker_time,HAVING 在 SQL 侧过滤窗)。 # (LEFT JOIN 走 ix_message_channel_owner_talker_time,HAVING 在 SQL 侧过滤窗)。
fallback = ( fallback = (
sa.select( sa.select(
Customer.friend_id, CustomerAccountLink.friend_id,
Customer.friend_nickname, CustomerAccountLink.friend_nickname,
Customer.friend_remark, CustomerAccountLink.friend_remark,
Customer.head_image_url, CustomerAccountLink.head_image_url,
sa.func.min(Message.timestamp).label("first_ts"), sa.func.min(Message.timestamp).label("first_ts"),
) )
.outerjoin( .outerjoin(
Message, Message,
sa.and_( sa.and_(
Message.channel == "wechat", Message.channel == "wechat",
Message.owner_account_id == Customer.owner_account_id, Message.owner_account_id == CustomerAccountLink.owner_account_id,
Message.talker_id == Customer.friend_id, Message.talker_id == CustomerAccountLink.friend_id,
Message.timestamp.is_not(None), Message.timestamp.is_not(None),
), ),
) )
.where(*base, Customer.add_time.is_(None)) .where(*base, CustomerAccountLink.add_time.is_(None))
# GROUP BY 显式列出全部裸选列:friend_id 是 Customer 唯一 PK,其余列 # GROUP BY 显式列出全部裸选列:friend_id 是 link 复合 PK 的尾列,
# 函数依赖于它→分组结果不变;MySQL 8 FD 推断可容忍裸列,但显式列表 # (owner,friend) 已由 WHERE 钉死,其余列函数依赖于它→分组结果不变;
# 在 ONLY_FULL_GROUP_BY 下部署安全(SQLite 同样兼容)。 # MySQL ONLY_FULL_GROUP_BY 部署安全(SQLite 同样兼容)。
.group_by( .group_by(
Customer.friend_id, CustomerAccountLink.friend_id,
Customer.friend_nickname, CustomerAccountLink.friend_nickname,
Customer.friend_remark, CustomerAccountLink.friend_remark,
Customer.head_image_url, CustomerAccountLink.head_image_url,
) )
.having(sa.func.min(Message.timestamp) >= start) .having(sa.func.min(Message.timestamp) >= start)
.having(sa.func.min(Message.timestamp) <= end) .having(sa.func.min(Message.timestamp) <= end)
) )
# 直接口径:add_time 本身在窗内。 # 直接口径:add_time 本身在窗内。
direct = sa.select( direct = sa.select(
Customer.friend_id, CustomerAccountLink.friend_id,
Customer.friend_nickname, CustomerAccountLink.friend_nickname,
Customer.friend_remark, CustomerAccountLink.friend_remark,
Customer.head_image_url, CustomerAccountLink.head_image_url,
Customer.add_time, CustomerAccountLink.add_time,
).where(*base, Customer.add_time >= start, Customer.add_time <= end) ).where(*base, CustomerAccountLink.add_time >= start, CustomerAccountLink.add_time <= end)
rows: list[dict[str, Any]] = [ rows: list[dict[str, Any]] = [
{ {
@@ -237,19 +251,43 @@ async def _red_transfer(
talker_ids = [m.talker_id for m in msgs if m.talker_id] talker_ids = [m.talker_id for m in msgs if m.talker_id]
nick_map: dict[str, str] = {} nick_map: dict[str, str] = {}
if talker_ids: if talker_ids:
# 昵称优先 link per-owner 快照(共享好友档案行可能挂别的员工名下,
# 2026-09-09 同 sessions._meta),档案兜底
nick_map = { nick_map = {
fid: nick fid: nick
for fid, nick in ( for fid, nick in (
await session.execute( await session.execute(
sa.select(Customer.friend_id, Customer.friend_nickname).where( sa.select(
Customer.channel == "wechat", CustomerAccountLink.friend_id,
Customer.owner_account_id == wxid, CustomerAccountLink.friend_nickname,
Customer.friend_id.in_(talker_ids), ).where(
CustomerAccountLink.channel == "wechat",
CustomerAccountLink.owner_account_id == wxid,
CustomerAccountLink.friend_id.in_(talker_ids),
) )
) )
).all() ).all()
if nick if nick
} }
missing = [t for t in talker_ids if t not in nick_map]
if missing:
nick_map.update(
{
fid: nick
for fid, nick in (
await session.execute(
sa.select(
Customer.friend_id, Customer.friend_nickname
).where(
Customer.channel == "wechat",
Customer.owner_account_id == wxid,
Customer.friend_id.in_(missing),
)
)
).all()
if nick
}
)
rows: list[dict[str, Any]] = [] rows: list[dict[str, Any]] = []
for m in msgs: for m in msgs:
raw_name = (m.raw_json or {}).get("name") if m.raw_json else None raw_name = (m.raw_json or {}).get("name") if m.raw_json else None
@@ -273,15 +311,20 @@ async def _flag_customers(
flag_cond: sa.ColumnElement, flag_cond: sa.ColumnElement,
*, *,
detail: bool, detail: bool,
model: type,
) -> tuple[int, list[dict[str, Any]]]: ) -> tuple[int, list[dict[str, Any]]]:
"""del_customers / blocked_customers 共用:现存量口径,忽略时间窗。 """del_customers / blocked_customers 共用:现存量口径,忽略时间窗。
``model`` 由调用方决定(两者列名同形):del 走 ``CustomerAccountLink``
(delete_flag per-relation,共享好友不漏),blocked 走 ``Customer``
档案(is_block 未镜像进 link,2026-09-09)。
无时间字段→明细行 time 用 updated_at 毫秒(仅展示;update 触发即变)。 无时间字段→明细行 time 用 updated_at 毫秒(仅展示;update 触发即变)。
""" """
conds = [Customer.channel == "wechat", Customer.owner_account_id == wxid, flag_cond] conds = [model.channel == "wechat", model.owner_account_id == wxid, flag_cond]
count = ( count = (
await session.execute( await session.execute(
sa.select(sa.func.count()).select_from(Customer).where(*conds) sa.select(sa.func.count()).select_from(model).where(*conds)
) )
).scalar_one() ).scalar_one()
if not detail: if not detail:
@@ -289,13 +332,13 @@ async def _flag_customers(
rows = ( rows = (
await session.execute( await session.execute(
sa.select( sa.select(
Customer.friend_id, model.friend_id,
Customer.friend_nickname, model.friend_nickname,
Customer.friend_remark, model.friend_remark,
Customer.updated_at, model.updated_at,
) )
.where(*conds) .where(*conds)
.order_by(Customer.updated_at.desc(), Customer.friend_id.asc()) .order_by(model.updated_at.desc(), model.friend_id.asc())
) )
).all() ).all()
return count, [ return count, [
@@ -311,18 +354,26 @@ async def _flag_customers(
async def _del_customers( async def _del_customers(
session: AsyncSession, wxid: str, start: int, end: int, detail: bool session: AsyncSession, wxid: str, start: int, end: int, detail: bool
) -> tuple[int, list[dict[str, Any]]]: ) -> tuple[int, list[dict[str, Any]]]:
"""删除客户:delete_flag=1 现存量(start/end 按口径忽略)。""" """删除客户:delete_flag=1 现存量(start/end 按口径忽略)。
读 link(per-relation delete_flag,共享好友不漏计,2026-09-09)。
"""
return await _flag_customers( return await _flag_customers(
session, wxid, Customer.delete_flag == 1, detail=detail session, wxid, CustomerAccountLink.delete_flag == 1,
detail=detail, model=CustomerAccountLink,
) )
async def _blocked_customers( async def _blocked_customers(
session: AsyncSession, wxid: str, start: int, end: int, detail: bool session: AsyncSession, wxid: str, start: int, end: int, detail: bool
) -> tuple[int, list[dict[str, Any]]]: ) -> tuple[int, list[dict[str, Any]]]:
"""流失(拉黑)客户:is_block=1 现存量(start/end 按口径忽略)。""" """流失(拉黑)客户:is_block=1 现存量(start/end 按口径忽略)。
保持档案口径:is_block 未镜像进 link 表,切了会漏;已知偏差=共享好友
在其他员工名下的拉黑不计入(待 link 补列后切换)。
"""
return await _flag_customers( return await _flag_customers(
session, wxid, Customer.is_block.is_(True), detail=detail session, wxid, Customer.is_block.is_(True), detail=detail, model=Customer
) )
+18
View File
@@ -39,6 +39,13 @@ class CustomerOut(BaseModel):
last_chat_time: int | None = None last_chat_time: int | None = None
add_time: int | None = None add_time: int | None = None
message_count: int | None = None message_count: int | None = None
# 累计发送/接收条数(同步自云客 friend 记录的 sendNum/receiveNum;员工
# 客户页「发送条数/接收条数」列用,2026-09-08)
send_num: int | None = None
receive_num: int | None = None
# 云客数据来源代码(customer_account_link 路径直读;档案路径为 None,
# 前端回退 raw_json.fromType)
from_type: int | None = None
version: str | None = None version: str | None = None
# Verbatim upstream friend record (~60 camelCase fields). Surfaced so the # Verbatim upstream friend record (~60 camelCase fields). Surfaced so the
# all-fields drawer can render every column the 云客 source page shows # all-fields drawer can render every column the 云客 source page shows
@@ -192,6 +199,17 @@ class GroupOut(BaseModel):
owner_avatar: str | None = None owner_avatar: str | None = None
friend_count: int | None = None friend_count: int | None = None
create_time: int | None = None # 建群时间 ms epoch 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 raw_json: dict[str, Any] | None = None
+321
View File
@@ -262,6 +262,46 @@ class Customer(Base):
) )
class CustomerAccountLink(Base):
"""账号↔客户 多对多关联(customer_account_link,2026-09-08)。
``customer`` 表 PK=``friend_id`` 全局一客户一行,归属=最后抓取员工
(last-wins)——共享客户(同一人加多个员工)在其他员工名下「消失」:
实测 Lily 云客好友 36 vs 镜像页面 12,24 个全在别的员工名下。云客
的口径是**每个账号各算一个**(per-employee 列表连备注/收发计数都按
关系各存一份);本表每个「渠道×账号×客户」一行,由
:func:`app.storage.repos.upsert_customer` 随档案同事务维护,员工维度
的列表/计数(`/api/customers?owner_account_id=`)读这里。
快照列为员工客户页所需(昵称/备注/头像 per-relation);全字段档案
(raw_json/60 列)仍在 ``customer`` 表按 friend_id 关联。
"""
__tablename__ = "customer_account_link"
channel: Mapped[str] = mapped_column(String(16), primary_key=True)
owner_account_id: Mapped[str] = mapped_column(String(128), primary_key=True)
friend_id: Mapped[str] = mapped_column(String(128), primary_key=True)
# per-relation 展示快照(云客好友记录按员工各一份;备注是员工自己的)
friend_nickname: Mapped[str | None] = mapped_column(String(255), nullable=True)
friend_remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
head_image_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
real_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
phone: Mapped[str | None] = mapped_column(String(64), nullable=True)
last_chat_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
add_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
send_num: Mapped[int | None] = mapped_column(Integer, nullable=True)
receive_num: Mapped[int | None] = mapped_column(Integer, nullable=True)
message_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 云客 fromType 代码(数据来源列;int,前端 fromTypeLabel 映射)
from_type: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 同 customer.delete_flag 口径(int 0/1)
delete_flag: Mapped[int | None] = mapped_column(Integer, nullable=True)
fetched_at: Mapped[dt.datetime] = _fetched_at()
updated_at: Mapped[dt.datetime] = _updated_at()
class ChatGroup(Base): class ChatGroup(Base):
"""聊天群 — source: ``POST /wechatweb/chatroom/getChatroomList``. """聊天群 — source: ``POST /wechatweb/chatroom/getChatroomList``.
@@ -304,6 +344,9 @@ class ChatGroup(Base):
owner_avatar: Mapped[str | None] = mapped_column(String(1024), nullable=True) owner_avatar: Mapped[str | None] = mapped_column(String(1024), nullable=True)
friend_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 群成员中员工好友数 friend_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 群成员中员工好友数
create_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 建群时间 ms epoch 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) version: Mapped[str | None] = mapped_column(String(64), nullable=True)
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
@@ -322,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): class GroupMember(Base):
"""群成员 — composite PK (channel, room_id, member_id). """群成员 — composite PK (channel, room_id, member_id).
@@ -466,6 +537,18 @@ class Message(Base):
"ix_message_fetched_at", "ix_message_fetched_at",
"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",
),
) )
@@ -959,11 +1042,249 @@ class SensitiveScanState(Base):
updated_at: Mapped[dt.datetime] = _updated_at() updated_at: Mapped[dt.datetime] = _updated_at()
# ==========================================================================
# 经营管理后台(2026-09-08 v2 spec,批次A)——fact/宽表/通话小表 + 三张业务表
# ==========================================================================
class FirstReplyFact(Base):
"""首响事实表(管理域)——一行 = 一个被回的 1:1 客户消息串(v2 §3.1)。
跨日精确串口径:按会话**全历史**计算,fr_ms = 员工首条回复 ts − 串末条
客户消息 ts,day = 串末条所在上海自然日。区别于 msg_daily_stats 首响
三列的日切近似(跨夜串整条丢失,v2 §1.1 #1)。只写 1:1(friend)会话
——群串语义不成立。由回填 CLI / 4h 滚动 / 夜间全量重算,**不挂写钩子**
(写钩子零改动)。用途:精确 P50/P90、2h/24h 回复率分子、下钻证据。
"""
__tablename__ = "first_reply_fact"
channel: Mapped[str] = mapped_column(String(16), primary_key=True)
owner_account_id: Mapped[str] = mapped_column(String(128), primary_key=True)
peer_id: Mapped[str] = mapped_column(String(128), primary_key=True)
thread_end_ts: Mapped[int] = mapped_column(BigInteger, primary_key=True)
reply_ts: Mapped[int] = mapped_column(BigInteger, primary_key=True)
# 回复消息 msg_id:同毫秒 F,T,F,T 交换的 (thread_end_ts, reply_ts) 并列
# 去重(一条回复恰好闭合一个串);兼作下钻证据定位回复消息
reply_msg_id: Mapped[str] = mapped_column(String(64), primary_key=True)
# 串末条所在上海自然日 yyyymmdd(冗余物化,窗口/趋势索引)
day: Mapped[int] = mapped_column(Integer, nullable=False)
fr_ms: Mapped[int] = mapped_column(BigInteger, nullable=False)
__table_args__ = (Index("ix_fr_fact_day_channel", "day", "channel"),)
class EmployeeMetricDaily(Base):
"""员工×日管理指标宽表(v2 §3.2)——1:1 口径,管理域专用。
列来源(v2 §1.1 #2/#5):消息计数/负载列自 msg_daily_stats
(peer_kind='friend' 过滤 + account_map 归并);串类三列与首响日值由
first_reply_facts.compute_conversation 同源输出(串末条归属日)——
回复率分子分母同口径。metric_version 只影响派生列(生命周期),
默认 'v1'。account_map 映射不上或不在 staff 的 owner(幽灵员工)
过滤不落表(v2 §1.3 #11)。通话在 employee_call_daily(无渠道维)。
"""
__tablename__ = "employee_metric_daily"
day: Mapped[int] = mapped_column(Integer, primary_key=True)
channel: Mapped[str] = mapped_column(String(16), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
metric_version: Mapped[str] = mapped_column(
String(16), primary_key=True, default="v1", server_default="v1"
)
# 消息计数列(1:1 口径)
msg_sent: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
msg_recv: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
# 负载列(active_rooms 为群负载,唯一计入宽表的群维度)
active_friends: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
active_rooms: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
# 串类列(与 first_reply_fact 同源,按串末条归属日)
cust_threads: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
threads_answered: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
fr_sum_ms: Mapped[int] = mapped_column(
BigInteger, nullable=False, default=0, server_default=sa.text("0")
)
# 首响日值(趋势图用;评分用周期值从 fact 表窗口函数精确计算)
first_reply_p50_ms: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
first_reply_p90_ms: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
# 生命周期 4 列(距今天数口径,当日持有客户状态分布,当前归属)
new_customer_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
cooling_customer_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
dormant_customer_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
reactivated_customer_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
# 预留完整性标记:宽表生成当前恒写 True(串类列与 fact 同一算法直算,
# 不读 fact 表)——真实语义(如校验 fact 覆盖/同步完整)由批次B定义
data_complete: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=sa.text("0")
)
computed_at: Mapped[dt.datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=sa.func.now()
)
class EmployeeCallDaily(Base):
"""员工×日通话小表(v2 §1.2 #9)——通话无消息渠道维度,独立于宽表。
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 群通话除外)。
"""
__tablename__ = "employee_call_daily"
day: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
call_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
call_seconds: Mapped[int] = mapped_column(
BigInteger, nullable=False, default=0, server_default=sa.text("0")
)
answered_call_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
call_followup_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
computed_at: Mapped[dt.datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=sa.func.now()
)
class EmployeeScoreConfig(Base):
"""评分配置版本(v1 §10.4/v2 §3.3)——config_json 存维度/指标/权重/
方向/阈值/样本门槛;激活前须过权重总和与阈值校验(批次B scoring 实现)。
MVP 无可视化编辑器,受控 CLI 种子维护。
JSON 列(config_json 等)修改须整列重赋值,原地修改不会提交。"""
__tablename__ = "employee_score_config"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
version: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(128), nullable=False)
# draft | active | retired
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="draft", server_default="draft"
)
effective_from: Mapped[int | None] = mapped_column(Integer, nullable=True)
effective_to: Mapped[int | None] = mapped_column(Integer, nullable=True)
config_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
approved_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[dt.datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=sa.func.now()
)
activated_at: Mapped[dt.datetime | None] = mapped_column(sa.DateTime, nullable=True)
class EmployeeScoreSnapshot(Base):
"""周/月考核快照(v1 §8/v2 §3.3)——不可变:冻结后业务层禁改评分字段;
重算走 supersedes_id 替代链。同批次×员工唯一。"""
__tablename__ = "employee_score_snapshot"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
snapshot_batch_id: Mapped[str] = mapped_column(String(64), nullable=False)
# week | month | custom
period_type: Mapped[str] = mapped_column(String(8), nullable=False)
period_start: Mapped[int] = mapped_column(Integer, nullable=False)
period_end: Mapped[int] = mapped_column(Integer, nullable=False)
user_id: Mapped[str] = mapped_column(String(64), nullable=False)
score_config_version: Mapped[str] = mapped_column(String(32), nullable=False)
metric_version: Mapped[str] = mapped_column(String(16), nullable=False)
total_score: Mapped[int | None] = mapped_column(Integer, nullable=True)
grade: Mapped[str | None] = mapped_column(String(2), nullable=True)
# scored | insufficient_sample | incomplete_data
score_status: Mapped[str] = mapped_column(
String(24), nullable=False, default="scored", server_default="scored"
)
dimension_scores_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
metric_details_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
data_as_of: Mapped[int] = mapped_column(Integer, nullable=False)
generated_at: Mapped[dt.datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=sa.func.now()
)
frozen_at: Mapped[dt.datetime | None] = mapped_column(sa.DateTime, nullable=True)
frozen_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
supersedes_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
__table_args__ = (
sa.UniqueConstraint("snapshot_batch_id", "user_id",
name="uq_score_snapshot_batch_user"),
Index("ix_score_snapshot_period", "period_type", "period_start"),
)
class ManagementFollowup(Base):
"""管理辅导闭环(v1 §7.3/v2 §3.3)——来源指标→问题→管理动作→结果。"""
__tablename__ = "management_followup"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
employee_user_id: Mapped[str] = mapped_column(String(64), nullable=False)
# metric | anomaly | review | manual
source_type: Mapped[str] = mapped_column(String(16), nullable=False)
source_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
source_period_start: Mapped[int | None] = mapped_column(Integer, nullable=True)
source_period_end: Mapped[int | None] = mapped_column(Integer, nullable=True)
source_value_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
reason: Mapped[str | None] = mapped_column(sa.Text, nullable=True)
action_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
owner_manager_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# open | in_progress | resolved | cancelled
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="open", server_default="open"
)
due_at: Mapped[dt.datetime | None] = mapped_column(sa.DateTime, nullable=True)
result: Mapped[str | None] = mapped_column(sa.Text, nullable=True)
employee_feedback: Mapped[str | None] = mapped_column(sa.Text, nullable=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[dt.datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=sa.func.now()
)
updated_at: Mapped[dt.datetime] = _updated_at()
closed_at: Mapped[dt.datetime | None] = mapped_column(sa.DateTime, nullable=True)
__table_args__ = (
Index("ix_followup_emp_status_due", "employee_user_id", "status", "due_at"),
Index("ix_followup_owner_status", "owner_manager_id", "status"),
)
__all__ = [ __all__ = [
"ChatGroup", "ChatGroup",
"Customer", "Customer",
"Department", "Department",
"EmployeeCallDaily",
"EmployeeMetricDaily",
"EmployeeScoreConfig",
"EmployeeScoreSnapshot",
"FirstReplyFact",
"GroupMember", "GroupMember",
"ManagementFollowup",
"Media", "Media",
"Message", "Message",
"MomentsPost", "MomentsPost",
+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/数字串,统一归一为 ``'YYYY-MM-DD HH:mm:ss'`` 字符串,另兼容 ms-int/数字串,统一归一为
ms-epoch int,复用 ``_parse_add_time`` 的双形态解析) ms-epoch int,复用 ``_parse_add_time`` 的双形态解析)
""" """
return { rec = {
"room_id": record.get("friendWxId"), "room_id": record.get("friendWxId"),
"channel": "wechat", "channel": "wechat",
"room_name": record.get("friendWxNickname"), "room_name": record.get("friendWxNickname"),
@@ -929,6 +929,12 @@ def _normalize_chat_group(record: dict[str, Any]) -> dict[str, Any]:
"version": record.get("version"), "version": record.get("version"),
"raw_json": record, "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( async def scrape_wechat_groups(
@@ -0,0 +1,356 @@
"""employee_call_daily / employee_metric_daily 维护层(管理域,批次A)。
数据流(v2 §4):
msg_daily_stats(friend) + first_reply_facts 同源扫描 + customer 生命周期
→ employee_metric_daily (day, channel, user, metric_version) [Task 6]
phone_call(按 user_id+start_call_time 日聚合)
→ employee_call_daily (day, user)——通话无渠道维(v2 §1.2 #9) [本任务]
幽灵员工过滤(v2 §1.3 #11):owner 映射不上或不在 staff 表的不落表。
重算语义:按 day delete+insert,幂等;夜间全量 + (批次F)4h 滚动。
"""
from __future__ import annotations
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
from app.analytics.lifecycle import classify
from app.db.models import (
Customer,
EmployeeCallDaily,
EmployeeMetricDaily,
Message,
MsgDailyStats,
PhoneCall,
Staff,
)
from app.storage.first_reply_facts import (
compute_conversation,
load_conversation,
pairs_active_in_days,
)
from app.storage.staff_summary import account_map
from app.storage.summary import day_bounds_ms
logger = logging.getLogger(__name__)
# 通话类型 → 消息渠道(Task0 核实:channel 列可判别,voice_call=微信/
# wa_voice_call=WA;followup 消息查询按此收窄)
_CALL_CHANNEL_TO_MSG: dict[str, str] = {"voice_call": "wechat", "wa_voice_call": "whatsapp"}
async def known_user_ids(session: AsyncSession) -> set[str]:
"""员工主数据 user_id 集合(staff 表;Task0 已核实 41 行全在职)。"""
rows = (await session.execute(sa.select(Staff.user_id))).scalars().all()
return {u for u in rows if u}
async def _followup_exists(
session: AsyncSession,
msg_channel: str,
owner_accounts: list[str],
peer: str,
call_ts_ms: int,
window_ms: int = 86_400_000,
) -> bool:
"""通话后 24h 内员工任一账号对该客户有 1:1 消息。
含客户发来的消息——口径=窗口内存在任一方向 1:1 消息。
按通话类型收窄消息渠道(Task0:voice_call→wechat/wa_voice_call→whatsapp,
1:1 通话可匹配率 100%,@chatroom 群通话天然不匹配 1:1 消息)。"""
if not owner_accounts or not peer:
return False
n = (
await session.execute(
sa.select(sa.func.count()).select_from(Message).where(
Message.channel == msg_channel,
Message.owner_account_id.in_(owner_accounts),
Message.talker_id == peer,
sa.or_(Message.room_id.is_(None), Message.room_id == ""),
Message.timestamp.is_not(None),
Message.timestamp > call_ts_ms,
Message.timestamp <= call_ts_ms + window_ms,
)
)
).scalar_one()
return bool(n)
async def recompute_call_days(session: AsyncSession, days: Iterable[int]) -> int:
"""重算给定日的通话小表:按 day delete+insert,幂等。返回写入行数。
口径:接通 = call_duration > 0(Task0:与 call_status=1 差 31 条/1.0%,
取 dur>0 与现有 roadmap 口径一致);call_seconds 单位=秒(Task0 已核实);
followup 只对已接通通话判定(未接通的回拨语义留二期)。
"""
# 员工→该渠道账号(followup 判定用,按消息渠道分组)
acct = {ch: await account_map(session, ch) for ch in ("wechat", "whatsapp")}
rev: dict[str, dict[str, list[str]]] = {} # uid → msg_channel → owners
for msg_ch, amap in acct.items():
for owner, uid in amap.items():
rev.setdefault(uid, {}).setdefault(msg_ch, []).append(owner)
written = 0
staff_ids = await known_user_ids(session) # 幽灵过滤(v2 §1.3 #11)
for day in sorted(set(days)):
lo, hi = day_bounds_ms(day)
calls = (
await session.execute(
sa.select(
PhoneCall.user_id,
PhoneCall.channel,
PhoneCall.call_number,
PhoneCall.start_call_time,
PhoneCall.call_duration,
).where(
PhoneCall.start_call_time.is_not(None),
PhoneCall.start_call_time >= lo,
PhoneCall.start_call_time < hi,
)
)
).all()
await session.execute(
sa.delete(EmployeeCallDaily).where(EmployeeCallDaily.day == day)
)
# (uid, call_type, number, ts, dur)——msg_channel 按通话类型映射;
# 未知通话类型(如蜂窝 phone_call)无消息渠道可查,followup 判
# False 但仍计数
agg: dict[str, dict] = {}
for uid, call_ch, number, ts, dur in calls:
if not uid or uid not in staff_ids:
continue
a = agg.setdefault(uid, {"cnt": 0, "sec": 0, "answered": 0, "followup": 0})
a["cnt"] += 1
a["sec"] += int(dur or 0)
if (dur or 0) > 0:
a["answered"] += 1
msg_ch = _CALL_CHANNEL_TO_MSG.get(call_ch or "")
if msg_ch and number:
owners = rev.get(uid, {}).get(msg_ch, [])
if await _followup_exists(session, msg_ch, owners, str(number), int(ts)):
a["followup"] += 1
for uid, a in sorted(agg.items()):
session.add(
EmployeeCallDaily(
day=day,
user_id=uid,
call_count=a["cnt"],
call_seconds=a["sec"],
answered_call_count=a["answered"],
call_followup_count=a["followup"],
)
)
written += 1
return written
METRIC_VERSION = "v1"
def _percentile(vals: list[int], q: float) -> int | None:
"""最近邻秩分位 ceil(q·n);空列表→None(与 0 严格区分)。"""
if not vals:
return None
s = sorted(vals)
idx = min(len(s) - 1, max(0, math.ceil(q * len(s)) - 1))
return s[idx]
async def _lifecycle_daily(
session: AsyncSession,
days: list[int],
channel: str,
amap: dict[str, str],
valid_users: set[str],
) -> dict[int, dict[str, dict[str, int]]]:
"""逐日 {day: {user: {state: count}}}——customer 当前归属 + 互动日 sweep。
数据源:msg_daily_stats friend 行全历史(互动日/条数)。hist 为 ≤as_of
的历史消息数(时点语义:与 last 同一 bisect 位取按日累积数组 cum[idx],
as_of 之后的消息不虚增历史日的沉睡门槛)。16k 客户 × 窗口天数
bisect+classify,夜间全量实测见批次A核实文档;4h tick 只算当日+3 天。
"""
rows = (
await session.execute(
sa.select(
MsgDailyStats.peer_id, MsgDailyStats.day,
MsgDailyStats.send_cnt, MsgDailyStats.recv_cnt,
).where(
MsgDailyStats.channel == channel,
MsgDailyStats.peer_kind == "friend",
)
)
).all()
# 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:
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(
sa.select(Customer.owner_account_id, Customer.friend_id).where(
Customer.channel == channel,
sa.or_(Customer.delete_flag.is_(None), Customer.delete_flag == 0),
Customer.owner_account_id.is_not(None),
)
)
).all()
peer_to_user: dict[str, str] = {}
for owner, peer in custs:
uid = amap.get(owner or "")
if uid and uid in valid_users and peer:
peer_to_user[str(peer)] = uid
out: dict[int, dict[str, dict[str, int]]] = {d: {} for d in days}
for peer, days_list in interaction.items():
uid = peer_to_user.get(peer)
if uid is None:
continue
first = days_list[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
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
return out
async def recompute_metric_days(
session: AsyncSession,
days: Iterable[int],
channels: tuple[str, ...] = ("wechat", "whatsapp"),
) -> int:
"""重算给定日的宽表:按 (day, channel) delete+insert,幂等。返回写入行数。
串类列与首响日值:扫窗口内活跃 friend 会话的**全历史**(每会话一次),
与 first_reply_fact 同一 compute_conversation——分子分母同源(v2 §1.1 #5)。
"""
days = sorted(set(days))
valid_users = await known_user_ids(session)
pairs = await pairs_active_in_days(session, days)
pairs_by_channel: dict[str, list] = {}
for p in pairs:
pairs_by_channel.setdefault(p[0], []).append((p[1], p[2]))
written = 0
for channel in channels:
amap = await account_map(session, channel)
# ---- 串类+分位:逐会话全历史一次,聚合到 (day, user) ----
thread_cols: dict[tuple[int, str], dict] = {}
frs: dict[tuple[int, str], list[int]] = {}
day_set = set(days)
for owner, peer in pairs_by_channel.get(channel, []):
uid = amap.get(owner)
if uid is None or uid not in valid_users:
continue
rows = await load_conversation(session, channel, owner, peer)
facts, agg = compute_conversation(rows)
for d, a in agg.items():
if d not in day_set:
continue
key = (d, uid)
t = thread_cols.setdefault(
key, {"threads": 0, "answered": 0, "fr": 0}
)
t["threads"] += a.threads
t["answered"] += a.answered
t["fr"] += a.fr_sum_ms
frs.setdefault(key, []).extend(
f.fr_ms for f in facts if f.day == d
)
# ---- 生命周期 ----
life = await _lifecycle_daily(session, days, channel, amap, valid_users)
# ---- 消息计数+负载:msg_daily_stats(算法无关列) ----
for day in days:
mds = (
await session.execute(
sa.select(
MsgDailyStats.owner_account_id,
MsgDailyStats.peer_kind,
MsgDailyStats.peer_id,
MsgDailyStats.send_cnt,
MsgDailyStats.recv_cnt,
).where(
MsgDailyStats.day == day, MsgDailyStats.channel == channel
)
)
).all()
await session.execute(
sa.delete(EmployeeMetricDaily).where(
EmployeeMetricDaily.day == day,
EmployeeMetricDaily.channel == channel,
EmployeeMetricDaily.metric_version == METRIC_VERSION,
)
)
agg: dict[str, dict] = {}
for owner, kind, peer, snd, rcv in mds:
uid = amap.get(owner)
if uid is None or uid not in valid_users:
continue # 幽灵过滤
a = agg.setdefault(
uid,
{"sent": 0, "recv": 0, "friends": set(), "rooms": set()},
)
# 消息计数列 = 1:1 口径(v2 §1.1 #2):room 只进 active_rooms
if kind == "friend":
a["sent"] += int(snd or 0)
a["recv"] += int(rcv or 0)
a["friends"].add(peer)
elif kind == "room":
a["rooms"].add(peer)
day_life = life.get(day, {})
# 行并集:消息源 ∪ 生命周期源——纯生命周期日(员工当日无消息
# 但持有降温/沉睡客户)也要出宽表行,否则客户经营维度漏天
all_uids = sorted(set(agg) | set(day_life))
for uid in all_uids:
a = agg.get(
uid, {"sent": 0, "recv": 0, "friends": set(), "rooms": set()}
)
key = (day, uid)
t = thread_cols.get(key, {"threads": 0, "answered": 0, "fr": 0})
lv = day_life.get(uid, {})
session.add(
EmployeeMetricDaily(
day=day,
channel=channel,
user_id=uid,
metric_version=METRIC_VERSION,
msg_sent=a["sent"],
msg_recv=a["recv"],
active_friends=len(a["friends"]),
active_rooms=len(a["rooms"]),
cust_threads=t["threads"],
threads_answered=t["answered"],
fr_sum_ms=t["fr"],
first_reply_p50_ms=_percentile(frs.get(key, []), 0.5),
first_reply_p90_ms=_percentile(frs.get(key, []), 0.9),
new_customer_count=lv.get("new", 0),
cooling_customer_count=lv.get("cooling", 0),
dormant_customer_count=lv.get("dormant", 0),
reactivated_customer_count=lv.get("reactivated", 0),
data_complete=True,
)
)
written += 1
# 防御性 flush:防 autoflush=False 类配置下逐日 add 的 ORM 对象
# 攒到事务尾(夜间全量约 52k 行;与 Task3 周期性 flush 同教训)
await session.flush()
return written
@@ -0,0 +1,195 @@
"""first_reply_fact 维护层(管理域,批次A)——跨日精确串算法。
区别于 app.storage.summary._thread_stats 的日切近似(v2 §1.1 #1):
按会话**全历史**计算,跨夜串(客户 23:00 → 员工次日 9:00 回)不再丢失。
**不挂写钩子**(v2 §3.1):由回填 CLI / 4h 滚动 / 夜间全量重算,写钩子
零改动。只处理 1:1(friend)会话——群串语义不成立。
compute_conversation 同时输出每日串聚合(threads/answered/fr_sum_ms,
按串末条归属日)——employee_metric_daily 的串类列与 fact 行**同源**
(回复率分子分母一致,v2 §1.1 #5)。
时序确定性:同毫秒并列(ts 相同的 F/T 消息)按 msg_id 定序(wechat
msgSvrId 服务端单调,近似真实到达序)——SQL 等键返回顺序未定义,不做
定序会导致串归属随查询抖动;fact PK 含 reply_msg_id,同毫秒 F,T,F,T
交换的两个样本不撞主键。
"""
from __future__ import annotations
import logging
from collections import namedtuple
from collections.abc import Iterable
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import FirstReplyFact, Message, MsgDailyStats
from app.storage.summary import day_key
logger = logging.getLogger(__name__)
# 一个被回串的首响样本;day = 串末条客户消息所在上海自然日,
# reply_msg_id = 闭合该串的员工回复消息 msg_id(PK 并列去重 + 下钻证据)
FactSample = namedtuple("FactSample", "thread_end_ts reply_ts fr_ms day reply_msg_id")
# 某会话某日的串聚合(分子分母同源)
DayAgg = namedtuple("DayAgg", "threads answered fr_sum_ms")
class _DayAcc:
__slots__ = ("answered", "fr_sum_ms", "threads")
def __init__(self) -> None:
self.threads = 0
self.answered = 0
self.fr_sum_ms = 0
def compute_conversation(rows: list) -> tuple[list[FactSample], dict[int, DayAgg]]:
"""跨日精确串算法。rows = [(ts, is_send, msg_id)] 该会话全历史,
**须按 (ts, msg_id) 升序**(load_conversation 负责排序;同毫秒并列以
msg_id 定序,见模块 docstring)。
- 串 = 客户连续消息(is_send=False)不被员工消息(is_send=True)打断的
段;is_send=None 跳过(不打断也不起串)——与 _thread_stats 判定一致。
- 每个被回串一个样本:fr_ms = 员工首条回复 ts − 串**末条**客户消息 ts,
归属 day = 串末条所在上海自然日(跨夜串不再丢失);reply_msg_id =
该回复消息 msg_id——一条回复恰好闭合一个串,同毫秒 F,T,F,T 交换的
(thread_end_ts, reply_ts) 并列靠它去重,兼作下钻证据。
- 会话末尾仍未闭合的串:计入当日 threads(回复率分母),不产生样本。
"""
facts: list[FactSample] = []
acc: dict[int, _DayAcc] = {}
in_thread = False
end_ts: int | None = None
for ts, is_send, msg_id in rows:
if is_send is False:
in_thread = True
end_ts = int(ts)
elif is_send is True:
if in_thread and end_ts is not None:
fr = int(ts) - end_ts
d = day_key(end_ts)
facts.append(FactSample(end_ts, int(ts), fr, d, msg_id))
a = acc.setdefault(d, _DayAcc())
a.threads += 1
a.answered += 1
a.fr_sum_ms += fr
in_thread = False
end_ts = None
# is_send=None:跳过
if in_thread and end_ts is not None: # 会话末尾未闭合串
a = acc.setdefault(day_key(end_ts), _DayAcc())
a.threads += 1
return facts, {d: DayAgg(a.threads, a.answered, a.fr_sum_ms)
for d, a in acc.items()}
async def load_conversation(
session: AsyncSession, channel: str, owner: str, peer: str
) -> list:
"""拉取该 1:1 会话全历史 (timestamp, is_send, msg_id),Python 侧按
(ts, msg_id) 排序——串算法需严格时序且同毫秒并列须定序(SQL 等键顺序
未定义,不排序会随查询抖动;与 summary.recompute_days 同惯例,避免
依赖 SQL 排序)。friend 过滤口径与 summary._peer_filter 的 friend 分支
同源:talker_id 原值精确匹配 + room_id 为空(peer_id 与 talker_id 同源
同形态,不做截断/归一化)。
公有命名:employee_metrics 宽表生成复用同一加载口径(同源原则)。"""
rows = (
await session.execute(
sa.select(
Message.timestamp, Message.is_send, Message.msg_id
).where(
Message.channel == channel,
Message.owner_account_id == owner,
Message.talker_id == peer,
sa.or_(Message.room_id.is_(None), Message.room_id == ""),
Message.timestamp.is_not(None),
)
)
).all()
return sorted(rows, key=lambda r: (r[0], r[2]))
async def recompute_pairs(
session: AsyncSession, pairs: list[tuple[str, str, str]]
) -> int:
"""重算给定 friend 会话的 fact 行:按会话 delete+insert,幂等。
pairs = [(channel, owner_account_id, peer_id)]。返回会话数。每 500 个
会话 flush 一次约束 session 内存(与 summary.recompute_all 同惯例——
全量回填百万级 fact 行不能攒到最后),同时打进度日志(回填分钟级,
可观测)。commit 由调用方负责。
"""
n = 0
for channel, owner, peer in sorted(pairs):
rows = await load_conversation(session, channel, owner, peer)
facts, _agg = compute_conversation(rows)
await session.execute(
sa.delete(FirstReplyFact).where(
FirstReplyFact.channel == channel,
FirstReplyFact.owner_account_id == owner,
FirstReplyFact.peer_id == peer,
)
)
for f in facts:
session.add(
FirstReplyFact(
channel=channel,
owner_account_id=owner,
peer_id=peer,
thread_end_ts=f.thread_end_ts,
reply_ts=f.reply_ts,
reply_msg_id=f.reply_msg_id,
day=f.day,
fr_ms=f.fr_ms,
)
)
n += 1
if n % 500 == 0:
await session.flush()
logger.info("first_reply_facts: 已重算 %d 个会话", n)
return n
async def all_friend_pairs(session: AsyncSession) -> list[tuple[str, str, str]]:
"""全部 1:1 会话(friend 行 distinct)——全量回填用。
依赖 msg_daily_stats 已回填(先 stats 后 facts):friend 会话清单取自
stats 表而非 message 全表 distinct——写路径钩子保证 stats 常新,代价
远低于扫 message。
"""
rows = (
await session.execute(
sa.select(
MsgDailyStats.channel,
MsgDailyStats.owner_account_id,
MsgDailyStats.peer_id,
).where(MsgDailyStats.peer_kind == "friend")
)
).all()
return sorted({(r[0], r[1], r[2]) for r in rows})
async def pairs_active_in_days(
session: AsyncSession, days: Iterable[int]
) -> list[tuple[str, str, str]]:
"""窗口内有消息的 friend 会话(4h 滚动重算用;批次F 接调度)。"""
rows = (
await session.execute(
sa.select(
MsgDailyStats.channel,
MsgDailyStats.owner_account_id,
MsgDailyStats.peer_id,
).where(
MsgDailyStats.peer_kind == "friend",
MsgDailyStats.day.in_(list(days)),
)
)
).all()
return sorted({(r[0], r[1], r[2]) for r in rows})
async def recompute_all(session: AsyncSession) -> int:
"""全量重建(回填/夜间自愈,幂等)。"""
return await recompute_pairs(session, await all_friend_pairs(session))
+75 -4
View File
@@ -62,7 +62,9 @@ from sqlalchemy.orm import DeclarativeBase
from app.db.models import ( from app.db.models import (
ChatGroup, ChatGroup,
ChatGroupMemberStats,
Customer, Customer,
CustomerAccountLink,
Department, Department,
GroupMember, GroupMember,
Media, Media,
@@ -81,8 +83,10 @@ _logger = logging.getLogger(__name__)
__all__ = [ __all__ = [
"upsert_chat_group", "upsert_chat_group",
"upsert_chat_group_member_stats",
"upsert_customer", "upsert_customer",
"upsert_customers", "upsert_customers",
"prune_customer_links",
"upsert_department", "upsert_department",
"upsert_media", "upsert_media",
"upsert_messages", "upsert_messages",
@@ -380,9 +384,34 @@ async def upsert_customer(session: AsyncSession, rec: dict[str, Any]) -> None:
"""Idempotently upsert one :class:`Customer` (PK = ``friend_id``). """Idempotently upsert one :class:`Customer` (PK = ``friend_id``).
Same ``friend_id`` twice -> one row, with non-PK fields refreshed from the Same ``friend_id`` twice -> one row, with non-PK fields refreshed from the
later record. later record. 同时维护 ``customer_account_link``(渠道×账号×客户一行,
2026-09-08):customer 档案全局单行 last-wins,共享客户的其他员工关系
靠 link 表保留;员工维度列表/计数读 link(对齐云客 per-employee 口径)。
""" """
await _upsert_one(session, Customer, rec) await _upsert_one(session, Customer, rec)
owner = rec.get("owner_account_id")
if owner:
raw = rec.get("raw_json") or {}
link_rec = {
"channel": rec.get("channel"),
"owner_account_id": owner,
"friend_id": rec.get("friend_id"),
"friend_nickname": rec.get("friend_nickname"),
"friend_remark": rec.get("friend_remark"),
"head_image_url": rec.get("head_image_url"),
"real_name": rec.get("real_name"),
"phone": rec.get("phone"),
"last_chat_time": rec.get("last_chat_time"),
"add_time": rec.get("add_time"),
"send_num": rec.get("send_num"),
"receive_num": rec.get("receive_num"),
"message_count": rec.get("message_count"),
"delete_flag": rec.get("delete_flag"),
}
# fromType 只在 raw_json(云客数据来源代码,员工客户页列)
if isinstance(raw, dict) and raw.get("fromType") is not None:
link_rec["from_type"] = raw.get("fromType")
await _upsert_one(session, CustomerAccountLink, link_rec)
async def upsert_customers(session: AsyncSession, recs: list[dict[str, Any]]) -> int: async def upsert_customers(session: AsyncSession, recs: list[dict[str, Any]]) -> int:
@@ -392,10 +421,33 @@ async def upsert_customers(session: AsyncSession, recs: list[dict[str, Any]]) ->
single row (the last write wins) at the DB layer. single row (the last write wins) at the DB layer.
""" """
for rec in recs: for rec in recs:
await _upsert_one(session, Customer, rec) await upsert_customer(session, rec)
return len(recs) return len(recs)
async def prune_customer_links(
session: AsyncSession,
*,
channel: str,
owner_account_id: str,
keep_friend_ids: list[str],
) -> int:
"""删除该账号名下**不在最新名单**里的 customer_account_link 行。
云客 isDel=0 只返回当前好友;抓取器逐员工整批 upsert 后调用本函数对账
(离开名单=已不是好友,或好友转移)。**调用方必须保证名单完整**(翻满
声明 total)再调——拿到半份名单就 prune 会误删。返回删除行数。
"""
conditions = [
CustomerAccountLink.channel == channel,
CustomerAccountLink.owner_account_id == owner_account_id,
]
if keep_friend_ids:
conditions.append(CustomerAccountLink.friend_id.not_in(keep_friend_ids))
result = await session.execute(sa.delete(CustomerAccountLink).where(*conditions))
return int(result.rowcount or 0)
async def upsert_messages(session: AsyncSession, recs: list[dict[str, Any]]) -> int: async def upsert_messages(session: AsyncSession, recs: list[dict[str, Any]]) -> int:
"""Batch idempotent upsert for :class:`Message` (PK = ``msg_id``). """Batch idempotent upsert for :class:`Message` (PK = ``msg_id``).
@@ -581,6 +633,18 @@ async def upsert_chat_group(session: AsyncSession, rec: dict[str, Any]) -> None:
await _upsert_one(session, GroupMember, member_rec) 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) # PhoneCall (voice_call) upsert (M2, Task 2.4)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -739,8 +803,13 @@ async def upsert_media(
dialect = await _dialect_name(session) dialect = await _dialect_name(session)
pk_cols = _pk_col_names(Media) 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 = [ 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 media_cols = Media.__table__.c
@@ -748,7 +817,8 @@ async def upsert_media(
"""Map each updated column to its conflict-set expression. """Map each updated column to its conflict-set expression.
Volatile download artifacts use ``COALESCE(incoming, existing)``; every 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] = {} set_: dict[str, Any] = {}
for name in update_cols: for name in update_cols:
@@ -756,6 +826,7 @@ async def upsert_media(
set_[name] = sa.func.coalesce(other_cols[name], media_cols[name]) set_[name] = sa.func.coalesce(other_cols[name], media_cols[name])
else: else:
set_[name] = other_cols[name] set_[name] = other_cols[name]
set_["updated_at"] = sa.func.now()
return set_ return set_
if dialect == "sqlite": 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 daily-stats
python -m app.sync.cli staff-daily-stats python -m app.sync.cli staff-daily-stats
python -m app.sync.cli sensitive-scan --full 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 Auth + DB
--------- ---------
@@ -49,6 +52,8 @@ import signal
import sys import sys
from typing import Any from typing import Any
import sqlalchemy as sa
from app.config import get_settings from app.config import get_settings
from app.db import models # noqa: F401 (register tables on Base.metadata) from app.db import models # noqa: F401 (register tables on Base.metadata)
from app.db.base import Base from app.db.base import Base
@@ -136,6 +141,43 @@ def _build_parser() -> argparse.ArgumentParser:
help="Full backfill scan (first deploy / new-word history).", 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`` ----------------------------------------------------------- # ``scheduler`` -----------------------------------------------------------
p_sched = sub.add_parser( p_sched = sub.add_parser(
"scheduler", "scheduler",
@@ -374,6 +416,77 @@ async def _cmd_sensitive_scan(args: argparse.Namespace) -> int:
await engine.dispose() 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: def main(argv: list[str] | None = None) -> int:
"""CLI entry point. Returns a process exit code.""" """CLI entry point. Returns a process exit code."""
# Configure logging so the scheduler's JSON line + the orchestrator's # 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)) return asyncio.run(_cmd_staff_daily_stats(args))
if args.command == "sensitive-scan": if args.command == "sensitive-scan":
return asyncio.run(_cmd_sensitive_scan(args)) 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: except KeyboardInterrupt:
# So `python -m app.sync.cli scheduler` exits cleanly on Ctrl-C. # So `python -m app.sync.cli scheduler` exits cleanly on Ctrl-C.
return 0 return 0
+106 -35
View File
@@ -602,41 +602,65 @@ class SyncScheduler:
if not bash: if not bash:
logger.error("page sync 'full': bash not found on PATH") logger.error("page sync 'full': bash not found on PATH")
return 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": elif kind == "messages":
# 先刷客户列表(最近沟通时间是云客列表的实时排序键,friends 快照 # 2026-09-10 调度根治(用户报「员工列表时间差 2 天」):
# 滞后会让本地排序与云客第一页完全不同,2026-08-20),再抓全部四类 # ① 序列头部刷员工账号表(accounts ~1min)——员工列表「数据
# 消息增量(2026-08-21 全接入:水位增量后单对话 1 页,可承受); # 更新时间/最新消息时间」从此最多滞后 4h(此前只在每日
# 末尾追加员工朋友圈浅扫(2026-08-25,max-pages 3 即每员工最多 # full 刷,长任务挤掉 full 就停更);
# 3 页,新帖通常落第一页;全量翻页由每日 full 走 full_sync_all.sh) # ② 消息实体只扫**近 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 = [ commands = [
[sys.executable, "scripts/scrape_via_page.py", "--entity", "friends"], (
[sys.executable, "scripts/scrape_via_page.py", "--entity", "wa-friends"],
] + [
[ [
sys.executable, sys.executable,
"scripts/scrape_via_page.py", "scripts/scrape_via_page.py",
"--entity", "--entity",
e, entity,
]
+ (
[
"--max-conversations", "--max-conversations",
"99999", "99999",
"--active-window-hours",
str(window),
] ]
for e in ("messages", "wa-messages", "group-messages", "wa-group-messages") if window
] + [ else (
[ ["--max-pages", "3"]
sys.executable, if entity == "moments"
"scripts/scrape_via_page.py", else []
"--entity", )
"moments", ),
"--max-pages", timeout,
"3", )
] for entity, window, timeout in msg_entities
] ]
elif kind == "full-history": elif kind == "full-history":
# 每周全量对齐(周日 05:00):强制全量翻历史,对齐增量模式抓不到的 # 每周全量对齐(周日 05:00):强制全量翻历史,对齐增量模式抓不到的
# 撤回/编辑消息 + 彻底消除 lastChatTime 滞后窗口(2026-08-21) # 撤回/编辑消息 + 彻底消除 lastChatTime 滞后窗口(2026-08-21)
entities = ["messages", "group-messages", "wa-messages", "wa-group-messages"] entities = ["messages", "group-messages", "wa-messages", "wa-group-messages"]
commands = [ commands = [
(
[ [
sys.executable, sys.executable,
"scripts/scrape_via_page.py", "scripts/scrape_via_page.py",
@@ -645,7 +669,10 @@ class SyncScheduler:
"--max-conversations", "--max-conversations",
"99999", "99999",
"--full-history", "--full-history",
] ],
6 * 3600, # 单实体 6h 上限(2026-09-10:防周日的全历史
# 对齐拖进周一,把 02:00 full 又挤掉——同类事故根治)
)
for e in entities for e in entities
] ]
else: else:
@@ -653,8 +680,8 @@ class SyncScheduler:
return return
rc = 0 rc = 0
for args in commands: for args, timeout in commands:
rc = await self._page_subprocess(args) or rc rc = await self._page_subprocess(args, timeout=timeout) or rc
if rc != 0: if rc != 0:
logger.warning("page sync %s: command failed rc=%s, skipping rest", kind, rc) logger.warning("page sync %s: command failed rc=%s, skipping rest", kind, rc)
break break
@@ -674,25 +701,27 @@ class SyncScheduler:
print(line, flush=True) print(line, flush=True)
async def _analytics_tick(self) -> None: async def _analytics_tick(self) -> None:
"""4h 分析链(stat_daily_staff 滚动3天 + 敏感词增量)——异常只记日志不断链。 """4h 分析链(staff 滚动3天 + 敏感词增量 + 管理域滚动)——异常只记日志不断链。
纯 DB 无浏览器子进程,不抢 ``_page_lock``(DB 写毫秒级; 两段独立事务:段1 staff+敏感词(既有);段2 管理域(fact 近3天活跃会话
``max_instances=1`` 防自身重叠即可)。与夜间 full_sync 的 + 宽表/通话 滚动3天,批次F 2026-09-10)。段2 失败只回滚自身,不牵连
staff-daily-stats 步骤重叠时,两侧 DELETE+INSERT 在重叠 PK 范围上 段1 已提交结果(v2 Task12:指标失败不中断主分析链)。纯 DB 无浏览器
可能死锁/锁等待——非致命:tick 吞异常下轮重试,夜间脚本无 set -e 子进程,不抢 ``_page_lock``;``max_instances=1`` 防自身重叠即可。与
次日自愈(UNIQUE/PK 幂等只保证串行提交下不重复,不消锁竞争)。 夜间 full_sync 的汇总/管理步重叠时,两侧 DELETE+INSERT 在重叠 PK
范围可能锁等待——非致命:tick 吞异常下轮重试,夜间脚本幂等自愈。
时区:``day_key`` 内部钉死 Asia/Shanghai,喂 UTC epoch 时区:``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: try:
async with self._session_maker() as session: async with self._session_maker() as session:
from app.analytics.sensitive import scanner from app.analytics.sensitive import scanner
from app.storage.staff_summary import recompute_days, rolling_days 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)) n = await recompute_days(session, rolling_days(today, 3))
scan = await scanner.run_incremental(session) scan = await scanner.run_incremental(session)
await session.commit() await session.commit()
@@ -703,8 +732,39 @@ class SyncScheduler:
except Exception: # noqa: BLE001 — scheduler MUST NOT crash except Exception: # noqa: BLE001 — scheduler MUST NOT crash
logger.exception("analytics tick failed") logger.exception("analytics tick failed")
async def _page_subprocess(self, args: list[str]) -> int: # ── 段2:管理域滚动(fact 活跃会话 + 宽表/通话,当日+滚动3天) ──
"""启动页面子进程并等待结束;继承环境 + 补 DB_URL/PYTHONIOENCODING。""" 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"} env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
settings = get_settings() settings = get_settings()
if settings.db_url and "DB_URL" not in env: if settings.db_url and "DB_URL" not in env:
@@ -714,7 +774,18 @@ class SyncScheduler:
cwd=str(REPO_ROOT), cwd=str(REPO_ROOT),
env=env, env=env,
) )
if timeout is None:
return await proc.wait() 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( async def _safe_run(
self, entity: str, *, log: bool = True self, entity: str, *, log: bool = True
@@ -0,0 +1,164 @@
"""经营管理后台批次A:6 张新表(fact/宽表/通话小表/评分配置/快照/辅导)
Revision ID: b7c8d9e0f1a2
Revises: f7a8b9c0d1e2
Create Date: 2026-09-08
spec: docs/superpowers/plans/2026-09-08-yunke-management-console-implementation-v2.md §3。
幂等:sa.inspect 先查,存在即跳过;SQLite/MySQL 通用。回填走 CLI
(first-reply-facts / employee-metrics),migration 不扫数据。
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "b7c8d9e0f1a2"
down_revision: str | Sequence[str] | None = "f7a8b9c0d1e2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_TABLES = [
("first_reply_fact", lambda: op.create_table(
"first_reply_fact",
sa.Column("channel", sa.String(length=16), primary_key=True),
sa.Column("owner_account_id", sa.String(length=128), primary_key=True),
sa.Column("peer_id", sa.String(length=128), primary_key=True),
sa.Column("thread_end_ts", sa.BigInteger(), primary_key=True),
sa.Column("reply_ts", sa.BigInteger(), primary_key=True),
# 同毫秒 F,T,F,T 交换的 (thread_end_ts, reply_ts) 并列去重(一条回复
# 恰好闭合一个串,reply_msg_id 唯一定位);兼作下钻证据定位回复消息
sa.Column("reply_msg_id", sa.String(length=64), primary_key=True),
sa.Column("day", sa.Integer(), nullable=False),
sa.Column("fr_ms", sa.BigInteger(), nullable=False),
)),
("employee_metric_daily", lambda: op.create_table(
"employee_metric_daily",
sa.Column("day", sa.Integer(), primary_key=True),
sa.Column("channel", sa.String(length=16), primary_key=True),
sa.Column("user_id", sa.String(length=64), primary_key=True),
sa.Column("metric_version", sa.String(length=16), primary_key=True,
server_default="v1"),
sa.Column("msg_sent", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("msg_recv", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("active_friends", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("active_rooms", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("cust_threads", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("threads_answered", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("fr_sum_ms", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
sa.Column("first_reply_p50_ms", sa.BigInteger(), nullable=True),
sa.Column("first_reply_p90_ms", sa.BigInteger(), nullable=True),
sa.Column("new_customer_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("cooling_customer_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("dormant_customer_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("reactivated_customer_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("data_complete", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("computed_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)),
("employee_call_daily", lambda: op.create_table(
"employee_call_daily",
sa.Column("day", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.String(length=64), primary_key=True),
sa.Column("call_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("call_seconds", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
sa.Column("answered_call_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("call_followup_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("computed_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)),
("employee_score_config", lambda: op.create_table(
"employee_score_config",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("version", sa.String(length=32), nullable=False, unique=True),
sa.Column("name", sa.String(length=128), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False, server_default="draft"),
sa.Column("effective_from", sa.Integer(), nullable=True),
sa.Column("effective_to", sa.Integer(), nullable=True),
sa.Column("config_json", sa.JSON(), nullable=False),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("approved_by", sa.String(length=64), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("activated_at", sa.DateTime(), nullable=True),
)),
("employee_score_snapshot", lambda: op.create_table(
"employee_score_snapshot",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("snapshot_batch_id", sa.String(length=64), nullable=False),
sa.Column("period_type", sa.String(length=8), nullable=False),
sa.Column("period_start", sa.Integer(), nullable=False),
sa.Column("period_end", sa.Integer(), nullable=False),
sa.Column("user_id", sa.String(length=64), nullable=False),
sa.Column("score_config_version", sa.String(length=32), nullable=False),
sa.Column("metric_version", sa.String(length=16), nullable=False),
sa.Column("total_score", sa.Integer(), nullable=True),
sa.Column("grade", sa.String(length=2), nullable=True),
sa.Column("score_status", sa.String(length=24), nullable=False, server_default="scored"),
sa.Column("dimension_scores_json", sa.JSON(), nullable=True),
sa.Column("metric_details_json", sa.JSON(), nullable=True),
sa.Column("data_as_of", sa.Integer(), nullable=False),
sa.Column("generated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("frozen_at", sa.DateTime(), nullable=True),
sa.Column("frozen_by", sa.String(length=64), nullable=True),
sa.Column("supersedes_id", sa.Integer(), nullable=True),
sa.UniqueConstraint("snapshot_batch_id", "user_id",
name="uq_score_snapshot_batch_user"),
)),
("management_followup", lambda: op.create_table(
"management_followup",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("employee_user_id", sa.String(length=64), nullable=False),
sa.Column("source_type", sa.String(length=16), nullable=False),
sa.Column("source_code", sa.String(length=64), nullable=True),
sa.Column("source_period_start", sa.Integer(), nullable=True),
sa.Column("source_period_end", sa.Integer(), nullable=True),
sa.Column("source_value_json", sa.JSON(), nullable=True),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("action_type", sa.String(length=32), nullable=True),
sa.Column("owner_manager_id", sa.String(length=64), nullable=True),
sa.Column("status", sa.String(length=16), nullable=False, server_default="open"),
sa.Column("due_at", sa.DateTime(), nullable=True),
sa.Column("result", sa.Text(), nullable=True),
sa.Column("employee_feedback", sa.Text(), nullable=True),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("closed_at", sa.DateTime(), nullable=True),
)),
]
_INDEXES = [
("first_reply_fact", "ix_fr_fact_day_channel", ["day", "channel"]),
("employee_score_snapshot", "ix_score_snapshot_period", ["period_type", "period_start"]),
("management_followup", "ix_followup_emp_status_due",
["employee_user_id", "status", "due_at"]),
("management_followup", "ix_followup_owner_status", ["owner_manager_id", "status"]),
]
def _has_index(bind, table: str, index: str) -> bool:
insp = sa.inspect(bind)
if not insp.has_table(table):
return False
return index in {ix["name"] for ix in insp.get_indexes(table)}
def upgrade() -> None:
bind = op.get_bind()
insp = sa.inspect(bind)
for name, create in _TABLES:
if not insp.has_table(name):
create()
# 索引守卫独立于建表(MySQL DDL 逐句自动提交;建表成功但索引失败的
# 中断重跑要能补上索引)
for table, index, cols in _INDEXES:
if not _has_index(bind, table, index):
op.create_index(index, table, cols)
def downgrade() -> None:
bind = op.get_bind()
insp = sa.inspect(bind)
for table in ("management_followup", "employee_score_snapshot",
"employee_score_config", "employee_call_daily",
"employee_metric_daily", "first_reply_fact"):
if insp.has_table(table):
op.drop_table(table)
@@ -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")
@@ -0,0 +1,78 @@
"""customer_account_link 账号↔客户多对多关联表
Revision ID: f7a8b9c0d1e2
Revises: e6f7a8b9c0d1
Create Date: 2026-09-08
内容
----
customer 表 PK=friend_id 全局一客户一行、归属=最后抓取员工(last-wins)。
共享客户(同一人加多个员工)在镜像里只挂在最后抓的员工名下——实测 Lily
云客好友 36,镜像员工客户页只有 12,其余 24 个全在另外 9 个员工名下
(2026-09-08 用户报「好友数与点进去的数量不一致」真机探明)。
云客口径是**每个账号各算一个**(per-employee 列表连备注/收发计数都按
关系各一份)。本表每个「渠道×账号×客户」一行:
- 写:repos.upsert_customer 随档案同事务维护(所有好友抓取路径一处挂全);
- 读:/api/customers?owner_account_id= 改读本表(员工维度列表/计数),
与云客 contacts_num 完全对齐;
- 对账:scrape_friends 逐员工整批 upsert 后删除该员工名下不在最新名单
里的 link(云客 isDel=0 返回当前好友,删链=已不是好友)。
**上线后需重跑 friends + wa-friends 各一次回填 link**(server:
`docker compose exec backend python scripts/scrape_via_page.py --entity friends`)
——存量关系无法从单行档案反推。
幂等:sa.inspect 先查表存在则跳过。SQLite/MySQL 通用。
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'f7a8b9c0d1e2'
down_revision: str | Sequence[str] | None = 'e6f7a8b9c0d1'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_TABLE = 'customer_account_link'
def _has_table(table: str) -> bool:
insp = sa.inspect(op.get_bind())
return table in insp.get_table_names()
def upgrade() -> None:
"""Upgrade schema."""
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('friend_id', sa.String(128), primary_key=True),
sa.Column('friend_nickname', sa.String(255), nullable=True),
sa.Column('friend_remark', sa.String(255), nullable=True),
sa.Column('head_image_url', sa.String(1024), nullable=True),
sa.Column('real_name', sa.String(64), nullable=True),
sa.Column('phone', sa.String(64), nullable=True),
sa.Column('last_chat_time', sa.BigInteger(), nullable=True),
sa.Column('add_time', sa.BigInteger(), nullable=True),
sa.Column('send_num', sa.Integer(), nullable=True),
sa.Column('receive_num', sa.Integer(), nullable=True),
sa.Column('message_count', sa.Integer(), nullable=True),
sa.Column('from_type', sa.Integer(), nullable=True),
sa.Column('delete_flag', sa.Integer(), nullable=True),
sa.Column('fetched_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
)
def downgrade() -> None:
"""Downgrade schema."""
if not _has_table(_TABLE):
return
op.drop_table(_TABLE)
+34 -12
View File
@@ -1,12 +1,15 @@
#!/usr/bin/env python #!/usr/bin/env python
"""从群消息派生 group_member(发言者=成员)。 """从群消息派生 group_member(发言者=成员)+ customer 兜底补全昵称/头像。
云客 API 不单独给群成员列表(chatroom payload 的 memberIds 我们抓不到, 云客 API 不单独给群成员列表(chatroom payload 的 memberIds 我们抓不到,
2026-08-20 确认),但群消息的 talker 就是发言成员的 wxid、sender_name 是其 2026-08-20 确认),但群消息的 talker 就是发言成员的 wxid、sender_name/
群昵称——368k 群消息足够派生出高质量成员表(覆盖所有发过言的成员)。 sender_head_url 是其群昵称/头像——群消息足够派生出高质量成员表(覆盖
所有发过言的成员)。昵称/头像取该成员在群里**最新一条**消息的值。
昵称取该成员在群里最新一条消息的 sender_name(SQLite 的 max()+裸列行为, 未发言的成员(被拉进群从没说话)从 customer 表兜底:member_id=friend_id
取 max(timestamp) 同行的值)。幂等可重跑;建议在 group-messages 之后执行。 命中本司好友时补好友昵称/头像(2026-09-10 群详情成员墙)。
幂等可重跑;建议在 group-messages 之后执行(full_sync_all.sh 8b 已挂)。
用法: 用法:
cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" \ 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") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("backfill_group_members") log = logging.getLogger("backfill_group_members")
# row_number() 窗口取 timestamp 最新一行的 sender_name:MySQL 8 的 # row_number() 窗口取 timestamp 最新一行的 sender_name/sender_head_url:
# only_full_group_by 拒绝 SQLite 式「max()+裸列」(1055),窗口写法两库通用 # MySQL 8 的 only_full_group_by 拒绝 SQLite 式「max()+裸列」(1055),窗口
# 且语义严格(最新一条的昵称),不依赖方言碰巧行为 # 写法两库通用且语义严格(最新一条的昵称/头像),不依赖方言碰巧行为
SQL = sa_text(""" 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 ( 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, count(*) OVER (PARTITION BY room_id, talker_id) AS msg_count,
row_number() OVER ( row_number() OVER (
PARTITION BY room_id, talker_id ORDER BY timestamp DESC PARTITION BY room_id, talker_id ORDER BY timestamp DESC
@@ -44,6 +48,17 @@ FROM (
WHERE rn = 1 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 def main() -> None:
async with async_engine.connect() as conn: async with async_engine.connect() as conn:
@@ -51,12 +66,13 @@ async def main() -> None:
log.info(f"派生出 {len(rows)} 个 (群, 成员) 对") log.info(f"派生出 {len(rows)} 个 (群, 成员) 对")
async with async_session_maker() as session: 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, { await _upsert_one(session, GroupMember, {
"channel": "wechat", "channel": "wechat",
"room_id": room_id, "room_id": room_id,
"member_id": talker_id, "member_id": talker_id,
"nickname": sender_name, "nickname": sender_name,
"avatar": head_url,
# raw_json 附加派生统计,抽屉可见 # raw_json 附加派生统计,抽屉可见
"raw_json": { "raw_json": {
"derived_from": "group_messages", "derived_from": "group_messages",
@@ -65,7 +81,13 @@ async def main() -> None:
}, },
}) })
await session.commit() 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__": 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 $PY scripts/scrape_via_page.py --entity groups --max-employees 100
echo "$(date) --- 5b/10 WA groups ---" echo "$(date) --- 5b/10 WA groups ---"
$PY scripts/scrape_via_page.py --entity 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) ---" echo "$(date) --- 6/10 Voice calls (WeChat + WA) ---"
$PY scripts/scrape_via_page.py --entity voice-calls $PY scripts/scrape_via_page.py --entity voice-calls
# 语音重扫会把 record_media_hash 覆盖回 None —— 必须紧跟录音重链接 # 语音重扫会把 record_media_hash 覆盖回 None —— 必须紧跟录音重链接
@@ -53,6 +59,15 @@ $PY -m app.sync.cli daily-stats
# A1 员工指标读路径依赖此表,2026-09-04) # A1 员工指标读路径依赖此表,2026-09-04)
echo "$(date) --- 12/12 Staff-daily-stats rebuild ---" echo "$(date) --- 12/12 Staff-daily-stats rebuild ---"
$PY -m app.sync.cli staff-daily-stats $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 "=============================================="
echo "$(date) === FULL RE-SYNC COMPLETE ===" echo "$(date) === FULL RE-SYNC COMPLETE ==="
@@ -78,6 +93,9 @@ TABLES = [
("wechat messages", "SELECT COUNT(*) FROM message WHERE channel='wechat'"), ("wechat messages", "SELECT COUNT(*) FROM message WHERE channel='wechat'"),
("whatsapp messages", "SELECT COUNT(*) FROM message WHERE channel='whatsapp'"), ("whatsapp messages", "SELECT COUNT(*) FROM message WHERE channel='whatsapp'"),
("daily stats rows", "SELECT COUNT(*) FROM msg_daily_stats"), ("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 calls", "SELECT COUNT(*) FROM phone_call WHERE channel IN ('voice_call','wa_voice_call')"),
("voice clips", "SELECT COUNT(*) FROM voice_clip"), ("voice clips", "SELECT COUNT(*) FROM voice_clip"),
("moments posts", "SELECT COUNT(*) FROM moments_post"), ("moments posts", "SELECT COUNT(*) FROM moments_post"),
+376 -31
View File
@@ -267,10 +267,10 @@ async def scrape_friends(cookie_str: str, max_employees: int = 100):
小漂移可忽略,且与云客后台「按员工看客户列表」口径一致。员工清单来自 小漂移可忽略,且与云客后台「按员工看客户列表」口径一致。员工清单来自
本地 wechat_account(user_id 列)。 本地 wechat_account(user_id 列)。
""" """
from app.db.base import async_engine from app.db.base import async_engine, async_session_maker
from sqlalchemy import text as sa_text from sqlalchemy import text as sa_text
from app.scraper.wechat import _normalize from app.scraper.wechat import _normalize
from app.storage.repos import upsert_customer from app.storage.repos import prune_customer_links, upsert_customer
dept = await _root_dept_id() dept = await _root_dept_id()
async with async_engine.connect() as conn: async with async_engine.connect() as conn:
@@ -334,9 +334,21 @@ async def scrape_friends(cookie_str: str, max_employees: int = 100):
if records: if records:
await _upsert_all(upsert_customer, records) await _upsert_all(upsert_customer, records)
total_upserted += len(records) total_upserted += len(records)
# 对账(2026-09-08):云客 isDel=0 返回当前好友;名单取满(>=声明
# total)才可信,删掉该账号名下已不在名单里的 link(转移/删好友)。
# 半份名单宁可不对账,防误删。
pruned = 0
if declared is not None and len(rows) >= declared:
keep_ids = [r.get("friendWxId") for r in rows if r.get("friendWxId")]
async with async_session_maker() as session:
pruned = await prune_customer_links(
session, channel="wechat", owner_account_id=wx_id,
keep_friend_ids=keep_ids,
)
await session.commit()
logger.info( logger.info(
f" [{i+1}/{len(employees)}] {wx_id[:16]}..: declared={declared} " f" [{i+1}/{len(employees)}] {wx_id[:16]}..: declared={declared} "
f"rows={len(rows)} (total fetched {total_fetched})" f"rows={len(rows)} pruned={pruned} (total fetched {total_fetched})"
) )
await asyncio.sleep(2 + (i % 3)) await asyncio.sleep(2 + (i % 3))
@@ -502,7 +514,50 @@ async def paginate_chats(fetch_page, end_ms: int, max_pages: int = 200, sleep=No
await sleep(1) 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). """Conversation-pair discovery for the message scrapers (wechat + WA).
Sources (union, dedup): Sources (union, dedup):
@@ -515,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 Order: pairs with no messages yet FIRST (new coverage survives an
interruption), then the rest by customer last_chat_time DESC. 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 import json as _json
@@ -524,14 +584,14 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
async with async_engine.connect() as conn: async with async_engine.connect() as conn:
cust = ( cust = (
await conn.execute(sa_text( 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 " "WHERE channel=:ch AND owner_account_id IS NOT NULL AND friend_id IS NOT NULL "
"ORDER BY last_chat_time DESC" "ORDER BY last_chat_time DESC"
), {"ch": channel}) ), {"ch": channel})
).fetchall() ).fetchall()
voice = ( voice = (
await conn.execute(sa_text( 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}) ), {"vc": voice_channel})
).fetchall() ).fetchall()
with_msgs = ( with_msgs = (
@@ -542,20 +602,45 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
).fetchall() ).fetchall()
have = {(r[0], r[1]) for r in with_msgs} 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() seen: set[tuple[str, str]] = set()
fresh: list[tuple[str, str]] = [] fresh: list[tuple[str, str]] = []
known: 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: if key in seen:
return return
seen.add(key) 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: for owner, friend, last_ts in cust:
_add((owner, friend)) _add((owner, friend), last_ts)
voice_only = 0 voice_only = 0
for (rj,) in voice: cust_keys = {(r[0], r[1]) for r in cust}
for rj, call_ts in voice:
try: try:
d = _json.loads(rj) d = _json.loads(rj)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -564,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"): if not owner or not talker or str(talker).endswith("@chatroom"):
continue continue
before = len(seen) before = len(seen)
_add((str(owner), str(talker))) _add((str(owner), str(talker)), call_ts)
if len(seen) > before and (str(owner), str(talker)) not in cust: if len(seen) > before and (str(owner), str(talker)) not in cust_keys:
voice_only += 1 voice_only += 1
skipped = len(cust) + voice_only - len(fresh) - len(known)
logger.info( logger.info(
f"discovery[{channel}]: {len(cust)} customer pairs + {voice_only} voice-only pairs, " 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 return fresh + known
@@ -623,7 +710,7 @@ def _page_min_ts(data: dict) -> int:
return min(ts_list) if ts_list else 0 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. """Scrape wechat messages via browser fetch POST /wechatweb/msg/chats.
**增量同步(默认)**:每对话水位 = 本地 max(timestamp);请求带 **增量同步(默认)**:每对话水位 = 本地 max(timestamp);请求带
@@ -636,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.db.base import async_session_maker
from app.storage.repos import upsert_messages 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") watermarks = await _conversation_watermarks("wechat")
cust_activity = await _customer_activity("wechat") cust_activity = await _customer_activity("wechat")
@@ -725,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 ts = int(str(ts_raw)) if ts_raw else None
except (ValueError, TypeError): except (ValueError, TypeError):
ts = None ts = None
# 增量模式过滤边界旧消息(云客 start inclusive,2026-09-10)
if watermark and ts is not None and ts <= watermark:
continue
records.append({ 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)}"), "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", "channel": "wechat",
@@ -774,6 +872,8 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_hi
continue continue
total_fetched += len(records) total_fetched += len(records)
if not records:
await _mark_scan_empty("wechat_conv_empty", f"{owner_id}:{friend_id}")
total_pages += pages total_pages += pages
if records: if records:
@@ -841,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. """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 Same endpoint as 1:1 chats — pass the room id as ``friendId`` (confirmed
@@ -856,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 app.storage.repos import upsert_messages
from sqlalchemy import text as sa_text 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: async with async_engine.connect() as conn:
result = await conn.execute(sa_text( result = await conn.execute(sa_text(
"SELECT owner_account_id, room_id FROM chat_group " "SELECT owner_account_id, room_id FROM chat_group "
"WHERE channel='wechat' AND owner_account_id IS NOT NULL " "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" "ORDER BY last_chat_time DESC"
)) ), {"t": active_since_ms})
groups = [(row[0], row[1]) for row in result.fetchall()] groups = [(row[0], row[1]) for row in result.fetchall()]
room_wms = await _room_watermarks() room_wms = await _room_watermarks()
@@ -922,6 +1027,9 @@ async def scrape_group_messages(cookie_str: str, max_groups: int = 500, full_his
continue continue
rec = _normalize_group_message(row, owner_id, room_id) rec = _normalize_group_message(row, owner_id, room_id)
if rec is not None: if rec is not None:
# 增量模式过滤边界旧消息(同 1:1,2026-09-10)
if watermark and (rec.get("timestamp") or 0) <= watermark:
continue
records.append(rec) records.append(rec)
except Exception as e: except Exception as e:
@@ -1463,10 +1571,10 @@ async def scrape_wa_friends(cookie_str: str, max_pages: int = 2000):
全局查询有 ~1 万行硬上限(实测云客声明 11931 vs 全局翻页只到 10000, 全局查询有 ~1 万行硬上限(实测云客声明 11931 vs 全局翻页只到 10000,
2026-08-20);逐员工 ``uid`` 过滤(员工 WA 号)范围小无上限,页码字段是 2026-08-20);逐员工 ``uid`` 过滤(员工 WA 号)范围小无上限,页码字段是
``page``,total-aware 翻页(短页继续,满声明数或连续空页停)。""" ``page``,total-aware 翻页(短页继续,满声明数或连续空页停)。"""
from app.db.base import async_engine from app.db.base import async_engine, async_session_maker
from sqlalchemy import text as sa_text from sqlalchemy import text as sa_text
from app.scraper.whatsapp import _normalize_whatsapp_friend from app.scraper.whatsapp import _normalize_whatsapp_friend
from app.storage.repos import upsert_customer from app.storage.repos import prune_customer_links, upsert_customer
dept = await _root_dept_id() dept = await _root_dept_id()
async with async_engine.connect() as conn: async with async_engine.connect() as conn:
@@ -1520,8 +1628,20 @@ async def scrape_wa_friends(cookie_str: str, max_pages: int = 2000):
if records: if records:
await _upsert_all(upsert_customer, records) await _upsert_all(upsert_customer, records)
total_upserted += len(records) total_upserted += len(records)
# 对账(2026-09-08,同微信 friends):名单取满才删该账号名下
# 不在当前列表里的 link。
pruned = 0
if declared is not None and len(rows) >= declared:
keep_ids = [r.get("friendUid") for r in rows if r.get("friendUid")]
async with async_session_maker() as session:
pruned = await prune_customer_links(
session, channel="whatsapp", owner_account_id=wa_uid,
keep_friend_ids=keep_ids,
)
await session.commit()
logger.info( logger.info(
f" [{i+1}/{len(accounts)}] {wa_uid[:18]}..: declared={declared} rows={len(rows)}" f" [{i+1}/{len(accounts)}] {wa_uid[:18]}..: declared={declared} "
f"rows={len(rows)} pruned={pruned}"
) )
await asyncio.sleep(2 + (i % 3)) await asyncio.sleep(2 + (i % 3))
@@ -1607,6 +1727,200 @@ async def scrape_wa_groups(cookie_str: str, max_pages: int = 200):
return total_fetched, total_upserted 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) # 员工朋友圈 Moments (browser fetch, per employee)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1962,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 """Scrape WhatsApp chat records via browser fetch POST
/qqweb/WhatsAppUser/chatRecords (JSON body {uid, friendUid, start, end}). /qqweb/WhatsAppUser/chatRecords (JSON body {uid, friendUid, start, end}).
@@ -1977,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.scraper.whatsapp import _is_wa_divider, _normalize_whatsapp_message
from app.storage.repos import upsert_messages 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") watermarks = await _conversation_watermarks("whatsapp")
cust_activity = await _customer_activity("whatsapp") cust_activity = await _customer_activity("whatsapp")
@@ -2023,7 +2344,14 @@ async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full
msgs = data.get("messages") or [] msgs = data.get("messages") or []
for row in msgs: for row in msgs:
if isinstance(row, dict) and not _is_wa_divider(row): 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"): if not data.get("hasLast"):
break break
# 增量触底停:翻到已同步区 # 增量触底停:翻到已同步区
@@ -2054,6 +2382,8 @@ async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full
continue continue
total_fetched += len(records) total_fetched += len(records)
if not records:
await _mark_scan_empty("whatsapp_conv_empty", f"{uid}:{friend_uid}")
if records: if records:
# upsert_messages 收整列表(逐条版 _upsert_all 会把 dict 迭代成 # upsert_messages 收整列表(逐条版 _upsert_all 会把 dict 迭代成
# key 字符串 → TypeError, 2026-08-19 WA 阶段首条即崩的根因) # key 字符串 → TypeError, 2026-08-19 WA 阶段首条即崩的根因)
@@ -2100,7 +2430,7 @@ def _normalize_wa_group_message(record: dict) -> dict:
return rec 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 """WA 群消息:浏览器 fetch chatRecords,friendUid 直接传 @g.us 群 id
(真机确认 2026-08-20,与微信群的 msg/chats+roomId 同套路)。 (真机确认 2026-08-20,与微信群的 msg/chats+roomId 同套路)。
@@ -2114,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 app.storage.repos import upsert_messages
from sqlalchemy import text as sa_text 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: async with async_engine.connect() as conn:
result = await conn.execute(sa_text( result = await conn.execute(sa_text(
"SELECT owner_account_id, room_id FROM chat_group " "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()] groups = [(row[0], row[1]) for row in result.fetchall()]
room_wms = await _room_watermarks() room_wms = await _room_watermarks()
@@ -2162,7 +2497,11 @@ async def scrape_wa_group_messages(cookie_str: str, max_groups: int = 500, full_
for row in rows: for row in rows:
if not isinstance(row, dict) or _is_wa_divider(row): if not isinstance(row, dict) or _is_wa_divider(row):
continue 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: except Exception as e:
logger.warning(f" [{i+1}] {str(owner_id)[:18]}:{str(room_id)[:18]} failed: {e}") logger.warning(f" [{i+1}] {str(owner_id)[:18]}:{str(room_id)[:18]} failed: {e}")
@@ -2277,6 +2616,7 @@ async def main():
"wa-accounts", # WA员工账号 (waBusiness/userlist interception) "wa-accounts", # WA员工账号 (waBusiness/userlist interception)
"wa-friends", # WA客户 (waBusiness/friendlist interception) "wa-friends", # WA客户 (waBusiness/friendlist interception)
"wa-groups", # WA群 (browser fetch businessFriendList type=1) "wa-groups", # WA群 (browser fetch businessFriendList type=1)
"chatroom-stats", # 微信群统计 (getChatroomList 带窗,云客同款)
"voice-calls", # 语音通话记录 (voice-call-list + wavoicecalllist) "voice-calls", # 语音通话记录 (voice-call-list + wavoicecalllist)
"voice-clips", # 长语音 (browser fetch call/list per employee) "voice-clips", # 长语音 (browser fetch call/list per employee)
"wa-messages", # WA消息 (browser fetch chatRecords per conversation) "wa-messages", # WA消息 (browser fetch chatRecords per conversation)
@@ -2291,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("--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("--batch-size", type=int, default=50, help="媒体下载批次大小 (media)")
parser.add_argument("--full-history", action="store_true", help="消息类实体强制全量翻历史(默认增量水位)") 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() args = parser.parse_args()
cookie = _load_cookie() cookie = _load_cookie()
@@ -2313,9 +2656,9 @@ async def _dispatch_entity(args, cookie: str) -> None:
elif args.entity == "groups": elif args.entity == "groups":
await scrape_groups(cookie, args.max_employees) await scrape_groups(cookie, args.max_employees)
elif args.entity == "messages": 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": 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": elif args.entity == "accounts":
await scrape_accounts(cookie, args.max_pages) await scrape_accounts(cookie, args.max_pages)
elif args.entity == "wa-accounts": elif args.entity == "wa-accounts":
@@ -2324,14 +2667,16 @@ async def _dispatch_entity(args, cookie: str) -> None:
await scrape_wa_friends(cookie, args.max_pages) await scrape_wa_friends(cookie, args.max_pages)
elif args.entity == "wa-groups": elif args.entity == "wa-groups":
await scrape_wa_groups(cookie, args.max_pages) 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": elif args.entity == "voice-calls":
await scrape_voice_calls(cookie, args.max_pages) await scrape_voice_calls(cookie, args.max_pages)
elif args.entity == "voice-clips": elif args.entity == "voice-clips":
await scrape_voice_clips(cookie, args.max_employees) await scrape_voice_clips(cookie, args.max_employees)
elif args.entity == "wa-messages": 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": 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": elif args.entity == "moments":
await scrape_moments(cookie, args.max_pages) await scrape_moments(cookie, args.max_pages)
elif args.entity == "media": elif args.entity == "media":
+19
View File
@@ -112,3 +112,22 @@ async def make_client() -> AsyncIterator[
await client.aclose() await client.aclose()
await engine.dispose() await engine.dispose()
app.dependency_overrides.clear() app.dependency_overrides.clear()
@pytest.fixture
async def make_session() -> AsyncIterator[AsyncSession]:
"""独立内存 SQLite AsyncSession(不挂 API 依赖)——存储层/管理域测试用。
与 make_client 共用 StaticPool 单连接模式;每个测试独享全新 schema。
"""
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as s:
yield s
await engine.dispose()
+110
View File
@@ -148,3 +148,113 @@ async def test_q_escapes_like_wildcards(make_client):
# 只命中字面含 "100%" 的行;不因 % 通配漏扩到 "1001" # 只命中字面含 "100%" 的行;不因 % 通配漏扩到 "1001"
assert body["total"] == 1 assert body["total"] == 1
assert [it["friend_id"] for it in body["items"]] == ["wx_pct"] assert [it["friend_id"] for it in body["items"]] == ["wx_pct"]
# ---------------------------------------------------------------------------
# owner 维度读 customer_account_link(共享好友,2026-09-08)
# ---------------------------------------------------------------------------
async def _seed_links(session):
from app.db.models import CustomerAccountLink as L
# 档案路径的旧行:共享好友 f_shared 档案 last-wins 挂在 wx_b 名下
session.add_all([
L(channel="wechat", owner_account_id="wx_a", friend_id="f_shared",
friend_remark="A的备注", friend_nickname="共享客户", last_chat_time=2000,
send_num=3, receive_num=1, from_type=1),
L(channel="wechat", owner_account_id="wx_b", friend_id="f_shared",
friend_remark="B的备注", friend_nickname="共享客户", last_chat_time=1000),
L(channel="wechat", owner_account_id="wx_a", friend_id="f_only_a",
friend_nickname="A独有", last_chat_time=3000),
])
await session.commit()
async def test_owner_filter_reads_links(make_client):
"""owner 过滤走 link:共享好友在两个员工名下各出现,备注各是各的。"""
client = await make_client(_seed_links)
r_a = await client.get("/api/customers", params={
"channel": "wechat", "owner_account_id": "wx_a"})
assert r_a.status_code == 200
body_a = r_a.json()
assert body_a["total"] == 2
by_id = {it["friend_id"]: it for it in body_a["items"]}
# 排序:last_chat_time desc
assert [it["friend_id"] for it in body_a["items"]] == ["f_only_a", "f_shared"]
assert by_id["f_shared"]["friend_remark"] == "A的备注"
assert by_id["f_shared"]["send_num"] == 3
assert by_id["f_shared"]["from_type"] == 1
r_b = await client.get("/api/customers", params={
"channel": "wechat", "owner_account_id": "wx_b"})
body_b = r_b.json()
assert body_b["total"] == 1
assert body_b["items"][0]["friend_remark"] == "B的备注"
async def test_owner_filter_q_and_friend_id_on_links(make_client):
"""link 路径的 q 四列搜索 + friend_id 精确查找(聊天窗名字解析用)。"""
client = await make_client(_seed_links)
r = await client.get("/api/customers", params={
"channel": "wechat", "owner_account_id": "wx_a", "q": "A的备注"})
assert r.status_code == 200
body = r.json()
assert [it["friend_id"] for it in body["items"]] == ["f_shared"]
r2 = await client.get("/api/customers", params={
"channel": "wechat", "owner_account_id": "wx_a", "friend_id": "f_shared"})
body2 = r2.json()
assert body2["total"] == 1
assert body2["items"][0]["friend_nickname"] == "共享客户"
async def test_owner_filter_with_dept_falls_back_to_archive(make_client):
"""owner+department 组合回退档案路径(旧行为;link 不解析部门)。"""
from app.db.models import Customer as C, Staff as S
async def seed(session):
await _seed_links(session)
session.add(S(user_id="u1", name="员工", dept_id="d1"))
session.add(C(channel="wechat", owner_account_id="wx_a",
friend_id="f_archived", user_id="u1"))
await session.commit()
client = await make_client(seed)
r = await client.get("/api/customers", params={
"channel": "wechat", "owner_account_id": "wx_a", "department_id": "d1"})
assert r.status_code == 200
body = r.json()
# 档案路径:只命中 user_id 属于 d1 的档案行(link 行不带 user_id)
assert [it["friend_id"] for it in body["items"]] == ["f_archived"]
async def test_owner_link_path_carries_archive_raw_json(make_client):
"""link 路径按 friend_id 带出档案 raw_json(2026-09-09 WA 列表修复)。
WA 客户列表大量列只存在于 raw_json(userName/salePhone/createTime 等),
link 快照没存——不带 raw_json 时整表列空白。
"""
from app.db.models import Customer as C, CustomerAccountLink as L
async def seed(session):
session.add_all([
# 档案行挂在别的员工名下(共享),raw_json 里带员工侧 camelCase 字段
C(channel="whatsapp", friend_id="f1@s.whatsapp.net",
owner_account_id="wx_other", friend_nickname="Nick",
raw_json={"userName": "员工WA名", "salePhone": "13800000000",
"uid": "15600000000@s.whatsapp.net", "createTime": 1700000000000}),
L(channel="whatsapp", owner_account_id="wa_me",
friend_id="f1@s.whatsapp.net", friend_nickname="Nick", last_chat_time=100),
])
await session.commit()
client = await make_client(seed)
r = await client.get("/api/customers", params={
"channel": "whatsapp", "owner_account_id": "wa_me"})
assert r.status_code == 200
body = r.json()
assert body["total"] == 1
it = body["items"][0]
assert it["friend_nickname"] == "Nick" # link 快照
assert it["raw_json"]["userName"] == "员工WA名" # 档案 raw_json 带出
assert it["raw_json"]["salePhone"] == "13800000000"
+94 -3
View File
@@ -1,16 +1,19 @@
"""Tests for GET /api/export/chat-html — 导出 1:1/群聊天为自包含 HTML。 """Tests for GET /api/export/chat-html(+zip) — 导出 1:1/群聊天为自包含 HTML。
对齐云客微信客户/群列表「导出HTML聊天记录」:按 (channel, owner, talker_id|room_id) 对齐云客微信客户/群列表「导出HTML聊天记录」:按 (channel, owner, talker_id|room_id)
拉消息(可选 start/end 时间范围),渲染为左右气泡 HTML 作为 attachment 返回下载。 拉消息(可选 start/end 时间范围),渲染为左右气泡 HTML 作为 attachment 返回下载。
zip 版:员工客户页勾选多个客户批量「导出聊天记录」(2026-09-08)。
""" """
from __future__ import annotations from __future__ import annotations
import functools import functools
import io
import zipfile
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.routers.export import EXPORT_MAX_ROWS from app.api.routers.export import EXPORT_MAX_ROWS, ZIP_MAX_CONVS
from app.db.models import Message from app.db.models import Customer, Message
async def _seed(session: AsyncSession) -> None: async def _seed(session: AsyncSession) -> None:
@@ -160,3 +163,91 @@ async def test_export_group_ignores_owner_mismatch(make_client):
assert r.status_code == 200 assert r.status_code == 200
assert "群消息1" in r.text assert "群消息1" in r.text
assert "群消息2" in r.text assert "群消息2" in r.text
# ---------------------------------------------------------------------------
# GET /api/export/chat-html-zip — 批量导出(2026-09-08 员工客户页)
# ---------------------------------------------------------------------------
async def _seed_zip(session: AsyncSession) -> None:
"""两个客户各几条 DM + 一条同 talker 的群消息(不应混入 DM zip)。"""
session.add_all([
Customer(channel="wechat", owner_account_id="wx_owner", friend_id="f1",
friend_nickname="小蜜", friend_remark="蜜姐"),
Customer(channel="wechat", owner_account_id="wx_owner", friend_id="f2",
friend_nickname="老王"),
Customer(channel="wechat", owner_account_id="wx_owner", friend_id="f3",
friend_nickname='坏/名字<>:"|'),
Message(msg_id="z1", channel="wechat", owner_account_id="wx_owner",
talker_id="f1", room_id=None, text="f1你好", timestamp=1000,
is_send=False, type="text", type_raw=1),
Message(msg_id="z2", channel="wechat", owner_account_id="wx_owner",
talker_id="f2", room_id=None, text="f2在吗", timestamp=2000,
is_send=True, type="text", type_raw=1),
Message(msg_id="z3", channel="wechat", owner_account_id="wx_owner",
talker_id="f2", room_id=None, text="f2更早", timestamp=500,
is_send=True, type="text", type_raw=1),
# f1 名下的群消息:room_id 有值,zip 的 DM 查询必须排除
Message(msg_id="z4", channel="wechat", owner_account_id="wx_owner",
talker_id="f1", room_id="g@chatroom", text="群内消息不进zip",
timestamp=3000, is_send=True, type="text", type_raw=1),
])
await session.commit()
def _zip_entries(r) -> dict[str, str]:
assert r.headers["content-type"].startswith("application/zip")
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
return {name: zf.read(name).decode("utf-8") for name in zf.namelist()}
async def test_zip_two_conversations_with_display_names(make_client):
"""zip 含两个会话 HTML;文件名取备注>昵称;群消息不混入。"""
client = await make_client(_seed_zip)
r = await client.get("/api/export/chat-html-zip", params={
"channel": "wechat", "owner_account_id": "wx_owner",
"talker_ids": "f1,f2"})
assert r.status_code == 200
assert "attachment" in r.headers["content-disposition"]
entries = _zip_entries(r)
assert sorted(entries) == ["01_蜜姐.html", "02_老王.html"]
assert "f1你好" in entries["01_蜜姐.html"]
assert "f2在吗" in entries["02_老王.html"]
# DM 查询排除 room 消息
assert "群内消息不进zip" not in "".join(entries.values())
async def test_zip_time_range_filter(make_client):
"""start/end 同样作用于 zip 内每个会话。"""
client = await make_client(_seed_zip)
r = await client.get("/api/export/chat-html-zip", params={
"channel": "wechat", "owner_account_id": "wx_owner",
"talker_ids": "f2", "start": 1000})
assert r.status_code == 200
entries = _zip_entries(r)
assert "f2在吗" in entries["01_老王.html"]
assert "f2更早" not in entries["01_老王.html"]
async def test_zip_filename_sanitized_and_fallback(make_client):
"""非法字符客户名清洗;无 customer 行的 talker 回退 friend_id。"""
client = await make_client(_seed_zip)
r = await client.get("/api/export/chat-html-zip", params={
"channel": "wechat", "owner_account_id": "wx_owner",
"talker_ids": "f3,f_unknown"})
assert r.status_code == 200
names = sorted(_zip_entries(r))
assert names[0].startswith("01_") and "/" not in names[0] and "<" not in names[0]
assert names[1] == "02_f_unknown.html"
async def test_zip_rejects_empty_and_over_limit(make_client):
"""空列表/超上限 422,防误传。"""
client = await make_client(_seed_zip)
r_empty = await client.get("/api/export/chat-html-zip", params={
"channel": "wechat", "owner_account_id": "wx_owner", "talker_ids": " , "})
assert r_empty.status_code == 422
many = ",".join(f"t{i}" for i in range(ZIP_MAX_CONVS + 1))
r_many = await client.get("/api/export/chat-html-zip", params={
"channel": "wechat", "owner_account_id": "wx_owner", "talker_ids": many})
assert r_many.status_code == 422
+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): 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) client = await make_client(_seed)
r = await client.get("/api/groups") r = await client.get("/api/groups")
assert r.status_code == 200 assert r.status_code == 200
body = r.json() body = r.json()
assert body["total"] == 3 assert body["total"] == 3
# 2000, 1000, null -> [room_a, room_b, wa_room_x]
assert [it["room_id"] for it in body["items"]] == [ assert [it["room_id"] for it in body["items"]] == [
"wa_room_x@s.whatsapp.net",
"room_a@chatroom", "room_a@chatroom",
"room_b@chatroom", "room_b@chatroom",
"wa_room_x@s.whatsapp.net",
] ]
for key in ( for key in (
"room_id", "room_id",
@@ -344,3 +346,79 @@ async def test_q_room_name_substring_filter(make_client):
# 空串 -> 不过滤 # 空串 -> 不过滤
r3 = await client.get("/api/groups", params={"q": ""}) r3 = await client.get("/api/groups", params={"q": ""})
assert r3.json()["total"] == 3 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
+35 -1
View File
@@ -5,7 +5,7 @@ import time
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import ChatGroup, Customer, Message from app.db.models import ChatGroup, Customer, CustomerAccountLink, Message
OWNER = "wx_owner_s" OWNER = "wx_owner_s"
CH = "wechat" CH = "wechat"
@@ -45,6 +45,40 @@ async def test_sessions_friend_and_group(make_client):
assert g["name"] == "群一号" and g["today_count"] == 1 assert g["name"] == "群一号" and g["today_count"] == 1
async def test_sessions_shared_friend_name_from_link(make_client):
"""共享好友昵称读 link 快照(2026-09-09):档案行挂在别的员工名下时,
会话列表此前回退显示 wxid;link 表每账号一行,回填后都有昵称。
"""
async def seed(session: AsyncSession) -> None:
session.add_all([
# OWNER 与共享好友 t_shared 有会话
Message(msg_id="sh1", channel=CH, owner_account_id=OWNER, talker_id="t_shared",
room_id=None, is_send=True, timestamp=_NOW, type="text", type_raw=1, text="x"),
# 档案行(last-wins)挂在别的员工名下——按 owner 查档案必然查空
Customer(friend_id="t_shared", channel=CH,
owner_account_id="wx_other", friend_nickname="A**郑"),
# OWNER 名下的 per-owner link 快照
CustomerAccountLink(channel=CH, owner_account_id=OWNER, friend_id="t_shared",
friend_nickname="A**郑", friend_remark=None),
])
await session.commit()
client = await make_client(seed)
r = await client.get("/api/sessions", params={"owner_account_id": OWNER, "channel": CH})
assert r.status_code == 200
by = {s.get("talker_id"): s for s in r.json()["sessions"]}
assert by["t_shared"]["name"] == "A**郑"
async def test_sessions_name_falls_back_to_archive_without_link(make_client):
"""link 未覆盖的好友回退档案路径(空表过渡安全)。"""
client = await make_client(_seed)
r = await client.get("/api/sessions", params={"owner_account_id": OWNER, "channel": CH})
by = {s.get("talker_id"): s for s in r.json()["sessions"]}
assert by["t1"]["name"] == "Alice" # 档案行在 OWNER 名下
async def test_sessions_sorted_by_last_chat_time_desc(make_client): async def test_sessions_sorted_by_last_chat_time_desc(make_client):
client = await make_client(_seed) client = await make_client(_seed)
body = (await client.get("/api/sessions", params={"owner_account_id": OWNER})).json() body = (await client.get("/api/sessions", params={"owner_account_id": OWNER})).json()
+54 -1
View File
@@ -5,7 +5,14 @@ import time
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import ChatGroup, Customer, Message, PhoneCall, WechatAccount from app.db.models import (
ChatGroup,
Customer,
CustomerAccountLink,
Message,
PhoneCall,
WechatAccount,
)
OWNER = "wxid_o" OWNER = "wxid_o"
UID = "U32HEX" # 员工 userId(32位HEX)——phone_call.user_id 存的是它,不是 wxId! UID = "U32HEX" # 员工 userId(32位HEX)——phone_call.user_id 存的是它,不是 wxId!
@@ -39,6 +46,11 @@ async def _seed_counts(s: AsyncSession) -> None:
# 删除/拉黑(现存量口径,忽略窗口) # 删除/拉黑(现存量口径,忽略窗口)
s.add(Customer(friend_id="c9", channel="wechat", owner_account_id=OWNER, delete_flag=1)) s.add(Customer(friend_id="c9", channel="wechat", owner_account_id=OWNER, delete_flag=1))
s.add(Customer(friend_id="c8", channel="wechat", owner_account_id=OWNER, is_block=True)) s.add(Customer(friend_id="c8", channel="wechat", owner_account_id=OWNER, is_block=True))
# link 行(生产抓取经 upsert_customer 成对写;新客户/删除统计读 link)
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c1", add_time=IN))
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c2"))
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c3", add_time=1_000))
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c9", delete_flag=1))
# 语音通话: user_id 是 32HEX 非 wxId!另 seed 一条直存 wxId 的(不计,锁死 join 口径) # 语音通话: user_id 是 32HEX 非 wxId!另 seed 一条直存 wxId 的(不计,锁死 join 口径)
s.add(PhoneCall(call_id="pc1", channel="voice_call", user_id=UID, s.add(PhoneCall(call_id="pc1", channel="voice_call", user_id=UID,
start_call_time=IN + 4, call_number="c1")) start_call_time=IN + 4, call_number="c1"))
@@ -78,6 +90,9 @@ async def test_counts_default_window_last_7_days(make_client):
s.add(Customer(friend_id="c_now", channel="wechat", owner_account_id=OWNER, add_time=now)) s.add(Customer(friend_id="c_now", channel="wechat", owner_account_id=OWNER, add_time=now))
s.add(Customer(friend_id="c_old", channel="wechat", owner_account_id=OWNER, s.add(Customer(friend_id="c_old", channel="wechat", owner_account_id=OWNER,
add_time=now - 8 * DAY)) add_time=now - 8 * DAY))
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c_now", add_time=now))
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c_old",
add_time=now - 8 * DAY))
s.add(PhoneCall(call_id="pc_now", channel="voice_call", user_id=UID, s.add(PhoneCall(call_id="pc_now", channel="voice_call", user_id=UID,
start_call_time=now, call_number="c_now")) start_call_time=now, call_number="c_now"))
s.add(PhoneCall(call_id="pc_old", channel="voice_call", user_id=UID, s.add(PhoneCall(call_id="pc_old", channel="voice_call", user_id=UID,
@@ -127,6 +142,12 @@ async def test_detail_new_customers_fields(make_client):
s.add(Customer(friend_id="c2", channel="wechat", owner_account_id=OWNER, add_time=None, s.add(Customer(friend_id="c2", channel="wechat", owner_account_id=OWNER, add_time=None,
friend_nickname="兜底客")) friend_nickname="兜底客"))
s.add(Customer(friend_id="c3", channel="wechat", owner_account_id=OWNER, add_time=1_000)) s.add(Customer(friend_id="c3", channel="wechat", owner_account_id=OWNER, add_time=1_000))
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c1", add_time=IN,
friend_nickname="牛牛", friend_remark="大客户",
head_image_url="http://a/1.png"))
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c2",
friend_nickname="兜底客"))
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c3", add_time=1_000))
s.add(Message(msg_id="m1", channel="wechat", owner_account_id=OWNER, talker_id="c2", s.add(Message(msg_id="m1", channel="wechat", owner_account_id=OWNER, talker_id="c2",
timestamp=IN - 5)) timestamp=IN - 5))
await s.commit() await s.commit()
@@ -238,6 +259,9 @@ async def test_detail_del_and_blocked_fields(make_client):
s.add(Customer(friend_id="c_ok", channel="wechat", owner_account_id=OWNER)) s.add(Customer(friend_id="c_ok", channel="wechat", owner_account_id=OWNER))
s.add(Customer(friend_id="c_other", channel="wechat", owner_account_id="wxid_other", s.add(Customer(friend_id="c_other", channel="wechat", owner_account_id="wxid_other",
delete_flag=1)) delete_flag=1))
# 删除读 link:c_del 补 link 行;is_block 保持档案(c_blk 不补)
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c_del",
delete_flag=1, friend_nickname="已删客"))
s.add(Customer(friend_id="c_fine", channel="wechat", owner_account_id=OWNER, s.add(Customer(friend_id="c_fine", channel="wechat", owner_account_id=OWNER,
delete_flag=0, is_block=False)) delete_flag=0, is_block=False))
await s.commit() await s.commit()
@@ -394,3 +418,32 @@ async def test_new_customers_fallback_first_msg_before_window(make_client):
assert r_count.status_code == r_detail.status_code == 200 assert r_count.status_code == r_detail.status_code == 200
assert r_count.json()["new_customers"] == 0 assert r_count.json()["new_customers"] == 0
assert r_detail.json()["rows"] == [] assert r_detail.json()["rows"] == []
async def test_new_customers_counts_shared_friend_via_link(make_client):
"""共享好友(档案挂别的员工名下)凭 link 行计入新客户(2026-09-09 口径修复)。
旧档案口径下 c_shared 对 OWNER 不可见(add_time 在窗内也不计);切 link
后按 per-owner 关系计数,与云客按员工统计一致。
"""
async def seed(s: AsyncSession) -> None:
s.add(WechatAccount(wx_id=OWNER, user_id=UID))
# 档案行(last-wins)挂在别的员工名下
s.add(Customer(friend_id="c_shared", channel="wechat",
owner_account_id="wxid_other", add_time=IN))
# OWNER 名下的 link 关系
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER,
friend_id="c_shared", add_time=IN))
await s.commit()
client = await make_client(seed)
r = await client.get(
"/api/staff-stats",
params={"wechat_id": OWNER, "kind": "new_customers",
"start": WIN_START, "end": WIN_END},
)
assert r.status_code == 200
body = r.json()
assert [row["talker_id"] for row in body["rows"]] == ["c_shared"]
assert len(body["rows"]) == 1
+269
View File
@@ -0,0 +1,269 @@
"""employee_call_daily / employee_metric_daily 生成判别测试(批次A Task5/6)。
判据(v2 §1.1 #2/#5、§1.2 #9、§1.3 #11):
- 通话无渠道维,独立小表;接通=duration>0;followup=24h 内任一账号有消息;
- 幽灵员工(owner 映射不上/不在 staff)过滤;
- 宽表消息计数 friend-only;串类与 fact 同源。
"""
import datetime as dt
from zoneinfo import ZoneInfo
import sqlalchemy as sa
from app.db.models import (
Customer,
EmployeeCallDaily,
EmployeeMetricDaily,
FirstReplyFact,
Message,
MsgDailyStats,
PhoneCall,
Staff,
WechatAccount,
WhatsappAccount,
)
from app.storage.employee_metrics import recompute_call_days, recompute_metric_days
_SH = ZoneInfo("Asia/Shanghai")
def _ts(*args) -> int:
return int(dt.datetime(*args, tzinfo=_SH).timestamp() * 1000)
async def _seed_org(session):
session.add(Staff(user_id="u1", name="张三", is_on_job=True))
session.add(WechatAccount(wx_id="wx1", user_id="u1"))
# 第二个微信号:验证 followup「任一账号」语义(owner-IN 而非单账号)
session.add(WechatAccount(wx_id="wx2", user_id="u1"))
# WA 账号:验证 wa_voice_call 渠道收窄正例
session.add(WhatsappAccount(uid="wa1", user_id="u1"))
await session.commit()
async def test_call_daily_aggregates(make_session):
s = make_session
await _seed_org(s)
s.add(PhoneCall(call_id="c1", channel="voice_call", user_id="u1",
call_number="p1", start_call_time=_ts(2026, 9, 1, 10, 0),
call_duration=120))
s.add(PhoneCall(call_id="c2", channel="voice_call", user_id="u1",
call_number="p2", start_call_time=_ts(2026, 9, 1, 15, 0),
call_duration=0)) # 未接通
# c3:WA 语音,接通,窗口内有 WA 消息 → followup 正例(渠道收窄匹配)
s.add(PhoneCall(call_id="c3", channel="wa_voice_call", user_id="u1",
call_number="p3", start_call_time=_ts(2026, 9, 1, 10, 0),
call_duration=60))
# c4:微信语音,接通,但窗口内只有 WA 消息 → followup 反例(渠道收窄)
s.add(PhoneCall(call_id="c4", channel="voice_call", user_id="u1",
call_number="p4", start_call_time=_ts(2026, 9, 1, 10, 0),
call_duration=90))
# c1 后 2h 员工**另一微信号 wx2** 有跟进消息(任一账号语义:owner-IN)
s.add(Message(msg_id="f1", channel="wechat", owner_account_id="wx2",
talker_id="p1", timestamp=_ts(2026, 9, 1, 12, 0),
is_send=True, type="text", text="跟进"))
# c2(未接通)后 1h 有消息:即使任一方向也不算 followup(仅已接通判定)
s.add(Message(msg_id="f2", channel="wechat", owner_account_id="wx1",
talker_id="p2", timestamp=_ts(2026, 9, 1, 16, 0),
is_send=False, type="text", text="客户回拨?"))
# c3 后 2h WA 账号有消息 → 正例
s.add(Message(msg_id="f3", channel="whatsapp", owner_account_id="wa1",
talker_id="p3", timestamp=_ts(2026, 9, 1, 12, 0),
is_send=True, type="text", text="wa 跟进"))
# c4(voice_call→wechat)窗口内只有 WA 消息 → 不匹配渠道,反例
s.add(Message(msg_id="f4", channel="whatsapp", owner_account_id="wa1",
talker_id="p4", timestamp=_ts(2026, 9, 1, 12, 0),
is_send=True, type="text", text="错渠道"))
await s.commit()
n = await recompute_call_days(s, [20260901])
await s.commit()
assert n == 1
row = (await s.execute(sa.select(EmployeeCallDaily))).scalar_one()
assert row.call_count == 4
assert row.call_seconds == 270 # 120+0+60+90;duration 单位=秒(Task0 已核实)
assert row.answered_call_count == 3 # c1/c3/c4
# c1(wx2 任一账号)+ c3(wa 渠道正例);c2 未接通守卫、c4 渠道收窄反例
assert row.call_followup_count == 2
async def test_call_daily_ignores_unknown_staff(make_session):
s = make_session
await _seed_org(s)
s.add(PhoneCall(call_id="c9", channel="phone_call", user_id="ghost",
call_number="p9", start_call_time=_ts(2026, 9, 1, 10, 0),
call_duration=60))
await s.commit()
await recompute_call_days(s, [20260901])
await s.commit()
assert (await s.execute(
sa.select(sa.func.count()).select_from(EmployeeCallDaily))).scalar_one() == 0
async def _seed_msg_rows(s, *, day=20260901, channel="wechat", owner="wx1",
kind="friend", peer="p1", send=3, recv=2):
s.add(MsgDailyStats(channel=channel, owner_account_id=owner, peer_kind=kind,
peer_id=peer, day=day, send_cnt=send, recv_cnt=recv))
await s.commit()
async def test_metric_daily_friend_only_caliber(make_session):
"""口径判别(v2 §1.1 #2 防线):room 行不进消息计数/串列,只进 active_rooms。"""
s = make_session
await _seed_org(s)
await _seed_msg_rows(s, send=3, recv=2, kind="friend", peer="p1")
await _seed_msg_rows(s, send=10, recv=0, kind="room", peer="r1")
# friend 会话给一条消息供串扫描(无消息则无串)
s.add(Message(msg_id="p1-0", channel="wechat", owner_account_id="wx1",
talker_id="p1", timestamp=_ts(2026, 9, 1, 10, 0),
is_send=False, type="text", text="q"))
await s.commit()
await recompute_metric_days(s, [20260901])
await s.commit()
row = (await s.execute(sa.select(EmployeeMetricDaily))).scalar_one()
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):
s = make_session
await _seed_org(s)
await _seed_msg_rows(s, owner="wx-ghost", peer="pg")
await recompute_metric_days(s, [20260901])
await s.commit()
assert (await s.execute(
sa.select(sa.func.count()).select_from(EmployeeMetricDaily))).scalar_one() == 0
async def test_metric_daily_same_source_as_fact(make_session):
"""同源判别(v2 §1.1 #5 防线):
1) threads_answered ≡ fact 行数(同归属日);
2) 多日未回串宽表 threads < 日切 cust_threads 之和(分母不再膨胀);
3) 幂等。"""
from app.storage.first_reply_facts import recompute_pairs
s = make_session
await _seed_org(s)
# 客户 D1 10:00、D1 20:00、D2 09:00 三条消息,始终未回 → 1 个跨日串
rows = [(_ts(2026, 9, 1, 10, 0), False), (_ts(2026, 9, 1, 20, 0), False),
(_ts(2026, 9, 2, 9, 0), False)]
for i, (ts, is_send) in enumerate(rows):
s.add(Message(msg_id=f"g{i}", channel="wechat", owner_account_id="wx1",
talker_id="pg", timestamp=ts, is_send=is_send,
type="text", text="x"))
# 日切口径的 msg_daily_stats:D1、D2 各计一个未回串(供对照)
await _seed_msg_rows(s, day=20260901, peer="pg", send=0, recv=2)
await _seed_msg_rows(s, day=20260902, peer="pg", send=0, recv=1)
await s.commit()
await recompute_pairs(s, [("wechat", "wx1", "pg")])
await s.commit()
await recompute_metric_days(s, [20260901, 20260902])
await s.commit()
got = {r.day: r for r in (
await s.execute(sa.select(EmployeeMetricDaily))).scalars().all()}
# 1) fact 行数=0(未回),宽表 threads_answered 也=0 且同源
assert (await s.execute(
sa.select(sa.func.count()).select_from(FirstReplyFact))).scalar_one() == 0
# 2) 跨日串只归属 D2;日切 D1+D2=2,宽表合计=1
assert got[20260901].cust_threads == 0
assert got[20260902].cust_threads == 1
# 3) 幂等
await recompute_metric_days(s, [20260901, 20260902])
await s.commit()
assert len((await s.execute(
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
await _seed_org(s)
# 客户 pc:D1 有互动后静默(历史≥5 条) → as_of=10-10 为 dormant;
# 10-11 回流 → 该日 reactivated
s.add(Customer(friend_id="pc", channel="wechat", owner_account_id="wx1",
delete_flag=0))
hist = []
for h in range(9, 17): # D1 8 条消息(≥5)
hist.append((_ts(2026, 9, 1, h, 0), h % 2 == 0))
for i, (ts, is_send) in enumerate(hist):
s.add(Message(msg_id=f"lc{i}", channel="wechat", owner_account_id="wx1",
talker_id="pc", timestamp=ts, is_send=is_send,
type="text", text="x"))
await _seed_msg_rows(s, day=20260901, peer="pc", send=4, recv=4)
await s.commit()
# D41(10-11)客户回流一条
s.add(Message(msg_id="lc-b", channel="wechat", owner_account_id="wx1",
talker_id="pc", timestamp=_ts(2026, 10, 11, 9, 0),
is_send=False, type="text", text="back"))
await _seed_msg_rows(s, day=20261011, peer="pc", send=0, recv=1)
await s.commit()
from app.storage.first_reply_facts import recompute_pairs
await recompute_pairs(s, [("wechat", "wx1", "pc")])
await recompute_metric_days(s, [20261010, 20261011])
await s.commit()
got = {r.day: r for r in (
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,202 @@
"""first_reply_fact 跨日精确串算法判别测试(批次A Task3)。
四个核心判据(v2 §1.1 #1/#5、§9):
1. 跨夜串必须产生样本(日切口径丢失的场景);
2. 多日未回串只计一次、归属末条日(日切会按天重复计——分母膨胀);
3. 同源:threads_answered ≡ fact 行数(按归属日);
4. is_send=None 不参与串判定(与 _thread_stats 一致)。
评审修正补判据:
5. 同毫秒 F,T,F,T 交换 → reply_msg_id 进 PK 去重,不撞主键(原崩溃回归);
6. 同毫秒并列按 (ts, msg_id) 定序,结果确定不随 SQL 等键序抖动。
"""
import datetime as dt
from zoneinfo import ZoneInfo
import sqlalchemy as sa
from app.db.models import FirstReplyFact, Message, MsgDailyStats
from app.storage.first_reply_facts import (
all_friend_pairs,
compute_conversation,
recompute_pairs,
)
_SH = ZoneInfo("Asia/Shanghai")
def _ts(*args) -> int:
return int(dt.datetime(*args, tzinfo=_SH).timestamp() * 1000)
# ---------------------------- 纯函数 ----------------------------
def test_overnight_thread_recovered():
"""客户 23:00 发→员工次日 9:00 回:fr_ms≈10h,归属 D1(v2 §1.1 #1)。"""
facts, agg = compute_conversation(
[(_ts(2026, 9, 1, 23, 0), False, "f1"),
(_ts(2026, 9, 2, 9, 0), True, "t1")]
)
assert len(facts) == 1
assert facts[0].fr_ms == 10 * 3600 * 1000
assert facts[0].day == 20260901
assert agg[20260901].answered == 1
def test_multiday_open_thread_counts_once_at_end_day():
"""客户消息跨 D1/D2 无回复:1 个串归属 D2;日切口径会 D1、D2 各计 1。"""
facts, agg = compute_conversation(
[(_ts(2026, 9, 1, 10, 0), False, "f1"), (_ts(2026, 9, 1, 20, 0), False, "f2"),
(_ts(2026, 9, 2, 9, 0), False, "f3")]
)
assert facts == []
assert 20260901 not in agg
assert agg[20260902].threads == 1 and agg[20260902].answered == 0
def test_reply_closes_thread_and_opens_new():
"""员工回复后客户再发=新串;串内多条客户消息取末条为 end。"""
facts, agg = compute_conversation(
[(_ts(2026, 9, 1, 10, 0), False, "f1"), (_ts(2026, 9, 1, 10, 5), False, "f2"),
(_ts(2026, 9, 1, 10, 30), True, "t1"), # 回复1:fr=30-10:05=25min
(_ts(2026, 9, 1, 11, 0), False, "f3"),
(_ts(2026, 9, 1, 12, 0), True, "t2")]) # 回复2:fr=12:00-11:00=60min
assert len(facts) == 2
assert facts[0].fr_ms == 25 * 60 * 1000
assert agg[20260901].threads == 2 and agg[20260901].answered == 2
assert agg[20260901].fr_sum_ms == 85 * 60 * 1000 # 25min + 60min
def test_unknown_direction_ignored():
facts, _agg = compute_conversation(
[(_ts(2026, 9, 1, 10, 0), False, "f1"), (_ts(2026, 9, 1, 10, 10), None, "u1"),
(_ts(2026, 9, 1, 10, 20), True, "t1")]
)
assert len(facts) == 1 # None 不打断串:仍视为对 10:00 串的回复
def test_staff_message_outside_thread_is_noop():
facts, agg = compute_conversation(
[(_ts(2026, 9, 1, 9, 0), True, "t1"), (_ts(2026, 9, 1, 10, 0), True, "t2")]
)
assert facts == [] and agg == {}
def test_unsorted_rows_raise_or_documented():
"""调用方契约:rows 须升序。乱序输入行为未定义——文档断言 + 防御排序
在 load_conversation 里做(与 summary.py 惯例一致)。"""
# 纯函数不做防御(性能);落库路径负责排序。此处仅锁定契约注释。
import app.storage.first_reply_facts as m
assert "升序" in m.compute_conversation.__doc__
def test_tie_break_by_msg_id_deterministic():
"""同毫秒 [F(m1), T(m2)]:按 (ts, msg_id) 定序 F 在前 → 1 个被回串;
同一 multiset 无论入参顺序,排序后结果一致(SQL 等键序未定义,不能
随查询抖动)。"""
same = _ts(2026, 9, 1, 10, 0)
rows = [(same, False, "m1"), (same, True, "m2")]
ordered = sorted(rows, key=lambda r: (r[0], r[2]))
assert ordered[0][2] == "m1" # F(m1) 先于 T(m2)
facts, agg = compute_conversation(ordered)
assert len(facts) == 1 and facts[0].reply_msg_id == "m2"
assert agg[20260901].answered == 1
shuffled = [rows[1], rows[0]] # 同一 multiset,入参顺序相反
facts2, agg2 = compute_conversation(
sorted(shuffled, key=lambda r: (r[0], r[2])))
assert facts2 == facts and agg2 == agg
# ---------------------------- 落库 ----------------------------
async def _seed_conversation(session, *, channel="wechat", owner="wx1",
peer="p1", rows):
for ts, is_send, msg_id in rows:
session.add(Message(msg_id=msg_id, channel=channel,
owner_account_id=owner, talker_id=peer,
timestamp=ts, is_send=is_send, type="text",
text="x"))
session.add(MsgDailyStats(channel=channel, owner_account_id=owner,
peer_kind="friend", peer_id=peer,
day=20260901, send_cnt=1, recv_cnt=1))
await session.commit()
async def test_recompute_pairs_persists_and_idempotent(make_session):
s = make_session
await _seed_conversation(
s, rows=[(_ts(2026, 9, 1, 23, 0), False, "p1-f0"),
(_ts(2026, 9, 2, 9, 0), True, "p1-t1")])
n = await recompute_pairs(s, [("wechat", "wx1", "p1")])
await s.commit()
assert n == 1
rows = (await s.execute(sa.select(FirstReplyFact))).scalars().all()
assert len(rows) == 1 and rows[0].fr_ms == 10 * 3600 * 1000
assert rows[0].reply_msg_id == "p1-t1"
# 幂等:重跑不重复
await recompute_pairs(s, [("wechat", "wx1", "p1")])
await s.commit()
assert len((await s.execute(sa.select(FirstReplyFact))).scalars().all()) == 1
async def test_same_ms_ftft_two_distinct_facts(make_session):
"""同毫秒 F,T,F,T 交换:两个样本 (thread_end_ts, reply_ts) 完全并列
——reply_msg_id 进 PK 去重后恰好 2 行(评审复现的主键冲突回归)。"""
s = make_session
same = _ts(2026, 9, 1, 10, 0)
await _seed_conversation(s, rows=[
(same, False, "m1"), (same, True, "m2"),
(same, False, "m3"), (same, True, "m4"),
])
n = await recompute_pairs(s, [("wechat", "wx1", "p1")])
await s.commit()
assert n == 1
rows = (await s.execute(sa.select(FirstReplyFact))).scalars().all()
assert len(rows) == 2
assert {r.reply_msg_id for r in rows} == {"m2", "m4"}
async def test_recompute_pairs_replaces_stale(make_session):
"""会话追加消息后重算,旧样本被替换(delete+insert 语义)。"""
s = make_session
await _seed_conversation(
s, rows=[(_ts(2026, 9, 1, 10, 0), False, "p1-f0"),
(_ts(2026, 9, 1, 11, 0), True, "p1-t1")])
await recompute_pairs(s, [("wechat", "wx1", "p1")])
await s.commit()
s.add(Message(msg_id="p1-new", channel="wechat", owner_account_id="wx1",
talker_id="p1", timestamp=_ts(2026, 9, 1, 12, 0),
is_send=False, type="text", text="y"))
await s.commit()
await recompute_pairs(s, [("wechat", "wx1", "p1")])
await s.commit()
rows = (await s.execute(sa.select(FirstReplyFact))).scalars().all()
assert len(rows) == 1 # 新串未回,只剩旧样本
async def test_recompute_pairs_empty_conversation(make_session):
"""stats 行在但 message 无合格行(空会话/全被过滤):删旧行不插新——
delete+insert 语义对空会话同样成立(幂等清空)。"""
s = make_session
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="p1", day=20260901))
# 预置历史残留 fact,重算后应被清掉
s.add(FirstReplyFact(channel="wechat", owner_account_id="wx1", peer_id="p1",
thread_end_ts=_ts(2026, 9, 1, 10, 0),
reply_ts=_ts(2026, 9, 1, 11, 0), reply_msg_id="stale",
day=20260901, fr_ms=3600 * 1000))
await s.commit()
n = await recompute_pairs(s, [("wechat", "wx1", "p1")])
await s.commit()
assert n == 1
assert (await s.execute(sa.select(FirstReplyFact))).scalars().all() == []
async def test_all_friend_pairs_only_friend(make_session):
s = make_session
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="room", peer_id="r1", day=20260901))
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="p1", day=20260901))
await s.commit()
assert await all_friend_pairs(s) == [("wechat", "wx1", "p1")]
+72
View File
@@ -0,0 +1,72 @@
"""客户生命周期判定纯函数测试(批次A Task4)。
口径(v2 §1.1 #3 + v1 §21 默认,已用户确认):新客 7 天、沉睡 30 天、
历史≥5 条才算沉睡(对齐 churn real min_msgs=5)。analytics 现有页
(churn/客户资产)暂不切换(口径差异 v2 §5 标注),不动 636 既有测试。
"""
from app.analytics.lifecycle import DORMANT_DAYS, MIN_HISTORY_MSGS, NEW_DAYS, classify, day_add
def test_constants_locked_to_confirmed_defaults():
assert NEW_DAYS == 7 and DORMANT_DAYS == 30 and MIN_HISTORY_MSGS == 5
def test_new_customer():
# 首次沟通在 7 天窗口内
assert (
classify(first_day=20260901, last_day=20260903, prev_day=None, as_of=20260905, hist_msgs=2)
== "new"
)
# 窗口边界:as_of-6=20260830 含当天共 7 天 → new;再早一天 → 非 new
assert classify(20260830, 20260830, None, 20260905, 100) == "new"
assert classify(20260829, 20260829, None, 20260905, 100) == "cooling"
def test_reactivated_requires_gap_over_30d():
# 当日回流且前次间隔>30 天
assert (
classify(
first_day=20260101, last_day=20260905, prev_day=20260801, as_of=20260905, hist_msgs=50
)
== "reactivated"
)
# 间隔仅 9 天 → 只是 active
assert (
classify(
first_day=20260101, last_day=20260905, prev_day=20260827, as_of=20260905, hist_msgs=50
)
== "active"
)
# 间隔恰好 30 天 → 严格 >30 不算回流,落回 active
assert (
classify(
first_day=20260101, last_day=20260905, prev_day=20260806, as_of=20260905, hist_msgs=50
)
== "active"
)
# 回流必须当日互动:前次间隔 31 天(>30)但 last_day=as_of-1 → 不算回流,按 gap=1 落 active
assert classify(20260101, 20260904, 20260804, 20260905, 50) == "active"
def test_active_cooling_dormant_boundaries():
f, m = 20260101, 100
assert classify(f, 20260830, None, 20260905, m) == "active" # 6 天
assert classify(f, 20260829, None, 20260905, m) == "cooling" # 7 天
assert classify(f, 20260807, None, 20260905, m) == "cooling" # 29 天
assert classify(f, 20260806, None, 20260905, m) == "dormant" # 30 天
def test_dormant_requires_history():
# ≥30 天无互动但历史<5 条:不计入任何状态(None)
assert classify(20260101, 20260806, None, 20260905, 4) is None
assert classify(20260101, 20260806, None, 20260905, 5) == "dormant"
def test_no_interaction_returns_none():
assert classify(None, None, None, 20260905, 0) is None
def test_day_add():
assert day_add(20260831, 1) == 20260901
assert day_add(20260901, -7) == 20260825
@@ -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,139 @@
"""管理域 6 张新表的模型冒烟测试(批次A Task1)。
只验证:建表可用、主键/唯一约束生效、默认值正确。业务逻辑在
first_reply_facts / employee_metrics 的测试里。
"""
import pytest
import sqlalchemy as sa
from sqlalchemy.exc import IntegrityError
from app.db.models import (
EmployeeCallDaily,
EmployeeMetricDaily,
EmployeeScoreConfig,
EmployeeScoreSnapshot,
FirstReplyFact,
ManagementFollowup,
)
async def test_first_reply_fact_roundtrip(make_session):
s = make_session
s.add(FirstReplyFact(channel="wechat", owner_account_id="wx1", peer_id="p1",
thread_end_ts=1_700_000_000_000,
reply_ts=1_700_000_600_000, reply_msg_id="m1",
day=20260901, fr_ms=600_000))
await s.commit()
val = (await s.execute(sa.select(FirstReplyFact.fr_ms))).scalar_one()
assert val == 600_000
async def test_first_reply_fact_unique_sample(make_session):
s = make_session
kwargs = {"channel": "wechat", "owner_account_id": "wx1", "peer_id": "p1",
"thread_end_ts": 1, "reply_ts": 2, "reply_msg_id": "m1",
"day": 20260901, "fr_ms": 1}
s.add(FirstReplyFact(**kwargs))
await s.commit()
s.add(FirstReplyFact(**kwargs))
with pytest.raises(IntegrityError):
await s.commit()
async def test_metric_daily_versioning_coexists(make_session):
s = make_session
for v in ("v1", "v2"):
s.add(EmployeeMetricDaily(day=20260901, channel="wechat",
user_id="u1", metric_version=v))
await s.commit()
n = (await s.execute(
sa.select(sa.func.count()).select_from(EmployeeMetricDaily))).scalar_one()
assert n == 2
async def test_metric_daily_defaults_zero_not_none(make_session):
s = make_session
s.add(EmployeeMetricDaily(day=20260901, channel="wechat", user_id="u1"))
await s.commit()
row = (await s.execute(sa.select(EmployeeMetricDaily))).scalar_one()
assert row.msg_sent == 0 and row.cust_threads == 0 and row.fr_sum_ms == 0
assert row.metric_version == "v1"
assert row.data_complete is False or row.data_complete == 0 # SQLite 0/1
async def test_metric_daily_pk_duplicate_rejected(make_session):
"""同 (day, channel, user_id, metric_version) 复合主键防重。
Task 6 delete+insert 幂等重算的安全网:版本内重复落表直接 IntegrityError。
"""
s = make_session
s.add(EmployeeMetricDaily(day=20260901, channel="wechat", user_id="u1"))
await s.commit()
s.add(EmployeeMetricDaily(day=20260901, channel="wechat", user_id="u1"))
with pytest.raises(IntegrityError):
await s.commit()
async def test_call_daily_roundtrip(make_session):
s = make_session
s.add(EmployeeCallDaily(day=20260901, user_id="u1", call_count=2,
call_seconds=164, answered_call_count=1,
call_followup_count=1))
await s.commit()
row = (await s.execute(sa.select(EmployeeCallDaily))).scalar_one()
assert row.call_seconds == 164
async def test_call_daily_pk_duplicate_rejected(make_session):
s = make_session
s.add(EmployeeCallDaily(day=20260901, user_id="u1"))
await s.commit()
s.add(EmployeeCallDaily(day=20260901, user_id="u1"))
with pytest.raises(IntegrityError):
await s.commit()
async def test_score_config_version_unique(make_session):
s = make_session
s.add(EmployeeScoreConfig(version="v1.0", name="一期试算",
config_json={"dimensions": {}}))
await s.commit()
s.add(EmployeeScoreConfig(version="v1.0", name="重复",
config_json={}))
with pytest.raises(IntegrityError):
await s.commit()
async def test_score_config_config_json_not_null(make_session):
"""config_json NOT NULL——注解与 DDL 一致(修复:去掉 | None)。
注意 JSON 列语义:Python None 经 JSON 序列化变成字符串 'null' 而非
SQL NULL,不会违反约束;测 NOT NULL 须用 sa.null() 显式发 SQL NULL。
"""
s = make_session
s.add(EmployeeScoreConfig(version="v1.0", name="空配置",
config_json=sa.null()))
with pytest.raises(IntegrityError):
await s.commit()
async def test_snapshot_batch_user_unique(make_session):
s = make_session
common = {"snapshot_batch_id": "b1", "period_type": "week",
"period_start": 20260901, "period_end": 20260907,
"score_config_version": "v1.0", "metric_version": "v1",
"data_as_of": 20260907}
s.add(EmployeeScoreSnapshot(user_id="u1", **common))
await s.commit()
s.add(EmployeeScoreSnapshot(user_id="u1", **common))
with pytest.raises(IntegrityError):
await s.commit()
async def test_followup_defaults(make_session):
s = make_session
s.add(ManagementFollowup(employee_user_id="u1", source_type="manual"))
await s.commit()
row = (await s.execute(sa.select(ManagementFollowup))).scalar_one()
assert row.status == "open"
assert row.created_at is not None
@@ -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) == []
+112
View File
@@ -25,6 +25,7 @@ from app.db.base import Base
from app.db.models import ( from app.db.models import (
ChatGroup, ChatGroup,
Customer, Customer,
CustomerAccountLink,
Department, Department,
GroupMember, GroupMember,
Message, Message,
@@ -39,6 +40,7 @@ from app.storage.repos import (
_build_column_values, _build_column_values,
_coerce_bool, _coerce_bool,
_coerce_int, _coerce_int,
prune_customer_links,
upsert_chat_group, upsert_chat_group,
upsert_customer, upsert_customer,
upsert_customers, upsert_customers,
@@ -107,6 +109,116 @@ def test_coerce_int_returns_none_for_non_numeric_string():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def test_upsert_customer_shared_friend_maintains_links():
"""共享好友:档案全局单行(last-wins),link 每个账号各一行(2026-09-08)。
同一 friend_id 先后在 ownerA/ownerB 名下 upsert——customer 只有一行
且归属=后写;customer_account_link 两行,各自带自己的备注快照与
fromType(来自 raw_json)。
"""
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
base = {
"friend_id": "wxid_shared",
"channel": "wechat",
"friend_nickname": "共享客户",
"phone": "13800000000",
"send_num": 10,
"receive_num": 4,
}
await upsert_customer(session, {
**base, "owner_account_id": "wx_a",
"friend_remark": "A的备注", "raw_json": {"fromType": 1},
})
await session.commit()
await upsert_customer(session, {
**base, "owner_account_id": "wx_b",
"friend_remark": "B的备注", "raw_json": {"fromType": 2},
})
await session.commit()
custs = (await session.execute(
sa.select(Customer).execution_options(populate_existing=True)
)).scalars().all()
assert len(custs) == 1
assert custs[0].owner_account_id == "wx_b" # 档案 last-wins
links = (await session.execute(
sa.select(CustomerAccountLink).execution_options(populate_existing=True)
)).scalars().all()
assert {(l.owner_account_id, l.friend_remark) for l in links} == {
("wx_a", "A的备注"), ("wx_b", "B的备注"),
}
by_owner = {l.owner_account_id: l for l in links}
assert by_owner["wx_a"].from_type == 1
assert by_owner["wx_b"].from_type == 2
assert by_owner["wx_a"].send_num == 10
assert by_owner["wx_a"].phone == "13800000000"
finally:
await engine.dispose()
async def test_upsert_customer_without_owner_skips_link():
"""无 owner_account_id 的档案 upsert(理论路径)只写 customer,不碰 link。"""
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
await upsert_customer(session, {
"friend_id": "wxid_solo", "channel": "wechat",
})
await session.commit()
n_links = (await session.execute(
sa.select(sa.func.count()).select_from(CustomerAccountLink)
)).scalar_one()
assert n_links == 0
finally:
await engine.dispose()
async def test_prune_customer_links_removes_departed():
"""对账:保留名单外的 link 被删;空名单删光;他人名下不动。"""
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
for owner, fid in [("wx_a", "f1"), ("wx_a", "f2"), ("wx_a", "f3"),
("wx_b", "f2")]:
await upsert_customer(session, {
"friend_id": fid, "channel": "wechat",
"owner_account_id": owner,
})
await session.commit()
deleted = await prune_customer_links(
session, channel="wechat", owner_account_id="wx_a",
keep_friend_ids=["f1", "f3"],
)
await session.commit()
assert deleted == 1
left = (await session.execute(
sa.select(CustomerAccountLink.friend_id, CustomerAccountLink.owner_account_id)
)).all()
assert set(left) == {("f1", "wx_a"), ("f3", "wx_a"), ("f2", "wx_b")}
# 空名单=该账号全部删除(wx_b 的保留)
deleted_all = await prune_customer_links(
session, channel="wechat", owner_account_id="wx_a",
keep_friend_ids=[],
)
await session.commit()
assert deleted_all == 2
left2 = (await session.execute(
sa.select(CustomerAccountLink.friend_id, CustomerAccountLink.owner_account_id)
)).all()
assert set(left2) == {("f2", "wx_b")}
finally:
await engine.dispose()
async def test_upsert_customer_is_idempotent_and_updates_version(): async def test_upsert_customer_is_idempotent_and_updates_version():
"""Upsert same friend_id twice with different version -> 1 row, version updated.""" """Upsert same friend_id twice with different version -> 1 row, version updated."""
engine = await _make_engine() engine = await _make_engine()
@@ -1,4 +1,4 @@
"""_analytics_tick:滚动刷新 + 增量扫描 + 异常吞掉(数据分析板块 v1, T11)。 """_analytics_tick:滚动刷新 + 增量扫描 + 管理域滚动段 + 异常吞掉(v1 T11;批次F F1)。
行为测试是底线——start() 的 job 注册断言在 test_sync_scheduler.py 的 行为测试是底线——start() 的 job 注册断言在 test_sync_scheduler.py 的
注册测试里同步更新(那里 spy 断言 add_job 全集)。 注册测试里同步更新(那里 spy 断言 add_job 全集)。
@@ -15,6 +15,7 @@ from sqlalchemy.pool import StaticPool
from app.db.base import Base from app.db.base import Base
from app.db.models import MsgDailyStats, SensitiveWord, StatDailyStaff 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.storage.summary import day_key
from app.sync.scheduler import SyncScheduler 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"): with caplog.at_level(logging.ERROR, logger="app.sync.scheduler"):
await sch._analytics_tick() # 不抛 await sch._analytics_tick() # 不抛
assert any("analytics tick failed" in r.getMessage() for r in caplog.records) 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
+19 -10
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): 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) sched = SyncScheduler(engine=engine, max_conversations_per_run=123)
commands: list[list[str]] = [] 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") await sched._safe_page_run("messages")
# 2026-08-21: messages job 是 friends→wa-friends→四类消息的命令序列, assert len(commands) == 10
# 每条都调 scrape_via_page.py;2026-08-25 末尾追加 moments 浅扫
assert len(commands) == 7
for args in commands: for args in commands:
assert args[1].endswith("scrape_via_page.py") assert args[1].endswith("scrape_via_page.py")
def entity_of(args: list[str]) -> str: def entity_of(args: list[str]) -> str:
return args[args.index("--entity") + 1] return args[args.index("--entity") + 1]
assert entity_of(commands[0]) == "friends" # 头部:账号刷新(员工列表「数据更新/最新消息时间」来源)
assert entity_of(commands[1]) == "wa-friends" assert entity_of(commands[0]) == "accounts"
for args in commands[2:6]: assert entity_of(commands[1]) == "wa-accounts"
assert entity_of(args) in ( assert entity_of(commands[2]) == "friends"
"messages", "wa-messages", "group-messages", "wa-group-messages", 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("--max-conversations") + 1] == "99999"
assert args[args.index("--active-window-hours") + 1] == "24"
# moments 浅扫在序列末尾:每员工最多 3 页 # moments 浅扫在序列末尾:每员工最多 3 页
moments_cmd = commands[-1] moments_cmd = commands[-1]
assert entity_of(moments_cmd) == "moments" assert entity_of(moments_cmd) == "moments"
+13
View File
@@ -178,6 +178,14 @@ export interface GroupQuery {
room_id?: string room_id?: string
/** 群名称模糊搜索(通讯录群 tab 搜索框)。 */ /** 群名称模糊搜索(通讯录群 tab 搜索框)。 */
q?: string q?: string
/** 群备注子串(微信群统计筛选)。 */
group_remark?: string
/** 最新消息时间段(ms,含端点)。 */
last_start?: number
last_end?: number
/** 统计时段(ms,含端点)——窗内输出沟通消息数/新增/退出成员数聚合。 */
msg_start?: number
msg_end?: number
page?: number page?: number
page_size?: number page_size?: number
} }
@@ -191,6 +199,11 @@ export function getGroups(query: GroupQuery = {}): Promise<Page<Group>> {
user_id: query.user_id, user_id: query.user_id,
room_id: query.room_id, room_id: query.room_id,
q: query.q, 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: query.page ?? 1,
page_size: query.page_size ?? 20, 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 })
+19
View File
@@ -33,6 +33,12 @@ export interface Customer {
/** 今日发送/接收条数(本地消息聚合,对齐云客今日口径)。 */ /** 今日发送/接收条数(本地消息聚合,对齐云客今日口径)。 */
send_today?: number | null send_today?: number | null
receive_today?: number | null receive_today?: number | null
/** 累计发送/接收条数(同步自云客 friend 记录 sendNum/receiveNum;员工客户页列)。 */
send_num?: number | null
receive_num?: number | null
/** 云客数据来源代码(customer_account_link 员工维度路径直读;档案路径空,
* 前端回退 raw_json.fromType)。 */
from_type?: number | null
/** ms epoch of friend add time (from raw_json.addTime). */ /** ms epoch of friend add time (from raw_json.addTime). */
add_time?: number | null add_time?: number | null
message_count?: number | null message_count?: number | null
@@ -107,6 +113,19 @@ export interface Group {
friend_count?: number | null friend_count?: number | null
/** 建群时间 ms-epoch */ /** 建群时间 ms-epoch */
create_time?: number | null 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, * Verbatim upstream chatroom record (camelCase keys: chatRoomId/chatRoomName,
* memberCount/memberIds, headImageUrl, wechatId/wechatNickName, userName/ * 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 // `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. // so the active item highlights on load and on browser back/forward.
// 带参路由(路径与菜单 index 不等)按 route.name 映射父菜单路径。
const activeMenu = computed(() => { const activeMenu = computed(() => {
if (route.name === 'chat') return '/customers' if (route.name === 'chat') return '/customers'
if (route.name === 'group-chat') return '/groups' if (route.name === 'group-chat') return '/groups'
if (route.name === 'management-employees-detail') return '/management/employees'
return route.path return route.path
}) })
@@ -99,6 +101,19 @@ function logout(): void {
全局搜索 全局搜索
</el-menu-item> </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"> <el-sub-menu index="analytics">
<template #title> <template #title>
+34
View File
@@ -92,6 +92,31 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/analytics/SensitiveView.vue'), component: () => import('@/views/analytics/SensitiveView.vue'),
meta: { title: '敏感词' }, 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?', path: '/chat/:channel/:ownerId/:talkerId?',
name: 'chat', name: 'chat',
@@ -117,6 +142,15 @@ const routes: RouteRecordRaw[] = [
props: true, props: true,
meta: { title: '员工聊天窗口' }, meta: { title: '员工聊天窗口' },
}, },
{
// 员工客户列表(2026-09-08 对齐云客员工详情):员工列表「好友数/微信群数」
// 账号维度下钻,与全局 /customers 分离; ?name= 直带员工显示名(直链自动解析)
path: '/staff-friends/:ownerId/:tab(customers|groups)?',
name: 'staff-friends',
component: () => import('@/views/StaffFriends.vue'),
props: true,
meta: { title: '员工客户列表' },
},
{ {
// 登录页(public:未登录可进;守卫据此放行) // 登录页(public:未登录可进;守卫据此放行)
path: '/login', path: '/login',
+11 -2
View File
@@ -10,6 +10,9 @@
* ], * ],
* filename: '微信客户列表', * filename: '微信客户列表',
* }) * })
*
* Optional `preamble: string[]` writes comment lines (口径/窗口注释等) before
* the header — each line occupies the first column of its own row.
*/ */
export interface CsvColumn<T> { export interface CsvColumn<T> {
@@ -30,6 +33,12 @@ export interface ExportOptions<T> {
pageSize?: number pageSize?: number
/** Max rows to export (safety cap, default 10000) */ /** Max rows to export (safety cap, default 10000) */
maxRows?: number 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 { 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 }> { 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 // Fetch all pages
const allRows: T[] = [] 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) return escapeCsv(val as string | number | null | undefined)
}).join(',') }).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 // Trigger download
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }) 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')}"` 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. */ /** Friendly label for a Channel value. */
export function channelLabel(channel?: string | null): string { export function channelLabel(channel?: string | null): string {
switch (channel) { switch (channel) {
+6 -2
View File
@@ -77,7 +77,7 @@ export const GROUP_FIELD_GROUPS: GroupFieldGroup[] = [
}, },
{ {
title: '成员', title: '成员',
keys: ['memberCount', 'memberIds'], keys: ['memberCount'], // memberIds 原始串由群详情成员墙替代(2026-09-10)
}, },
{ {
title: '归属微信与群主', title: '归属微信与群主',
@@ -105,7 +105,11 @@ export const GROUP_FIELD_GROUPS: GroupFieldGroup[] = [
] ]
/** Every key already surfaced via a group (so 其他 can skip duplicates). */ /** 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). */ /** Resolve the 中文 label for a raw_json key (falls back to the key). */
export function fieldLabel(key: string): string { export function fieldLabel(key: string): string {
+13 -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). */ /** True when this timeline is a group chat (drives the query + title). */
const isGroup = computed(() => !!props.roomId) const isGroup = computed(() => !!props.roomId)
/**
* 专注模式(?bare=1):群统计等列表入口的「查看详情」——隐藏会话侧栏,
* 对齐云客群详情页(无好友/群列表,纯聊天记录窗)。员工聊天窗口入口
* (不带 bare)侧栏常驻(2026-09-09 修复保持)。
*/
const isBare = computed(() => route.query.bare === '1')
/** 当前会话类型(高亮 sidebar + 驱动空态)。账号级入口(无 talkerId/roomId)→ null。 */ /** 当前会话类型(高亮 sidebar + 驱动空态)。账号级入口(无 talkerId/roomId)→ null。 */
const activeType = computed<'friend' | 'group' | null>(() => const activeType = computed<'friend' | 'group' | null>(() =>
isGroup.value ? 'group' : props.talkerId ? 'friend' : null, isGroup.value ? 'group' : props.talkerId ? 'friend' : null,
@@ -342,8 +349,13 @@ onMounted(() => {
<template> <template>
<div class="yk-chat"> <div class="yk-chat">
<div class="yk-chat__body"> <div class="yk-chat__body">
<!-- 侧栏常驻(含群聊路由):好友/群列表随时可切,对齐云客聊天窗体验。
旧版 v-if="!isGroup" 在群路由下藏侧栏,点群后「好友列表/群列表」
消失(2026-09-09 用户报);onSelectSession 本就双类型导航。
bare 模式(群统计「查看详情」入口)例外——纯聊天记录窗无侧栏,
对齐云客群详情页(2026-09-10 用户反馈)。 -->
<ChatSidebar <ChatSidebar
v-if="!isGroup" v-if="!isBare"
:channel="channelTyped" :channel="channelTyped"
:owner-id="props.ownerId" :owner-id="props.ownerId"
:active-type="activeType" :active-type="activeType"
+17 -3
View File
@@ -43,6 +43,20 @@ function freshTabState(pageSize = 5): TabState {
/** 云客口径客户合计(分渠道,Σ员工好友数声明值,含云客索引重复;页脚对齐云客)。 */ /** 云客口径客户合计(分渠道,Σ员工好友数声明值,含云客索引重复;页脚对齐云客)。 */
const declaredTotals = ref<{ wechat: number | null; whatsapp: number | null }>({ wechat: null, whatsapp: null }) const declaredTotals = ref<{ wechat: number | null; whatsapp: number | null }>({ wechat: null, whatsapp: null })
/** 列表过滤是否生效(部门/员工)——生效时计数与分页必须用真实 total,声明口径是
* 全局值不吃过滤:员工列表点「好友数」跳入时曾把 8956 当分页总数,列表却只有
* 该员工的 9 条,翻页按全部客户算(2026-09-08 用户报「不是员工下面的好友列表」)。 */
const filtered = computed(
() => departmentId.value !== null || ownerId.value !== null,
)
/** 展示用总数:无过滤时对齐云客声明口径(产品定调),有过滤时回到真实结果数。 */
function displayTotal(tab: TabKey): number {
return !filtered.value && declaredTotals.value[tab] !== null
? (declaredTotals.value[tab] as number)
: tabState[tab].total
}
async function loadDeclaredTotal(): Promise<void> { async function loadDeclaredTotal(): Promise<void> {
try { try {
const r = await getCustomerDeclaredTotal() const r = await getCustomerDeclaredTotal()
@@ -440,7 +454,7 @@ onMounted(() => {
<div> <div>
<h2 style="margin: 0 0 4px; font-size: 16px">{{ activeTab === 'wechat' ? '微信' : 'WhatsApp' }}客户列表</h2> <h2 style="margin: 0 0 4px; font-size: 16px">{{ activeTab === 'wechat' ? '微信' : 'WhatsApp' }}客户列表</h2>
<span class="yk-customers__sub"> <span class="yk-customers__sub">
共 {{ declaredTotals[activeTab] !== null ? declaredTotals[activeTab] : tabState[activeTab].total }} 条 共 {{ displayTotal(activeTab) }} 条
<template v-if="departmentName"> · 部门:{{ departmentName }}</template> <template v-if="departmentName"> · 部门:{{ departmentName }}</template>
<template v-if="ownerId"> · 员工账号:{{ ownerId }}</template> <template v-if="ownerId"> · 员工账号:{{ ownerId }}</template>
</span> </span>
@@ -589,7 +603,7 @@ onMounted(() => {
:current-page="tabState.wechat.page" :current-page="tabState.wechat.page"
:page-size="tabState.wechat.pageSize" :page-size="tabState.wechat.pageSize"
:page-sizes="[5, 10, 20, 50, 100]" :page-sizes="[5, 10, 20, 50, 100]"
:total="declaredTotals.wechat ?? tabState.wechat.total" :total="displayTotal('wechat')"
layout="total, sizes, prev, pager, next, jumper" layout="total, sizes, prev, pager, next, jumper"
background background
@current-change="onWechatPageChange" @current-change="onWechatPageChange"
@@ -726,7 +740,7 @@ onMounted(() => {
:current-page="tabState.whatsapp.page" :current-page="tabState.whatsapp.page"
:page-size="tabState.whatsapp.pageSize" :page-size="tabState.whatsapp.pageSize"
:page-sizes="[5, 10, 20, 50, 100]" :page-sizes="[5, 10, 20, 50, 100]"
:total="declaredTotals.whatsapp ?? tabState.whatsapp.total" :total="displayTotal('whatsapp')"
layout="total, sizes, prev, pager, next, jumper" layout="total, sizes, prev, pager, next, jumper"
background background
@current-change="onWhatsappPageChange" @current-change="onWhatsappPageChange"
+228 -56
View File
@@ -3,14 +3,12 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Download } from '@element-plus/icons-vue' import { Download } from '@element-plus/icons-vue'
import { getGroups } from '@/api' import { getAccounts, getGroupMembers, getGroups } from '@/api'
import { downloadBlob } from '@/api/client' import type { Account, Channel, Group, GroupMember } from '@/api/types'
import type { Channel, Group } from '@/api/types'
import { useChannelStore } from '@/stores/channel' import { useChannelStore } from '@/stores/channel'
import { channelLabel, formatTime } from '@/utils/format' import { channelLabel, formatTime } from '@/utils/format'
import { exportListToCsv } from '@/utils/csvExport' import { exportListToCsv } from '@/utils/csvExport'
import { buildGroupSections, readField } from '@/utils/groupFields' import { buildGroupSections, readField } from '@/utils/groupFields'
import { fromTypeLabel } from '@/utils/customerFields'
/** /**
* 群列表 — channel-tabbed view mirroring 云客「沟通」板块的群列表. * 群列表 — channel-tabbed view mirroring 云客「沟通」板块的群列表.
@@ -84,10 +82,6 @@ function cellNum(row: Group, camelKey: string, fallback = 0): number {
const v = cell(row, camelKey) const v = cell(row, camelKey)
return typeof v === 'number' && Number.isFinite(v) ? v : fallback 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 { function imageCell(row: Group, camelKey: string): string | undefined {
const v = cell(row, camelKey) const v = cell(row, camelKey)
return typeof v === 'string' && v !== '' ? v : undefined return typeof v === 'string' && v !== '' ? v : undefined
@@ -108,7 +102,16 @@ async function loadTab(ch: TabKey): Promise<void> {
channel: ch, channel: ch,
page: st.page, page: st.page,
page_size: st.pageSize, 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.items = res.items
st.total = res.total st.total = res.total
@@ -120,6 +123,47 @@ async function loadTab(ch: TabKey): Promise<void> {
st.loading = false 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 { function handlePageChange(ch: TabKey, p: number): void {
tabState[ch].page = p tabState[ch].page = p
loadTab(ch) loadTab(ch)
@@ -145,6 +189,9 @@ function viewChat(row: Group): void {
router.push({ router.push({
name: 'group-chat', name: 'group-chat',
params: { channel: row.channel, ownerId: row.owner_account_id, roomId: row.room_id }, 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 守卫。 */ /** drawer 内「查看聊天记录」入口:drawerGroup 是 ref<Group|null>,模板嵌套不保证窄化,用 wrapper 守卫。 */
@@ -154,28 +201,6 @@ function viewChatFromDrawer(): void {
viewChat(row) 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(含当前员工筛选) ---- // ---- header 导出:当前 tab 渠道全量群 CSV(含当前员工筛选) ----
const exporting = ref(false) const exporting = ref(false)
@@ -222,9 +247,35 @@ function clearOwnerFilter(): void {
} }
// ---- drawer ---- // ---- 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 { function openDrawer(row: Group): void {
drawerGroup.value = row drawerGroup.value = row
drawerOpen.value = true drawerOpen.value = true
if (row.channel === 'wechat') {
loadDrawerMembers(row.room_id)
} else {
drawerMembers.value = []
}
} }
const drawerSections = computed(() => const drawerSections = computed(() =>
drawerGroup.value ? buildGroupSections(drawerGroup.value) : [], drawerGroup.value ? buildGroupSections(drawerGroup.value) : [],
@@ -262,6 +313,7 @@ watch(
) )
onMounted(() => { onMounted(() => {
loadStaffOptions()
loadTab(activeTab.value) loadTab(activeTab.value)
}) })
</script> </script>
@@ -287,9 +339,52 @@ onMounted(() => {
</div> </div>
<!-- ============================================================ <!-- ============================================================
微信群 — 7 列,对齐云客 wechatdetail/2/{wxId}. 微信群统计 — 对齐云客「沟通→微信号管理→微信群统计」
(/cms/wechat/wechatgroup,2026-09-09 真机对齐)。
统计时段窗内:沟通消息数=本地日汇总;新增/退出成员数=按天进出表
求和(上线日起积累,历史日期为 0)。
============================================================ --> ============================================================ -->
<template v-if="activeTab === 'wechat'"> <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 <el-alert
v-if="tabState.wechat.errorMsg" v-if="tabState.wechat.errorMsg"
:title="tabState.wechat.errorMsg" :title="tabState.wechat.errorMsg"
@@ -309,40 +404,55 @@ onMounted(() => {
empty-text="暂无微信群数据" empty-text="暂无微信群数据"
@row-click="openDrawer" @row-click="openDrawer"
> >
<el-table-column label="群信息" min-width="180"> <el-table-column label="群信息" min-width="200">
<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">
<template #default="{ row }"> <template #default="{ row }">
<div class="yk-group-owner"> <div class="yk-group-owner">
<el-avatar :size="36" :src="imageCell(row, 'ownerImage') || undefined" shape="circle"> <el-avatar :size="34" :src="row.head_image_url || undefined" shape="circle">
{{ (cellText(row, 'ownerName') || '?').slice(0, 1) }} {{ (row.room_name || '?').slice(0, 1) }}
</el-avatar> </el-avatar>
<div class="yk-group-owner__meta"> <div class="yk-group-owner__meta">
<div class="yk-staffinfo__nick">{{ cellText(row, 'ownerName') }}</div> <div class="yk-staffinfo__nick">{{ row.room_name || '--' }}</div>
<div class="yk-staffinfo__muted">{{ cellText(row, 'realName') }}</div>
</div> </div>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="群成员数量" width="110" align="right"> <el-table-column label="群备注" min-width="110">
<template #default="{ row }">{{ cellNum(row, 'memberCount') }}</template> <template #default="{ row }">{{ row.group_remark || '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column label="群成员好友数量" width="130" align="right"> <el-table-column label="员工微信" min-width="170">
<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">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" size="small" @click.stop="viewChat(row)">查看聊天记录</el-button> <div class="yk-group-owner">
<el-button link type="primary" size="small" @click.stop="exportGroupHtml(row)">导出HTML聊天记录</el-button> <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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -465,6 +575,26 @@ onMounted(() => {
</div> </div>
</div> </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"> <div v-for="section in drawerSections" :key="section.title" class="yk-drawer__section">
<el-divider content-position="left">{{ section.title }}</el-divider> <el-divider content-position="left">{{ section.title }}</el-divider>
<dl class="yk-drawer__kv"> <dl class="yk-drawer__kv">
@@ -513,4 +643,46 @@ onMounted(() => {
.yk-group-owner__meta { .yk-group-owner__meta {
min-width: 0; 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> </style>
+587
View File
@@ -0,0 +1,587 @@
<script setup lang="ts">
/**
* 员工客户列表页(2026-09-08,对齐云客员工详情客户列表,用户截图版式)。
*
* 入口:员工列表行内「好友数/微信群数」(微信行)——账号维度下钻,与全局
* 客户列表(/customers)分离:固定 owner 过滤、微信客户/微信群两 tab、
* 搜索(昵称/微信号/备注名/手机号)、勾选批量「导出聊天记录」(服务端打
* zip,每会话一个 HTML)、行内查看/导出聊天记录(沿用 staff-chat 与
* /export/chat-html)。
*
* 列口径:发送/接收条数用 Customer.send_num/receive_num(云客 friend 记录
* 累计值,与截图同源);分页用真实过滤 total(本页恒有 owner 过滤,不存在
* 全局声明口径问题)。
*/
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue'
import { getAccounts, getCustomers, getGroups } from '@/api'
import { downloadBlob } from '@/api/client'
import type { Account, Customer, Group } from '@/api/types'
import { exportListToCsv } from '@/utils/csvExport'
import { formatTime } from '@/utils/format'
import { fromTypeLabel, readField } from '@/utils/customerFields'
const props = defineProps<{
/** 归属员工账号(wechatId) */
ownerId: string
/** customers=微信客户 tab(默认) / groups=微信群 tab */
tab?: string
}>()
const route = useRoute()
const router = useRouter()
const activeTab = ref<'customers' | 'groups'>(props.tab === 'groups' ? 'groups' : 'customers')
// ---- 员工名:入口 query ?name= 直带;直链/刷新时经 /api/accounts 解析 ----
const staffName = ref<string | null>(
route.query.name ? String(route.query.name) : null,
)
const resolvedName = ref<string | null>(null)
const displayTitle = computed(() => staffName.value ?? resolvedName.value ?? props.ownerId)
async function resolveName(): Promise<void> {
if (staffName.value) return
try {
const res = await getAccounts({ channel: 'wechat', page: 1, page_size: 100 })
const hit = res.items.find((a: Account) => a.account_id === props.ownerId)
resolvedName.value = hit?.nickname || hit?.user_name || null
} catch {
// 静默降级:标题显示 ownerId
}
}
// ---- 搜索(作用于当前 tab;/api/customers 与 /api/groups 均支持 q) ----
const keyword = ref('')
// ---- 微信客户 tab ----
const customers = ref<Customer[]>([])
const custTotal = ref(0)
const custPage = ref(1)
const custPageSize = ref(10)
const custLoading = ref(false)
const custError = ref<string | null>(null)
// ---- 微信群 tab ----
const groups = ref<Group[]>([])
const groupTotal = ref(0)
const groupPage = ref(1)
const groupPageSize = ref(10)
const groupLoading = ref(false)
const groupError = ref<string | null>(null)
async function loadCustomers(): Promise<void> {
custLoading.value = true
custError.value = null
try {
const res = await getCustomers({
channel: 'wechat',
owner_account_id: props.ownerId,
q: keyword.value.trim() || undefined,
page: custPage.value,
page_size: custPageSize.value,
})
customers.value = res.items
custTotal.value = res.total
} catch (e) {
custError.value = (e as Error).message
ElMessage.error(`客户列表加载失败:${(e as Error).message}`)
} finally {
custLoading.value = false
}
}
async function loadGroups(): Promise<void> {
groupLoading.value = true
groupError.value = null
try {
const res = await getGroups({
channel: 'wechat',
owner_account_id: props.ownerId,
q: keyword.value.trim() || undefined,
page: groupPage.value,
page_size: groupPageSize.value,
})
groups.value = res.items
groupTotal.value = res.total
} catch (e) {
groupError.value = (e as Error).message
ElMessage.error(`群列表加载失败:${(e as Error).message}`)
} finally {
groupLoading.value = false
}
}
function onSearch(): void {
custPage.value = 1
groupPage.value = 1
clearSelection()
if (activeTab.value === 'customers') loadCustomers()
else loadGroups()
}
// tab 切换:懒加载目标 tab(搜索词共享);URL param 同步,便于直链/刷新。
function onTabChange(t: 'customers' | 'groups'): void {
clearSelection()
router.replace({
name: 'staff-friends',
params: { ownerId: props.ownerId, tab: t === 'groups' ? 'groups' : 'customers' },
query: route.query.name ? { name: String(route.query.name) } : undefined,
})
if (t === 'customers') {
if (customers.value.length === 0 && custTotal.value === 0) loadCustomers()
} else {
if (groups.value.length === 0 && groupTotal.value === 0) loadGroups()
}
}
// ---- 勾选 + 批量导出聊天记录(服务端 zip) ----
const selected = ref<Customer[]>([])
function onSelectionChange(rows: Customer[]): void {
selected.value = rows
}
function clearSelection(): void {
selected.value = []
custTableRef.value?.clearSelection()
}
const custTableRef = ref<{
clearSelection: () => void
} | null>(null)
const ZIP_LIMIT = 50
const batchExporting = ref(false)
async function exportChatZip(): Promise<void> {
if (selected.value.length === 0) return
if (selected.value.length > ZIP_LIMIT) {
ElMessage.warning(`一次最多导出 ${ZIP_LIMIT} 个会话,请减少勾选`)
return
}
batchExporting.value = true
try {
await downloadBlob('/export/chat-html-zip', {
channel: 'wechat',
owner_account_id: props.ownerId,
talker_ids: selected.value.map((c) => c.friend_id).join(','),
})
ElMessage.success(`已导出 ${selected.value.length} 个会话的聊天记录`)
} catch (e) {
ElMessage.error(`批量导出失败:${(e as Error).message}`)
} finally {
batchExporting.value = false
}
}
// ---- 行内:查看聊天记录 → 员工聊天窗口(single/group tab) ----
function viewChat(row: Customer): void {
router.push({
name: 'staff-chat',
params: { ownerId: props.ownerId, tab: 'single', talkerId: row.friend_id },
})
}
function viewGroupChat(row: Group): void {
router.push({
name: 'staff-chat',
params: { ownerId: props.ownerId, tab: 'group', talkerId: row.room_id },
})
}
// ---- 行内:导出HTML聊天记录(可选时间范围,沿用全局客户列表的弹窗模式) ----
const exportOpen = ref(false)
const exportRow = ref<Customer | null>(null)
// value-format="X" 绑秒级 unix 字符串;confirm 时 *1000 转毫秒。
const exportRange = ref<[string, string] | null>(null)
function openExport(row: Customer): void {
exportRow.value = row
exportRange.value = null
exportOpen.value = true
}
async function confirmExport(): Promise<void> {
const row = exportRow.value
if (!row) return
const params: Record<string, unknown> = {
channel: 'wechat',
owner_account_id: props.ownerId,
talker_id: row.friend_id,
}
if (exportRange.value) {
params.start = Number(exportRange.value[0]) * 1000
params.end = Number(exportRange.value[1]) * 1000
}
exportOpen.value = false
try {
await downloadBlob('/export/chat-html', params)
ElMessage.success('导出已开始')
} catch (e) {
ElMessage.error(`导出失败,请稍后重试:${(e as Error).message}`)
}
}
// ---- 导出 CSV(当前 tab + 搜索词 + owner 过滤) ----
const exporting = ref(false)
async function exportCsv(): Promise<void> {
exporting.value = true
try {
if (activeTab.value === 'customers') {
const r = await exportListToCsv<Customer>({
fetchPage: (page, pageSize) =>
getCustomers({
channel: 'wechat',
owner_account_id: props.ownerId,
q: keyword.value.trim() || undefined,
page,
page_size: pageSize,
}),
columns: [
{ key: 'friend_nickname', label: '客户昵称' },
{ key: 'friend_id', label: '微信号' },
{ key: 'friend_remark', label: '备注名' },
{ key: 'from_type', label: '数据来源', format: (r) => fromTypeLabel(readField(r, 'fromType')) },
{
key: 'last_chat_time',
label: '最近沟通时间',
format: (r) => (r.last_chat_time ? formatTime(r.last_chat_time) : '-'),
},
{ key: 'send_num', label: '发送条数', format: (r) => r.send_num ?? '-' },
{ key: 'receive_num', label: '接收条数', format: (r) => r.receive_num ?? '-' },
],
filename: `微信客户_${props.ownerId}`,
})
ElMessage.success(`已导出 ${r.rows} 行`)
} else {
const r = await exportListToCsv<Group>({
fetchPage: (page, pageSize) =>
getGroups({
channel: 'wechat',
owner_account_id: props.ownerId,
q: keyword.value.trim() || undefined,
page,
page_size: pageSize,
}),
columns: [
{ key: 'room_name', label: '群名称' },
{ key: 'room_id', label: '群ID' },
{ key: 'owner_name', label: '群主昵称' },
{ key: 'owner_wxid', label: '群主wxId' },
{ key: 'member_count', label: '群成员数量' },
{ key: 'message_count', label: '消息数量' },
],
filename: `微信群_${props.ownerId}`,
})
ElMessage.success(`已导出 ${r.rows} 行`)
}
} catch (e) {
ElMessage.error(`导出失败:${(e as Error).message}`)
} finally {
exporting.value = false
}
}
// ---- 展示 helper ----
function avatarUrl(c: Customer): string | undefined {
const v = readField(c, 'friendHeadImageUrl')
return typeof v === 'string' && v !== '' ? v : undefined
}
function groupAvatarUrl(g: Group): string | undefined {
const v = g.raw_json?.headImageUrl
return typeof v === 'string' && v !== '' ? v : undefined
}
function initials(c: Customer): string {
const nick = c.friend_nickname || c.friend_id
return nick ? nick.slice(0, 1).toUpperCase() : '?'
}
function groupInitials(g: Group): string {
const n = g.room_name || g.room_id
return n ? n.slice(0, 1) : '?'
}
function goBack(): void {
if (window.history.length > 1) router.back()
else router.push({ path: '/staff', query: { channel: 'wechat' } })
}
// ---- 生命周期 + 路由参数变化(同组件不同员工切换) ----
watch(
() => [props.ownerId, props.tab] as const,
() => {
activeTab.value = props.tab === 'groups' ? 'groups' : 'customers'
resetAndLoad()
},
)
function resetAndLoad(): void {
custPage.value = 1
groupPage.value = 1
clearSelection()
loadCustomers()
if (activeTab.value === 'groups') loadGroups()
}
onMounted(() => {
resolveName()
loadCustomers()
if (activeTab.value === 'groups') loadGroups()
})
</script>
<template>
<div class="sf-page">
<!-- 头部:返回 + 员工名 + 两 tab + 搜索/导出行 -->
<div class="sf-header">
<div class="sf-header__title">
<el-button :icon="ArrowLeft" circle size="small" @click="goBack" />
<h1>{{ displayTitle }}</h1>
<span class="sf-header__sub">微信号:{{ ownerId }}</span>
</div>
<el-tabs v-model="activeTab" class="sf-tabs" @tab-change="onTabChange($event as 'customers' | 'groups')">
<el-tab-pane label="微信客户" name="customers" />
<el-tab-pane label="微信群" name="groups" />
</el-tabs>
<div class="sf-toolbar">
<el-input
v-model="keyword"
class="sf-search"
placeholder="支持微信昵称、微信号、备注名、手机号搜索"
clearable
@keyup.enter="onSearch"
@clear="onSearch"
/>
<el-button type="primary" @click="onSearch">查询</el-button>
<el-button :loading="exporting" @click="exportCsv">导出</el-button>
<el-button
v-if="activeTab === 'customers'"
:disabled="selected.length === 0"
:loading="batchExporting"
@click="exportChatZip"
>
导出聊天记录{{ selected.length > 0 ? `(${selected.length})` : '' }}
</el-button>
</div>
</div>
<!-- 微信客户表 -->
<div v-show="activeTab === 'customers'" class="sf-card" v-loading="custLoading">
<el-alert v-if="custError" :title="`客户列表加载失败:${custError}`" type="error" show-icon :closable="false" />
<el-table
ref="custTableRef"
:data="customers"
row-key="friend_id"
empty-text="该员工暂无客户"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="42" reserve-selection />
<el-table-column label="客户微信" min-width="220">
<template #default="{ row }">
<div class="sf-wechat">
<el-avatar :size="36" :src="avatarUrl(row)" shape="square">
{{ initials(row) }}
</el-avatar>
<div class="sf-wechat__meta">
<span class="sf-wechat__nick">{{ row.friend_nickname || '--' }}</span>
<span class="sf-wechat__id">{{ row.friend_id }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="数据来源" width="110">
<template #default="{ row }">{{ fromTypeLabel(row.from_type ?? readField(row, 'fromType')) }}</template>
</el-table-column>
<el-table-column label="客户备注名" min-width="120">
<template #default="{ row }">{{ row.friend_remark || '-' }}</template>
</el-table-column>
<el-table-column label="最近沟通时间" width="170">
<template #default="{ row }">{{ row.last_chat_time ? formatTime(row.last_chat_time) : '-' }}</template>
</el-table-column>
<el-table-column label="发送条数" width="95" align="right">
<template #default="{ row }">{{ row.send_num ?? '-' }}</template>
</el-table-column>
<el-table-column label="接收条数" width="95" align="right">
<template #default="{ row }">{{ row.receive_num ?? '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<div class="sf-actions">
<el-button link type="primary" size="small" @click="viewChat(row)">查看聊天记录</el-button>
<el-button link type="primary" size="small" @click="openExport(row)">导出HTML聊天记录</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="sf-pager">
<el-pagination
:current-page="custPage"
:page-size="custPageSize"
:page-sizes="[10, 20, 50]"
:total="custTotal"
layout="total, sizes, prev, pager, next"
@current-change="(p: number) => { custPage = p; loadCustomers() }"
@size-change="(s: number) => { custPageSize = s; custPage = 1; loadCustomers() }"
/>
</div>
</div>
<!-- 微信群表 -->
<div v-show="activeTab === 'groups'" class="sf-card" v-loading="groupLoading">
<el-alert v-if="groupError" :title="`群列表加载失败:${groupError}`" type="error" show-icon :closable="false" />
<el-table :data="groups" row-key="room_id" empty-text="该员工暂无群">
<el-table-column label="群名称" min-width="240">
<template #default="{ row }">
<div class="sf-wechat">
<el-avatar :size="36" :src="groupAvatarUrl(row)" shape="square">
{{ groupInitials(row) }}
</el-avatar>
<div class="sf-wechat__meta">
<span class="sf-wechat__nick">{{ row.room_name || '--' }}</span>
<span class="sf-wechat__id">{{ row.room_id }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="群主" min-width="150">
<template #default="{ row }">
<div class="sf-wechat__meta">
<span>{{ row.owner_name || '-' }}</span>
<span v-if="row.owner_wxid" class="sf-wechat__id">{{ row.owner_wxid }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="群成员数量" width="110" align="right">
<template #default="{ row }">{{ row.member_count ?? '-' }}</template>
</el-table-column>
<el-table-column label="消息数量" width="100" align="right">
<template #default="{ row }">{{ row.message_count ?? '-' }}</template>
</el-table-column>
<el-table-column label="最近消息时间" width="170">
<template #default="{ row }">{{ row.last_chat_time ? formatTime(row.last_chat_time) : '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="viewGroupChat(row)">查看群聊记录</el-button>
</template>
</el-table-column>
</el-table>
<div class="sf-pager">
<el-pagination
:current-page="groupPage"
:page-size="groupPageSize"
:page-sizes="[10, 20, 50]"
:total="groupTotal"
layout="total, sizes, prev, pager, next"
@current-change="(p: number) => { groupPage = p; loadGroups() }"
@size-change="(s: number) => { groupPageSize = s; groupPage = 1; loadGroups() }"
/>
</div>
</div>
<!-- 行内导出时间范围弹窗(对齐全局客户列表) -->
<el-dialog v-model="exportOpen" title="导出HTML聊天记录" width="420px">
<el-date-picker
v-model="exportRange"
type="datetimerange"
value-format="X"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
style="width: 100%"
/>
<template #footer>
<el-button @click="exportOpen = false">取消</el-button>
<el-button type="primary" @click="confirmExport">导出</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.sf-page {
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
overflow: auto;
}
.sf-header {
background: #fff;
border-radius: 8px;
padding: 12px 16px 0;
}
.sf-header__title {
display: flex;
align-items: center;
gap: 10px;
}
.sf-header__title h1 {
margin: 0;
font-size: 17px;
}
.sf-header__sub {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.sf-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 0 12px;
}
.sf-search {
width: 340px;
}
.sf-card {
background: #fff;
border-radius: 8px;
padding: 12px 16px;
flex: 1;
min-height: 0;
}
.sf-wechat {
display: flex;
align-items: center;
gap: 10px;
}
.sf-wechat__meta {
display: flex;
flex-direction: column;
line-height: 1.4;
min-width: 0;
}
.sf-wechat__nick {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sf-wechat__id {
color: var(--el-text-color-secondary);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sf-actions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.sf-actions .el-button + .el-button {
margin-left: 0;
}
.sf-pager {
display: flex;
justify-content: flex-end;
padding-top: 12px;
}
</style>
+19
View File
@@ -239,6 +239,16 @@ function waSerial(index: number): number {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function viewCustomers(row: Account): void { function viewCustomers(row: Account): void {
// 微信行 → 员工客户列表独立页(2026-09-08 对齐云客员工详情,账号维度
// 下钻:两 tab/搜索/批量导出);WA 行保持全局客户列表(该页暂仅微信)。
if (row.channel === 'wechat') {
router.push({
name: 'staff-friends',
params: { ownerId: row.account_id, tab: 'customers' },
query: { name: row.nickname || row.user_name || '' },
})
return
}
channelStore.setChannel(row.channel) channelStore.setChannel(row.channel)
router.push({ router.push({
path: '/customers', path: '/customers',
@@ -247,6 +257,15 @@ function viewCustomers(row: Account): void {
} }
function viewGroups(row: Account): void { function viewGroups(row: Account): void {
// 同上:微信群数 → 员工客户列表的群 tab;WA 保持原路径。
if (row.channel === 'wechat') {
router.push({
name: 'staff-friends',
params: { ownerId: row.account_id, tab: 'groups' },
query: { name: row.nickname || row.user_name || '' },
})
return
}
channelStore.setChannel(row.channel) channelStore.setChannel(row.channel)
if (row.channel === 'whatsapp') { if (row.channel === 'whatsapp') {
// WA:对齐云客,群列表在客户列表页的子 tab(2026-08-24) // WA:对齐云客,群列表在客户列表页的子 tab(2026-08-24)
+6 -14
View File
@@ -143,16 +143,16 @@
<template #default="{ row }">{{ fmt(row.first_contact_cnt) }}</template> <template #default="{ row }">{{ fmt(row.first_contact_cnt) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="首响" align="right"> <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>
<el-table-column label="回复率" align="right"> <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>
<el-table-column label="通话时长" align="right"> <el-table-column label="通话时长" align="right">
<template #default="{ row }">{{ fmtDur(row.call_seconds) }}</template> <template #default="{ row }">{{ fmtDur(row.call_seconds) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="接通率" align="right"> <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>
<el-table-column label="删粉/拉黑" align="right"> <el-table-column label="删粉/拉黑" align="right">
<template #default="{ row }">{{ fmtDelBlocked(row) }}</template> <template #default="{ row }">{{ fmtDelBlocked(row) }}</template>
@@ -187,7 +187,7 @@ import {
type StaffRankingRow, type StaffRankingRow,
} from '@/api/analytics' } from '@/api/analytics'
import { useChart } from '@/composables/useChart' import { useChart } from '@/composables/useChart'
import { formatTimeShort } from '@/utils/format' import { formatMsDuration, formatRatio, formatTimeShort } from '@/utils/format'
const router = useRouter() 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) const assets = computed<CustomerAssets | null>(() => data.value?.customer_assets ?? null)
// ── 排行新列格式化 ── // ── 排行新列格式化 ──
// 首响:<1h 显「X分Y秒」,≥1h 显「X小时Y分」;null(无样本)显 '-' // 首响/百分比用共享版(formatMsDuration/formatRatio,utils/format——批次C
const fmtFr = (ms: number | null) => { // 驾驶舱组件同口径,原本地 fmtFr/fmtPct 已提升去重)
if (ms == null) return '-'
const totalSec = Math.round(ms / 1000)
const mins = Math.floor(totalSec / 60)
if (mins < 60) return `${mins}分${totalSec % 60}秒`
return `${Math.floor(mins / 60)}小时${mins % 60}分`
}
// 百分比 1 位小数(回复率/接通率);null 显 '-'
const fmtPct = (v: number | null) => (v == null ? '-' : `${(v * 100).toFixed(1)}%`)
// 通话时长(秒):<1h 显「X分」,否则「X小时Y分」;0/null 显 '-' // 通话时长(秒):<1h 显「X分」,否则「X小时Y分」;0/null 显 '-'
const fmtDur = (s: number | null) => { const fmtDur = (s: number | null) => {
if (!s) return '-' 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>