Compare commits
35 Commits
b0ebe8a15a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 682249a3fd | |||
| 80b721b84f | |||
| acd6997bf0 | |||
| 253be99eca | |||
| e00d399195 | |||
| 2ac63e3844 | |||
| 0dc5993af7 | |||
| 0b975a8289 | |||
| 8f9ef747cc | |||
| 3d2889bcde | |||
| 2c7625c3e4 | |||
| 422a726ee8 | |||
| be0d6f844e | |||
| 577617545f | |||
| 4e53a99629 | |||
| b72e957da2 | |||
| 602bee8fdf | |||
| a9197f696a | |||
| ddeda3fa22 | |||
| 2587e4dd08 | |||
| 10b7e5e534 | |||
| e3fadfe1a6 | |||
| 3cf77ec6d5 | |||
| 7343e1c9aa | |||
| 5196a72ac2 | |||
| df8d2b30d5 | |||
| c56f13059d | |||
| 7b0e1601b7 | |||
| 940762e7ff | |||
| 2b10c07fb7 | |||
| 9fbd572bb7 | |||
| 23ac95f2e4 | |||
| 3d5644c42a | |||
| 31cd2d92aa | |||
| 57ace98792 |
@@ -241,3 +241,171 @@
|
|||||||
| 9 | 全量测试套件绿 | ✅ | **653 passed in 62s**(Task 8 本机实跑,2026-09-09) |
|
| 9 | 全量测试套件绿 | ✅ | **653 passed in 62s**(Task 8 本机实跑,2026-09-09) |
|
||||||
| 10 | 核实文档完整 | ✅ | 本文补齐 |
|
| 10 | 核实文档完整 | ✅ | 本文补齐 |
|
||||||
| 11 | 写钩子零改动 | ✅ | 批次A全部 17 个 commits(27f91e5→0fce21a)逐一核查,无一触碰 storage/summary.py、storage/repos.py(repos.py 最近一次改动 d428953 属 customer_account_link 工作线,先于批次A) |
|
| 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/敏感词扫描的真实活跃量级须在服务器侧(夜间链已跑)观察 |
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ DEFAULT_SCORE_CONFIG: dict = {
|
|||||||
"workload": {"weight": 15, "name": "有效负载", "metrics": {
|
"workload": {"weight": 15, "name": "有效负载", "metrics": {
|
||||||
"active_friends_band": {"type": "band", "metric": "active_friends",
|
"active_friends_band": {"type": "band", "metric": "active_friends",
|
||||||
"low": 5, "high": 80,
|
"low": 5, "high": 80,
|
||||||
"basis": "period_daily_avg"},
|
"basis": "period_distinct"},
|
||||||
"call_band": {"type": "band", "metric": "call_count",
|
"call_band": {"type": "band", "metric": "call_count",
|
||||||
"low": 1, "high": 200, "basis": "period_total",
|
"low": 1, "high": 200, "basis": "period_total",
|
||||||
"note": "通话低不扣分仅提示;band 得分下限 80"},
|
"note": "通话低不扣分仅提示;band 得分下限 80"},
|
||||||
@@ -452,7 +452,7 @@ def score_employee(config, current: PeriodMetrics, baseline: PeriodMetrics | Non
|
|||||||
```
|
```
|
||||||
|
|
||||||
规则(写死进测试):
|
规则(写死进测试):
|
||||||
- 周期值派生:`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 日均与 call_count 总量。
|
- 周期值派生:`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"(评分但标注)。
|
- 样本保护:`cust_threads < min_threads(30)` → 该 service/customer 指标 sample_status="insufficient",不评分;soft(10)~min 之间 "low_confidence"(评分但标注)。
|
||||||
- 维度分 = 各指标得分的均值(等权);任一指标 None 时该指标剔除并按剩余归一;**全部 None → 维度 pending**。
|
- 维度分 = 各指标得分的均值(等权);任一指标 None 时该指标剔除并按剩余归一;**全部 None → 维度 pending**。
|
||||||
- 总分 = Σ(可用维度 weight×dim_score)/Σ(可用维度 weight)×100;可用=pending_data 以外且维度分非 None;全 None → score_status="insufficient_sample"(total None)。
|
- 总分 = Σ(可用维度 weight×dim_score)/Σ(可用维度 weight)×100;可用=pending_data 以外且维度分非 None;全 None → score_status="insufficient_sample"(total None)。
|
||||||
@@ -478,7 +478,7 @@ def score_employee(config, current: PeriodMetrics, baseline: PeriodMetrics | Non
|
|||||||
| reply_p90_worsened | p90 > 基线p90×1.5 且样本≥soft;×2.5 升 critical | warning/critical | metric_code=first_reply_p90 |
|
| 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 |
|
| reply_rate_dropped | reply_2h_rate < 基线×0.7 且当前≥soft 样本 | warning | metric_code=unreplied |
|
||||||
| activity_drop | 日均 msg_total < 基线日均×0.5 且基线≥min_days | warning | 员工详情 |
|
| activity_drop | 日均 msg_total < 基线日均×0.5 且基线≥min_days | warning | 员工详情 |
|
||||||
| workload_overload | active_friends 日均 > 80(band 上限) | 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 日均。
|
卡片字段照 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 日均。
|
||||||
|
|
||||||
|
|||||||
@@ -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)。
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -101,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()),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -537,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",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -88,12 +88,13 @@ def baseline_window(start: int) -> tuple[int, int]:
|
|||||||
return _to_key(bs), _to_key(be)
|
return _to_key(bs), _to_key(be)
|
||||||
|
|
||||||
|
|
||||||
def _percentile(vals: list[int], q_num: int, q_den: int) -> int | None:
|
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)。
|
"""最近邻秩分位 ceil(q·n) 的样本值,q=q_num/q_den(1/2、9/10)。
|
||||||
|
|
||||||
整数算术 rank=(q_num*n + q_den - 1)//q_den 对正整数 n 恒落在 [1,n]
|
公有供同包 api_assembly(B4 服务趋势)复用;整数算术
|
||||||
(与 math.ceil(q·n) 等价,与批次A _percentile 同口径);空列表→None
|
rank=(q_num*n + q_den - 1)//q_den 对正整数 n 恒落在 [1,n]
|
||||||
(与 0 严格区分)。
|
(与 math.ceil(q·n) 等价,与批次A storage 层 _percentile 同口径——
|
||||||
|
后者为不同层单独保留);空列表→None(与 0 严格区分)。
|
||||||
"""
|
"""
|
||||||
if not vals:
|
if not vals:
|
||||||
return None
|
return None
|
||||||
@@ -246,8 +247,8 @@ async def period_metrics(
|
|||||||
if uid is not None and uid in target:
|
if uid is not None and uid in target:
|
||||||
fr_by_user.setdefault(uid, []).append(int(fr))
|
fr_by_user.setdefault(uid, []).append(int(fr))
|
||||||
for uid, frs in fr_by_user.items():
|
for uid, frs in fr_by_user.items():
|
||||||
acc[uid]["fr_p50_ms"] = _percentile(frs, 1, 2)
|
acc[uid]["fr_p50_ms"] = percentile(frs, 1, 2)
|
||||||
acc[uid]["fr_p90_ms"] = _percentile(frs, 9, 10)
|
acc[uid]["fr_p90_ms"] = percentile(frs, 9, 10)
|
||||||
# 2h/24h 计数与分位同源同窗——回复率分子分母口径一致
|
# 2h/24h 计数与分位同源同窗——回复率分子分母口径一致
|
||||||
acc[uid]["reply_2h_count"] = sum(1 for fr in frs if fr <= 7_200_000)
|
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)
|
acc[uid]["reply_24h_count"] = sum(1 for fr in frs if fr <= 86_400_000)
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ def validate_config(config: dict) -> list[str]:
|
|||||||
- grade_bounds 须含 A/B/C/D 且严格降序
|
- grade_bounds 须含 A/B/C/D 且严格降序
|
||||||
- 指标 code 跨维度不得重复:metrics_detail 为扁平 dict,
|
- 指标 code 跨维度不得重复:metrics_detail 为扁平 dict,
|
||||||
同名 code 会相互覆盖(后写胜出)
|
同名 code 会相互覆盖(后写胜出)
|
||||||
|
- 源指标名跨维度不得重复:employees 行 comparisons 按源名建键
|
||||||
|
(api_assembly._code_to_source),两个 code 指向同一源名会静默覆盖
|
||||||
"""
|
"""
|
||||||
problems: list[str] = []
|
problems: list[str] = []
|
||||||
dims = config.get("dimensions", {})
|
dims = config.get("dimensions", {})
|
||||||
@@ -125,6 +127,7 @@ def validate_config(config: dict) -> list[str]:
|
|||||||
f"(当前 A={gb['A']} B={gb['B']} C={gb['C']} D={gb['D']})"
|
f"(当前 A={gb['A']} B={gb['B']} C={gb['C']} D={gb['D']})"
|
||||||
)
|
)
|
||||||
seen_codes: dict[str, str] = {}
|
seen_codes: dict[str, str] = {}
|
||||||
|
seen_sources: dict[str, str] = {}
|
||||||
for key, dim in dims.items():
|
for key, dim in dims.items():
|
||||||
if dim.get("status") == "pending_data":
|
if dim.get("status") == "pending_data":
|
||||||
continue
|
continue
|
||||||
@@ -133,6 +136,13 @@ def validate_config(config: dict) -> list[str]:
|
|||||||
problems.append(f"指标 code 重复定义于多维度: {code}")
|
problems.append(f"指标 code 重复定义于多维度: {code}")
|
||||||
seen_codes[code] = key
|
seen_codes[code] = key
|
||||||
src = spec.get("metric", code)
|
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:
|
if src not in METRIC_DEFINITIONS:
|
||||||
problems.append(f"{key}.{code} 引用未定义指标 {src}")
|
problems.append(f"{key}.{code} 引用未定义指标 {src}")
|
||||||
mtype = spec.get("type")
|
mtype = spec.get("type")
|
||||||
|
|||||||
@@ -803,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
|
||||||
|
|
||||||
@@ -812,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:
|
||||||
@@ -820,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":
|
||||||
|
|||||||
@@ -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,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")
|
||||||
@@ -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__":
|
||||||
|
|||||||
@@ -59,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 ==="
|
||||||
@@ -84,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"),
|
||||||
|
|||||||
@@ -514,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):
|
||||||
@@ -527,6 +570,11 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
|
|||||||
|
|
||||||
Order: pairs with no messages yet FIRST (new coverage survives an
|
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
|
||||||
|
|
||||||
@@ -536,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 = (
|
||||||
@@ -554,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):
|
||||||
@@ -576,13 +649,15 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
|
|||||||
if not owner or not talker or str(talker).endswith("@chatroom"):
|
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
|
||||||
|
|
||||||
@@ -635,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);请求带
|
||||||
@@ -648,7 +723,15 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_hi
|
|||||||
from app.db.base import async_session_maker
|
from app.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")
|
||||||
|
|
||||||
@@ -737,6 +820,9 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_hi
|
|||||||
ts = int(str(ts_raw)) if ts_raw else None
|
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",
|
||||||
@@ -786,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:
|
||||||
@@ -853,7 +941,7 @@ def _normalize_group_message(row: dict, owner_id: str, room_id: str) -> dict | N
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def scrape_group_messages(cookie_str: str, max_groups: int = 500, full_history: bool = False):
|
async def scrape_group_messages(cookie_str: str, max_groups: int = 500, full_history: bool = False, active_window_hours: int | None = None):
|
||||||
"""Scrape WeChat GROUP chat messages via browser fetch POST /wechatweb/msg/chats.
|
"""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
|
||||||
@@ -868,12 +956,17 @@ async def scrape_group_messages(cookie_str: str, max_groups: int = 500, full_his
|
|||||||
from app.storage.repos import upsert_messages
|
from 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()
|
||||||
@@ -934,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:
|
||||||
@@ -1683,9 +1779,12 @@ def _normalize_chatroom_stat_row(row: dict, owner_wxid: str) -> dict:
|
|||||||
return rec
|
return rec
|
||||||
|
|
||||||
|
|
||||||
async def _chatroom_rows(page, dept: str, start_str: str, end_str: str, max_pages: int = 100):
|
async def _chatroom_rows(
|
||||||
"""翻完 getChatroomList 一个窗,返回全部行(list)。"""
|
page, dept: str, start_str: str, end_str: str, max_pages: int = 100
|
||||||
|
) -> tuple[list[dict], int]:
|
||||||
|
"""翻完 getChatroomList 一个窗,返回 (全部行, 云客声明 total)。"""
|
||||||
rows_all: list[dict] = []
|
rows_all: list[dict] = []
|
||||||
|
declared = 0
|
||||||
page_num = 1
|
page_num = 1
|
||||||
while page_num <= max_pages:
|
while page_num <= max_pages:
|
||||||
body = await _browser_fetch(
|
body = await _browser_fetch(
|
||||||
@@ -1698,13 +1797,13 @@ async def _chatroom_rows(page, dept: str, start_str: str, end_str: str, max_page
|
|||||||
data = body.get("data") or {}
|
data = body.get("data") or {}
|
||||||
rows = data.get("overviews") or []
|
rows = data.get("overviews") or []
|
||||||
rows_all.extend(rows)
|
rows_all.extend(rows)
|
||||||
total = data.get("totalCount") or 0
|
declared = data.get("totalCount") or declared
|
||||||
logger.info(f" chatroom-list page {page_num}: {len(rows)} rows (total {total})")
|
logger.info(f" chatroom-list page {page_num}: {len(rows)} rows (total {declared})")
|
||||||
if not rows or page_num * 50 >= (total or 0):
|
if not rows or page_num * 50 >= (declared or 0):
|
||||||
break
|
break
|
||||||
page_num += 1
|
page_num += 1
|
||||||
await asyncio.sleep(1.2)
|
await asyncio.sleep(1.2)
|
||||||
return rows_all
|
return rows_all, declared or 0
|
||||||
|
|
||||||
|
|
||||||
async def _owner_alias_map(conn):
|
async def _owner_alias_map(conn):
|
||||||
@@ -1755,9 +1854,11 @@ async def scrape_chatroom_stats(cookie_str: str, day: str | None = None):
|
|||||||
|
|
||||||
group_recs: list[dict] = []
|
group_recs: list[dict] = []
|
||||||
stats_recs: list[dict] = []
|
stats_recs: list[dict] = []
|
||||||
|
declared = 0
|
||||||
async with PageScraper(cookie_str) as scraper:
|
async with PageScraper(cookie_str) as scraper:
|
||||||
page = await _open_fetch_page(scraper)
|
page = await _open_fetch_page(scraper)
|
||||||
for row in await _chatroom_rows(page, dept, start_str, end_str):
|
rows, declared = await _chatroom_rows(page, dept, start_str, end_str)
|
||||||
|
for row in rows:
|
||||||
rid = row.get("chatRoomId")
|
rid = row.get("chatRoomId")
|
||||||
if not rid:
|
if not rid:
|
||||||
continue
|
continue
|
||||||
@@ -1781,9 +1882,41 @@ async def scrape_chatroom_stats(cookie_str: str, day: str | None = None):
|
|||||||
await _upsert_all(upsert_chat_group, group_recs)
|
await _upsert_all(upsert_chat_group, group_recs)
|
||||||
if stats_recs:
|
if stats_recs:
|
||||||
await _upsert_all(upsert_chat_group_member_stats, 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(
|
logger.info(
|
||||||
f"DONE: chatroom-stats day={day or '全窗'} groups={len(group_recs)} "
|
f"DONE: chatroom-stats day={day or '全窗'} groups={len(group_recs)} "
|
||||||
f"stats_rows={len(stats_recs)}"
|
f"stats_rows={len(stats_recs)} pruned={pruned}"
|
||||||
)
|
)
|
||||||
return len(group_recs), len(stats_recs)
|
return len(group_recs), len(stats_recs)
|
||||||
|
|
||||||
@@ -2143,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}).
|
||||||
|
|
||||||
@@ -2158,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")
|
||||||
|
|
||||||
@@ -2204,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
|
||||||
# 增量触底停:翻到已同步区
|
# 增量触底停:翻到已同步区
|
||||||
@@ -2235,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 阶段首条即崩的根因)
|
||||||
@@ -2281,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 同套路)。
|
||||||
|
|
||||||
@@ -2295,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()
|
||||||
@@ -2343,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}")
|
||||||
@@ -2473,6 +2631,8 @@ 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(落按天进出表);缺省=全窗档案基线")
|
parser.add_argument("--day", type=str, default=None, help="chatroom-stats:单日窗 YYYY-MM-DD(落按天进出表);缺省=全窗档案基线")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -2496,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":
|
||||||
@@ -2514,9 +2674,9 @@ async def _dispatch_entity(args, cookie: str) -> None:
|
|||||||
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":
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -300,6 +300,16 @@ def test_validate_config_cross_dimension_code_unique():
|
|||||||
assert any("active_share" in p and "重复" in p for p in validate_config(dup))
|
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():
|
def test_validate_config_default_config_clean():
|
||||||
"""默认配置自身必须全绿(空问题列表)。"""
|
"""默认配置自身必须全绿(空问题列表)。"""
|
||||||
assert validate_config(DEFAULT_SCORE_CONFIG) == []
|
assert validate_config(DEFAULT_SCORE_CONFIG) == []
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""_analytics_tick:滚动刷新 + 增量扫描 + 异常吞掉(数据分析板块 v1, T11)。
|
"""_analytics_tick:滚动刷新 + 增量扫描 + 管理域滚动段 + 异常吞掉(v1 T11;批次F F1)。
|
||||||
|
|
||||||
行为测试是底线——start() 的 job 注册断言在 test_sync_scheduler.py 的
|
行为测试是底线——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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 })
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -344,8 +351,11 @@ onMounted(() => {
|
|||||||
<div class="yk-chat__body">
|
<div class="yk-chat__body">
|
||||||
<!-- 侧栏常驻(含群聊路由):好友/群列表随时可切,对齐云客聊天窗体验。
|
<!-- 侧栏常驻(含群聊路由):好友/群列表随时可切,对齐云客聊天窗体验。
|
||||||
旧版 v-if="!isGroup" 在群路由下藏侧栏,点群后「好友列表/群列表」
|
旧版 v-if="!isGroup" 在群路由下藏侧栏,点群后「好友列表/群列表」
|
||||||
消失(2026-09-09 用户报);onSelectSession 本就双类型导航。 -->
|
消失(2026-09-09 用户报);onSelectSession 本就双类型导航。
|
||||||
|
bare 模式(群统计「查看详情」入口)例外——纯聊天记录窗无侧栏,
|
||||||
|
对齐云客群详情页(2026-09-10 用户反馈)。 -->
|
||||||
<ChatSidebar
|
<ChatSidebar
|
||||||
|
v-if="!isBare"
|
||||||
:channel="channelTyped"
|
:channel="channelTyped"
|
||||||
:owner-id="props.ownerId"
|
:owner-id="props.ownerId"
|
||||||
:active-type="activeType"
|
:active-type="activeType"
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ 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 { getAccounts, 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 { Account, 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'
|
||||||
@@ -190,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 守卫。 */
|
||||||
@@ -199,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)
|
||||||
|
|
||||||
@@ -267,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) : [],
|
||||||
@@ -444,11 +450,9 @@ onMounted(() => {
|
|||||||
<el-table-column label="沟通消息数" width="105" align="right">
|
<el-table-column label="沟通消息数" width="105" align="right">
|
||||||
<template #default="{ row }">{{ row.window_msg_count ?? '-' }}</template>
|
<template #default="{ row }">{{ row.window_msg_count ?? '-' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="220" fixed="right" align="center">
|
<el-table-column label="操作" width="110" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" size="small" @click.stop="openDrawer(row)">查看详情</el-button>
|
<el-button link type="primary" size="small" @click.stop="viewChat(row)">查看详情</el-button>
|
||||||
<el-button link type="primary" size="small" @click.stop="viewChat(row)">查看聊天记录</el-button>
|
|
||||||
<el-button link type="primary" size="small" @click.stop="exportGroupHtml(row)">导出HTML</el-button>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -571,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">
|
||||||
@@ -619,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user