Compare commits
50 Commits
5bfcebf20c
..
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 | |||
| b0ebe8a15a | |||
| 03c08881b6 | |||
| 07c9d091a1 | |||
| c9f8c844d6 | |||
| b51b32a14b | |||
| 729710da63 | |||
| 8b555a5ad4 | |||
| afc71e61ef | |||
| d523813435 | |||
| 7758a3351f | |||
| 639edf56a6 | |||
| 0fce21aaf7 | |||
| b2e8edacc2 | |||
| e725c7032a | |||
| ad3beb7625 |
@@ -228,6 +228,32 @@ alembic 链自当前线上版本起新增 3 支迁移:`a7d3e9f1c2b4` msg_daily_s
|
||||
幂等自动补种,无需手插;后续加新词默认只扫增量,要扫历史消息需重跑
|
||||
`--full`。
|
||||
|
||||
### 管理后台批次A附加步骤(一次性,2026-09-09)
|
||||
|
||||
迁移链新增 `b7c8d9e0f1a2`(经营管理域六表:first_reply_fact /
|
||||
employee_metric_daily / employee_call_daily / employee_score_config /
|
||||
employee_score_snapshot / management_followup)。**服务器 backend 容器
|
||||
不会自动跑 alembic**,须手动:
|
||||
|
||||
```bash
|
||||
sudo docker compose -f docker-compose.server.yml exec backend alembic upgrade head
|
||||
```
|
||||
|
||||
部署带该迁移后在**夜间低峰**跑两条一次性回填(幂等;本地实测耗时:
|
||||
fact 72s / 宽表+通话 71s,均分钟级):
|
||||
|
||||
```bash
|
||||
sudo docker compose -f docker-compose.server.yml exec backend python -m app.sync.cli first-reply-facts
|
||||
sudo docker compose -f docker-compose.server.yml exec backend python -m app.sync.cli employee-metrics
|
||||
```
|
||||
|
||||
- `first-reply-facts`:跨日精确首响事实表(本地实测 11,020 会话 → 6.8 万行)。
|
||||
- `employee-metrics`:员工指标宽表 + 通话日小表(本地实测 12,371 宽表行 +
|
||||
1,163 通话行);**必须在 first-reply-facts 之后跑**——fact 表是后续评分/
|
||||
下钻的数据源,须先填充(宽表自身从 message 表经同一算法重算串类列,
|
||||
不读 fact 表,先跑宽表不会出 0,但 fact 缺失会阻塞后续能力)。
|
||||
- 两命令均为按日 delete+insert 幂等重建,复跑结果一致,中断重跑无副作用。
|
||||
|
||||
## 8. 本地收尾(部署验证通过后)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -170,3 +170,242 @@
|
||||
| 6 | 群消息占总消息量 64%(微信侧 69%) | Task 3/6(friend 过滤必须,佐证 spec 口径) |
|
||||
| 7 | call_number↔talker_id 命中率 voice_call 95.0% / wa_voice_call 99.3% | Task 5(call_followup_count 高置信,不降级) |
|
||||
| 8 | staff 41 行无幽灵账号;`stat_daily_staff` 仅 40 名员工有行 | Task 5/6(known_user_ids 过滤零损耗) |
|
||||
|
||||
---
|
||||
|
||||
# 批次A回填实测与对账(Task 8,2026-09-09)
|
||||
|
||||
- **环境**:本地 Docker MySQL(`yunke-mysql-1`),数据快照同上(stat/msg 覆盖 20251024~20260907)。
|
||||
- **方式**:Task 7 真机全量回填后本任务只读对账抽查(未重跑回填);对账前全库同源校验复验一次,确认库状态即 Task 7 收尾状态。
|
||||
|
||||
## 1. 回填实测汇总(Task 7 真机数字,Task 8 复验)
|
||||
|
||||
| 项目 | 实测 |
|
||||
|---|---|
|
||||
| `first-reply-facts` | 11,020 会话 → **68,476** fact 行,**72s**(冷) |
|
||||
| `employee-metrics` | **12,371** 宽表行(day×channel×user)+ **1,163** 通话行(**314** 天并集),**71s** 冷 / 36s 热 |
|
||||
| 幂等复跑 | 两命令复跑结果一致(delete+insert 语义,无重复无漂移) |
|
||||
| 全库同源 | `SUM(employee_metric_daily.threads_answered)` = **68,476** ≡ fact 行数(Task 8 复验通过) |
|
||||
| 孤儿检查 | 宽表/通话表 user_id 不在 staff = **0**;fact 账号不在对应渠道账号表 = **0**(Task 8 复验通过) |
|
||||
| 通话日并集修复 | `employee_call_daily` 覆盖 **314** 天(修复前 306):8 个消息空窗日找回 27 通话 / 13 接通 |
|
||||
|
||||
## 2. 对账抽查(2 员工 × 3 日期 × 三组)
|
||||
|
||||
员工取自本文回测名单:**王云青** `369D257EEC9A4357935FDE19DDD99CAA`(#1,高负载+群运营,混合口径差异最大场景;1 微信 + 2 WA 账号)、**田瑞志** `9C0B195A2C234C829FF507E011B4B460`(#2,WA 为主;1 微信 + 2 WA 账号)。日期 20260815 / 20260801 / 20260601(近/中/远)。跨渠道员工按 channel 分别对账(①②组逐渠道逐日须一致)。
|
||||
|
||||
判定标准:**①组(消息计数列 ↔ msg_daily_stats friend 聚合)与 ②组(串类列 ↔ fact 行数)必须逐日一致;③组(宽表串类 cust_threads ↔ 日切 stat_daily_staff.cust_threads)允许差异,差值=跨夜串归属效应。**
|
||||
|
||||
### 王云青
|
||||
|
||||
| 渠道 | 日期 | ① mds sent/recv | ① 宽表 sent/recv | ① 判定 | ② fact 行 | ② 宽表 answered | ② 判定 | ③ 宽表 threads | ③ 日切 threads | ③ 差值 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| wechat | 20260601 | 186 / 177 | 186 / 177 | ✅ | 76 | 76 | ✅ | 76 | 121 | 45 |
|
||||
| wechat | 20260801 | 0 / 38 | 0 / 38 | ✅ | 0 | 0 | ✅ | 0 | 16 | 16 |
|
||||
| wechat | 20260815 | 0 / 23 | 0 / 23 | ✅ | 0 | 0 | ✅ | 0 | 17 | 17 |
|
||||
| whatsapp | 20260601 | (无行) | 0 / 0 | ✅ | (无行) | 0 | ✅ | 0 | (无行) | — |
|
||||
| whatsapp | 20260801 | 0 / 1 | 0 / 1 | ✅ | 1 | 1 | ✅ | 1 | 1 | 0 |
|
||||
| whatsapp | 20260815 | 0 / 4 | 0 / 4 | ✅ | 1 | 1 | ✅ | 1 | 2 | 1 |
|
||||
|
||||
### 田瑞志
|
||||
|
||||
| 渠道 | 日期 | ① mds sent/recv | ① 宽表 sent/recv | ① 判定 | ② fact 行 | ② 宽表 answered | ② 判定 | ③ 宽表 threads | ③ 日切 threads | ③ 差值 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| wechat | 三日期均 | (无行) | 0 / 0 | ✅ | (无行) | 0 | ✅ | 0 | (无行) | — |
|
||||
| whatsapp | 20260601 | 1 / 1 | 1 / 1 | ✅ | 1 | 1 | ✅ | 1 | 1 | 0 |
|
||||
| whatsapp | 20260801 | (无行) | 0 / 0 | ✅ | (无行) | 0 | ✅ | 0 | 2 | 2 |
|
||||
| whatsapp | 20260815 | (无行) | 0 / 0 | ✅ | (无行) | 0 | ✅ | 0 | (无行) | — |
|
||||
|
||||
注:「无行」= 源表该 (渠道, 日) 无该员工数据,宽表记 0/0——一致。20260601 无 WA 数据未换日期:田瑞志 whatsapp 该日恰好有 1 条收发(对账有效),王云青 whatsapp 全期仅 369 条消息、无行属真实分布。
|
||||
|
||||
**结论:①②组 12/12 逐日全部一致(含渠道拆分);③组宽表全部 ≤ 日切值。**
|
||||
|
||||
## 3. 跨夜串差值观察(③组差异解释)
|
||||
|
||||
- **归属语义**:宽表串按「串末条客户消息所在上海自然日」归属(`first_reply_facts.compute_conversation`);日切表按「当日有客户消息的 peer 数」计。串末条必在归属日当天 ⇒ 结构上保证 ③组逐日 宽表 ≤ 日切,差值=归属到其他日终了的跨夜/多日串。
|
||||
- **差值形态**:王云青 wechat 三日差 45/16/17——该员工深夜消息多(20260801/15 发送 0、仅收 38/23 条,客户消息跨零点后串归属顺延);whatsapp 差 0/1。田瑞志 whatsapp 20260801 差 2(2 个 peer 当日来消息但串末条在次日)。
|
||||
- **串未丢失验证**:王云青 wechat 全期 宽表 12,055 threads(11,930 answered,314 天)vs 日切 20,981(14,647 answered,312 天)——串模型把多日串折叠为 1(比值 57.4%),且抽查 20260814~31 逐日,日切 17 线程的 20260815 对应串终落在真实结束日(0819=8、0821=10、0828=9),无凭空丢失。
|
||||
- 另:宽表 `cust_threads ≥ threads_answered`(如王云青 wechat 20260602 = 54/52)——未闭合尾串计入分母不产 fact 样本,与算法注释一致。
|
||||
|
||||
## 4. 批次A验收清单(Task 8 收尾核对)
|
||||
|
||||
| # | 项 | 状态 | 依据 |
|
||||
|---|---|---|---|
|
||||
| 1 | 6 张表本地存在,migration 幂等 + downgrade 干净 | ✅ | Task 2(迁移 `b7c8d9e0f1a2`,alembic head 实测一致;表实测在库:first_reply_fact / employee_metric_daily / employee_call_daily / employee_score_config / employee_score_snapshot / management_followup) |
|
||||
| 2 | first-reply-facts 全量回填成功且复跑幂等 | ✅ | Task 7(本文 §1) |
|
||||
| 3 | employee-metrics 全量回填成功且复跑幂等 | ✅ | Task 7(本文 §1) |
|
||||
| 4 | 跨夜串判别测试通过 | ✅ | Task 3(test_first_reply_facts.py 恰 12 例,Task 8 复跑全绿;含跨夜/多日串/同毫秒并列) |
|
||||
| 5 | friend-only 口径判别测试通过 | ✅ | Task 6(test_all_friend_pairs_only_friend + 宽表 friend 过滤判别例) |
|
||||
| 6 | 同源判别测试通过 + 全库 SUM 验证 | ✅ | Task 3/6;全库 68,476 ≡ 68,476(本文 §1 复验) |
|
||||
| 7 | 幽灵员工过滤测试通过 | ✅ | Task 5/6;孤儿=0(本文 §1 复验) |
|
||||
| 8 | 对账抽查 ①②一致、③差异可解释 | ✅ | 本文 §2/§3 |
|
||||
| 9 | 全量测试套件绿 | ✅ | **653 passed in 62s**(Task 8 本机实跑,2026-09-09) |
|
||||
| 10 | 核实文档完整 | ✅ | 本文补齐 |
|
||||
| 11 | 写钩子零改动 | ✅ | 批次A全部 17 个 commits(27f91e5→0fce21a)逐一核查,无一触碰 storage/summary.py、storage/repos.py(repos.py 最近一次改动 d428953 属 customer_account_link 工作线,先于批次A) |
|
||||
|
||||
---
|
||||
|
||||
# 批次B冒烟(Task B5,2026-09-09)
|
||||
|
||||
经营管理后台六端点真机冒烟 + 性能初测。回测员工王云青 `369D257EEC9A4357935FDE19DDD99CAA`(#1 高负载+群运营),窗口 30 天 `20260810~20260908`。
|
||||
|
||||
## 0. 环境与启动方式
|
||||
|
||||
- **基线**:`pytest tests/ -q` → **721 passed in 60.9s**(批次B收尾后全绿)。
|
||||
- **后端启动**:docker 无 `yunke-backend/frontend-1` 容器;冒烟前 8000 端口空闲(连接拒绝,无常驻进程)。本任务自起:`yunke-server/.venv/Scripts/python.exe -m uvicorn app.api.main:app --port 8000`(无 `--reload`),`GET /api/health` → `{"status":"ok"}`;`openapi.json` 含 management 路由(grep 命中)确认新代码已加载。
|
||||
- **鉴权模式**:`.env` 中 `API_TOKEN=`(空值)→ 鉴权中间件 no-op,**免鉴权直接 curl**(无 Authorization 头)。
|
||||
- **进程处置**:uvicorn 留跑供用户查看,Git Bash PID **2423**(`kill 2423` 关闭;或任务管理器结束对应 python.exe)。
|
||||
|
||||
## 1. 六端点结果(HTTP 码 × 耗时 × 大小)
|
||||
|
||||
首次访问含冷启动(连接池/查询编译/InnoDB 冷缓存),dashboard 另测暖态三次:
|
||||
|
||||
| 端点 | HTTP | 首次耗时 | 暖态耗时 | 大小 | 判定 |
|
||||
|---|---|---|---|---|---|
|
||||
| `/api/management/dashboard` | 200 | 12.03s | **2.92 / 2.39 / 2.18s** | 22,309B | ⚠️ 超 1.5s 目标(慢段定位见 §2) |
|
||||
| `/api/management/employees?page_size=50` | 200 | 0.34s | 0.30 / 0.31s | 58,865B | ✅ ≤1.5s |
|
||||
| `/api/management/employees/{王云青}` | 200 | 0.40s | — | 4,739B | ✅ |
|
||||
| `…/metrics?section=service` | 200 | 0.40s | — | 4,655B | ✅ |
|
||||
| `…/drilldown?metric_code=first_reply_p90` | 200 | 0.016s | — | 5,268B | ✅ |
|
||||
| `/api/management/metric-definitions` | 200 | 0.005s | — | 4,081B | ✅ |
|
||||
|
||||
## 2. dashboard 慢段定位(分段实测,不做优化——决策归批次F)
|
||||
|
||||
用 app 同一 session 复放 dashboard 组装流水线逐段计时(合计 2.685s ≈ 暖态端点耗时):
|
||||
|
||||
| 段 | 耗时 |
|
||||
|---|---|
|
||||
| load_score_config | 0.044s |
|
||||
| assemble_employees(评分+异常,2 窗聚合) | 0.262s |
|
||||
| wide_daily_rows / call_daily_rows | 0.007s / 0.002s |
|
||||
| service_daily_trend | 0.032s |
|
||||
| data_as_of | 0.009s |
|
||||
| **action_counts(unreplied+churn+sensitive)** | **2.328s** |
|
||||
|
||||
action_counts 内部:`unreplied(hours=2)` **2.38s**;`churn(real,30d)` 0.12s;sensitive count 0.005s。unreplied 再拆:候选窗口函数查询(row_number over 67,116 行 friend 分区 → 3,123 候选)仅 **0.10~0.16s**;>500 候选走「仅本页决赛圈」分支无 N+1,但**单条尾部消息点查耗时 1.7~1.9s**。EXPLAIN 实证:`channel=None`(全平台)时无法使用 channel 打头的全部索引(`ix_message_channel_owner_talker_time(channel,owner,talker,timestamp)` 等),优化器退化到 `ix_message_room`(`room_id IS NULL/''`)→ **扫 378,202 行 + filesort**(message 表 96.4 万行)。
|
||||
|
||||
优化方向(供批次F选择):① 查询改写按渠道 UNION/`channel IN (…)` 绑定前缀走现有索引;② 补 `(owner_account_id, talker_id, timestamp)` 无 channel 前缀索引;③ dashboard 对 action_counts 做短 TTL 缓存。除 unreplied 外全链路 <0.5s,达标无压力。
|
||||
|
||||
## 3. 合理性抽查
|
||||
|
||||
- **employees 列表**:`total=41`/rows=41(=本文① staff 总数 ✓)。**王云青行与宽表 SQL 聚合逐字段全等**:msg_sent=111 / msg_recv=1003 / cust_threads=89 / threads_answered=57 / fr_sum_ms=698,140,844、days_with_data=29。数量级说明:「千级」预期对应全期全口径(全期 162,740 条消息/12,055 串,群聊为主);本窗 friend 口径 recv=1003 千级 ✓,sent=111 为真实分布——同窗全口径 recv≈2.0 万(stat_daily_staff 实测) vs friend 1,003,差额几乎全是群消息;cust_threads=89 是串模型多日串折叠(本文 §3 已验证折叠比 57.4%)+本窗 friend 活动偏低。
|
||||
- **dashboard kpis**:active_employees=**40**(~40 ✓,=本文① stat 有数员工数)、active_customers=2,195、msg_total=42,422、cust_threads=9,330、answered=8,460、call 263/接通 113;lifecycle new/cooling/dormant/reactivated=231/1,432/3,353/145。**anomalies=28(critical 3 / warning 25)**,首条 `reply_p90_worsened` critical:P90 324.98min vs 基线期 18.06min(+1700%,样本 62、涉及 51 客户)。dimension_summary:risk/action 全 41 pending(批次C接线,符合预期),service scored 27/insufficient 4/pending 10、均分 56.0。趋势 30 点,末日 20260908 全零——数据止于 20260907(本文快照范围),边界正确。
|
||||
- **detail(王云青)**:`score.trial=true` ✓、grade=**E**(total=39,scored 非 insufficient)、dimensions:risk/action **pending** ✓(批次C)、service 47.8 / customer 0.0 / workload 100;含 accounts/prev/baseline/anomalies。
|
||||
- **metrics section=service**:数值与宽表窗聚合一致 + trend 30 点 ✓。
|
||||
- **drilldown first_reply_p90**:total=**57**=该员工账号窗内 fact 行数(=threads_answered ✓)。首行 fr_ms=**498,242,238ms≈5.8 天**——超「分钟~小时」预期,但与源表全等(fact MAX 同值);窗内全库更极端值达 1,617,406,247ms≈18.7 天(thread_end 08-14 → reply 09-02,沉睡客户复活串),属真实数据形态非计算缺陷。该员工 p50=97,153ms(1.6min)/p90=1,651,758ms(27.5min)分钟级 ✓。极值是否 winsorize/封顶归批次F口径决策。
|
||||
- **metric-definitions**:19 项定义(msg_sent…call_followup_rate)+ score_config + caliber_notes,`config_source="db"`(评分配置 v1.0 种子生效 ✓)。
|
||||
|
||||
**结论:六端点全 200、数据与库逐字段可对;唯一性能缺口=dashboard 的 unreplied 行动计数(channel 无前缀索引可用),暖态 2.2~2.9s,其余端点全部达标。**
|
||||
|
||||
## 4. 批次B遗留观察项归档(终评补录,2026-09-09)
|
||||
|
||||
| # | 项 | 归档 |
|
||||
|---|---|---|
|
||||
| 1 | call_band 样本基数:band 类指标样本量取 active_friends(规格字面),WA 通话量上来后"通话多客户少"员工会被误标 insufficient——当前数据零影响(实测 calls≥1 且 af<10 仅 5 人且 calls≤5,怎么选都不足);归批次F回测前一行修正(sample_basis 字段或按源指标取样本) |
|
||||
| 2 | 配置源名唯一性:两个 code 指向同一源指标会使 employees comparisons 键静默覆盖——已在 validate_config 加源名唯一校验+测试(批次B终评闭环),默认配置六个源名互异不受影响 |
|
||||
| 3 | dashboard 性能(2.2~2.9s,unreplied channel=None 无前缀索引)与 fr_ms 天级极值 winsorize:见上文 §2/§3,决策归批次F |
|
||||
|
||||
---
|
||||
|
||||
# 批次D走查与验收(Task D4,2026-09-10)
|
||||
|
||||
批次D收尾:员工总览(b72e957)+ 员工详情(5776175)+ 驾驶舱环比/异常跳员工闭环(422a726,前置接口/时间预设 ddeda3f)。Playwright 真机七步全链路走查 + v1 §19.1 员工子集与批次D验收清单逐项核对。环境:后端 uvicorn 8000(免鉴权)+ 前端 vite dev 5173,视口 **1366px**。
|
||||
|
||||
## 0. 四门禁(全绿)
|
||||
|
||||
| 门禁 | 结果 |
|
||||
|---|---|
|
||||
| 后端 `pytest tests/ -q` | **723 passed** in 65.29s |
|
||||
| 前端 `npm run build` | ✓ built in 1.03s(exit 0) |
|
||||
| 前端 `npm run test`(vitest) | Test Files 2 passed / **Tests 23 passed** |
|
||||
|
||||
## 1. 七步动线走查记录(数字为真机实测,关键值已对 API 复核)
|
||||
|
||||
**① 驾驶舱默认近30天(20260812~20260910)**:三 KPI 卡环比箭头 ✓(客户端自 prev_kpis 按 (cur-prev)/prev 计算,API 复核数值吻合)——全渠道:活跃客户 **1,992人 ↓12.6%**(prev 2,278)/ 活跃员工 **40人 →0.0%**(40/40)/ 消息量 **37,742条 ↓21.0%**(prev 47,749);切微信:**979人 ↓25.3% / 40人 ↑2.6% / 27,949条 ↓29.6%**。方向与量级合理(等长紧邻前窗)。
|
||||
|
||||
**② 异常卡员工名 → 员工详情**:点微信异常榜员工名链接(落点须为 a.el-link;整行点击触发行下钻抽屉,属预期交互)→ 落地 `/management/employees/18B5EE6B1AD94B15A5CD1CDABCECAB96?channel=wechat&start=…&end=…`(驾驶舱当前筛选 query 随行 ✓)。
|
||||
|
||||
**③ 员工详情(朱晨曦,综合 21/E)**:五维条 **3 实 + 2 待数据**——合规风险/行动执行 pending 灰条占位,服务质量 0 分/客户经营 0 分/有效负载 100 分(API dimensions 逐项一致;行序随接口返回序,见遗留①)。四对比表(当前/上期/基线/中位)✓(cust_threads 62/114/107+中位)。服务质量页签双折线渲染(canvas 1074×300)→「查看超时明细」→ 首响P90 抽屉 **Total 11** → 点行进聊天:**17 条消息**,时间线与所点明细行吻合(客户 08-31 11:59:12 → 回复 17:24:11 ≈ 5小时24分)。
|
||||
|
||||
**④ 客户经营页签**:返回列表再进 → section=customer →「沉睡客户名单」抽屉 **Total 89** ✓。
|
||||
|
||||
**⑤ 员工总览对比**:勾选 2 人 → 对比抽屉并列两列指标 ✓(2-5 人为按钮启用门槛),关闭复原。
|
||||
|
||||
**⑥ 排序+翻页+CSV(微信渠道)**:综合分降序(服务端 sort)——第 1 页 **55→21**(林峰 55 领头),第 2 页 **11 / 1 / 其余「--」**(18 人 insufficient_sample **空值排尾** ✓)。导出 CSV:**3 行口径前言**(口径/窗口+渠道/试算版本)**+ 表头 + 41 行数据**,toast「已导出 41 条」。
|
||||
|
||||
**⑦ 控制台与持久化**:走查全程 console **0 errors**;修复遗留告警后(见 §3)全新加载 **0 warnings**。刷新保持 ✓——列表 view=service 刷新还原(Total 41)、详情返回后 section=service 还原。
|
||||
|
||||
**附**:9 档时间预设逐一真机核验:今日 0910~0910 / 昨日 0909 / 近7 0904~0910 / 近30 0812~0910 / 本周 0907~0910(周一周首 ✓)/ 上周 0831~0906 / 本月 0901~0910 / 上月 0801~0831 / 自定义 0901~0907。1366px 驾驶舱与员工总览双页截图目检:KPI 3×2 网阵、双趋势图、表格列均无塌陷/无横向溢出。
|
||||
|
||||
## 2. 验收清单核对
|
||||
|
||||
**v1 §19.1 员工子集(6 项)**
|
||||
|
||||
| # | 项 | 状态 | 依据 |
|
||||
|---|---|---|---|
|
||||
| 1 | 能查看所有员工多维状态 | ✅ | total=41(API=列表=CSV 行数三方一致);四视图(管理概览/工作负载/服务质量/客户经营)切换正常 |
|
||||
| 2 | 任一分数可展开为指标/公式/样本/事实 | ✅ | 表头「?」→ 指标口径抽屉(19 项定义+公式+样本);行/详情下钻按钮 → 事实明细 → 聊天原文(§1 ③) |
|
||||
| 3 | 四对比 | ✅ | 详情四对比表逐列可见(§1 ③:62/114/107/中位) |
|
||||
| 4 | 样本不足不评分 | ✅ | 18 人 insufficient_sample 综合分显「--」(§1 ⑥),CSV 同口径 |
|
||||
| 5 | 异常两次点击进事实 | ✅ | 异常卡员工名(第1击)→ 详情下钻按钮(第2击)→ 事实明细/聊天 |
|
||||
| 6 | 默认排序非总分/消息量榜 | ✅ | 默认 sort_by=anomaly_severity(异常严重度优先) |
|
||||
|
||||
**批次D验收清单(5 项)**
|
||||
|
||||
| # | 项 | 状态 | 依据 |
|
||||
|---|---|---|---|
|
||||
| 1 | 员工总览:41 人四视图/服务端排序分页/keyword/对比 2-5 人/CSV 含口径注释 | ✅ | §1 ⑤⑥;CSV 前言 3 行 |
|
||||
| 2 | 员工详情:五维条 3+2 占位/五页签懒加载/四对比/评分明细可展开/下钻进抽屉/账号跳聊天 | ✅ | §1 ③④;页签懒加载+loaded 缓存+section URL 还原 |
|
||||
| 3 | 驾驶舱:三 KPI 环比箭头/异常卡可跳员工详情 | ✅ | §1 ①②(闭环打通) |
|
||||
| 4 | 时间预设全套 9 档 | ✅ | §1 附 |
|
||||
| 5 | URL 全参数保持;四门禁绿;后端仅 dashboard 一处小改且测试补齐 | ✅ | §1 ⑦ + §0;后端改动=dashboard prev_kpis(D3,随测试提交) |
|
||||
|
||||
## 3. 走查发现并修复(随本任务提交)
|
||||
|
||||
| 问题 | 修复 |
|
||||
|---|---|
|
||||
| `AnomalyList.vue` 员工链接 `:underline="false"` 触发 Element Plus 废弃 API 告警——驾驶舱每次加载刷 **31 条** `[el-link] underline boolean deprecated`(异常榜每行一条) | 改 `underline="never"`(string API);修复后全新加载 0 errors 0 warnings |
|
||||
|
||||
## 4. 遗留观察项(不阻塞)
|
||||
|
||||
| # | 项 | 归档 |
|
||||
|---|---|---|
|
||||
| 1 | 五维条行序随接口返回序 | 合规风险/行动执行(待数据)排在三实维之前——非固定语义序;批次E 接线 risk/action 后自然消解,如需固定序前端按 dim 顺序数组排即可 |
|
||||
| 2 | 走查任务字面 Step6 措辞偏差 | 「切视图服务质量→点综合分列排序」按字面不可执行(综合分列仅管理概览视图有);实际动线=管理概览排序→切服务质量视图导出,已按实际动线完成走查 |
|
||||
| 3 | 空值显示 `-` 与 `--` 混用 | format.ts 的 formatMsDuration/formatRatio 按各自注释约定 null 显 `-`,员工总览本地数值/比率格式化显 `--`——同表跨列观感不一;纯观感项,批次E 顺手统一即可 |
|
||||
|
||||
---
|
||||
|
||||
# 批次F-调度接入验证(Task F3,2026-09-10)
|
||||
|
||||
F1(`_analytics_tick` 段2 管理域滚动,0dc5993)/ F2(夜间链管理步,2ac63e3)已提交后,本地真机直调 tick 验证 + 本地 scheduler 镜像重建。方法:临时脚本(已删)按 tests/test_scheduler_analytics_tick.py 的构造方式 `SyncScheduler(engine=create_async_engine(get_settings().db_url), session_maker=...)`,不 start、只 `await scheduler._analytics_tick()`,对六个上游函数挂计时包装(与测试 monkeypatch 同一延迟 import 打点位),连跑两次。
|
||||
|
||||
## 1. tick 两段实测(本地真机库,连跑两次)
|
||||
|
||||
| 段 | 内容 | 第1次(冷) | 第2次(暖) |
|
||||
|---|---|---|---|
|
||||
| 总计 | `_analytics_tick` 全程 | **2.78s** | **0.74s** |
|
||||
| 段1 | recompute_days(staff 滚动3天) | 0.86s | 0.15s |
|
||||
| 段1 | scanner.run_incremental(敏感词增量) | 0.14s | 0.02s |
|
||||
| 段2 | pairs_active_in_days(fact 活跃对发现) | 0.04s | 0.02s |
|
||||
| 段2 | recompute_pairs(fact 重算) | 0.00s | 0.00s |
|
||||
| 段2 | recompute_metric_days(宽表滚动3天) | 1.51s | 0.52s |
|
||||
| 段2 | recompute_call_days(通话滚动3天) | 0.22s | 0.02s |
|
||||
|
||||
(段值不含各自 session.commit;日志输出:`staff_days=0 scanned=10 hits=0` + `mgmt fact_pairs=0 wide_rows=168 call_rows=0`,两次一致。)
|
||||
|
||||
- **远低于 <60s 预算**。本地镜像已停同步 7 天,滚动窗(0908~0910)内无消息/通话活动——`staff_days=0`、`fact_pairs=0`、`call_rows=0` 属停同步后的数据真空而非缺陷;`wide_rows=168`= 3 天 × 56 行/天(39 员工,wechat 38 + whatsapp 18 行/天,双渠道员工两行),宽表对零活跃员工照常出零值行。段2 主成本=宽表重算,随员工数×天数扩展而非会话数,服务器侧活跃会话数百的形态下同量级。
|
||||
- **幂等 ✓**:三表行数跑前 fact=68,476 / wide=12,371 / calls=1,163 → 第1次后 68,476 / **12,539** / 1,163 → 第2次后 68,476 / 12,539 / 1,163。wide 的 +168 为首跑补齐:0908~0910 三天行在表内原本不存在(本地夜间链停摆 7 天没写过),第1次 DELETE+INSERT 补齐后第2次行数纹丝不动——滚动窗语义下幂等成立。
|
||||
|
||||
## 2. 本地 scheduler 容器重建(范围仅本地;服务器镜像 2026-09-09 已重建不碰)
|
||||
|
||||
- `docker compose up -d --build scheduler`:镜像 `yunke-scheduler:latest`(`ded96dfc9625`,2026-09-10 构建)重建成功,容器 recreate + 起来。
|
||||
- 冒烟:`docker exec yunke-scheduler-1 python -c "import app.sync.scheduler"` → ok;容器日志确认 **4 个 job 全注册**——3×`_safe_page_run`(full/messages/full-history)+ **`SyncScheduler._analytics_tick`(F1 新 job 已进夜间/4h 链)**,"Scheduler started" 正常。
|
||||
- **调度状态**:验证完即 `docker stop`——重建前容器本就 Exited 7 天(本地不跑调度,避免与服务器调度器双跑同刷云客触发风控),维持 stopped 现状;容器已绑新镜像,用户随时 `docker compose start scheduler` 即以新代码上线。mysql/redis 未动。
|
||||
|
||||
## 3. 已知边界(归档)
|
||||
|
||||
| # | 项 | 说明 |
|
||||
|---|---|---|
|
||||
| 1 | phone_call 未进页面同步链 | 通话小表(employee_call_daily)暂从既有静态源重生成——本地 call_rows=0 即此边界的体现;审计 P0-5 由并行会话领地,不在此处理 |
|
||||
| 2 | 补同步历史日期 | tick 只滚动当日+前2天;更早日期的修正由夜间全量(full_sync 汇总/管理步)覆盖语义兜底 |
|
||||
| 3 | 本地数据真空 | 本地镜像停摆 7 天,0908~0910 无消息/通话——fact/敏感词扫描的真实活跃量级须在服务器侧(夜间链已跑)观察 |
|
||||
|
||||
@@ -1627,6 +1627,7 @@ cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_employee_metric
|
||||
```python
|
||||
import math # (文件头补充)
|
||||
from bisect import bisect_right
|
||||
from itertools import accumulate
|
||||
|
||||
from app.analytics.lifecycle import classify
|
||||
from app.db.models import Customer, EmployeeMetricDaily, MsgDailyStats
|
||||
@@ -1657,8 +1658,11 @@ async def _lifecycle_daily(
|
||||
) -> dict[int, dict[str, dict[str, int]]]:
|
||||
"""逐日 {day: {user: {state: count}}}——customer 当前归属 + 互动日 sweep。
|
||||
|
||||
数据源:msg_daily_stats friend 行全历史(互动日/条数)。16k 客户 × 窗口
|
||||
天数 bisect,夜间全量约秒级;4h tick 只算当日+3 天。
|
||||
数据源:msg_daily_stats friend 行全历史(互动日/条数)。hist 为 ≤as_of
|
||||
的历史消息数(时点语义:与 last 同一 bisect 位取按日累积数组 cum[idx],
|
||||
as_of 之后的消息不虚增历史日的沉睡门槛——Task6 评审修正)。16k 客户 ×
|
||||
窗口天数 bisect+classify,夜间全量实测见批次A核实文档;4h tick 只算
|
||||
当日+3 天。
|
||||
"""
|
||||
rows = (
|
||||
await session.execute(
|
||||
@@ -1671,12 +1675,13 @@ async def _lifecycle_daily(
|
||||
)
|
||||
)
|
||||
).all()
|
||||
days_map: dict[str, set[int]] = {}
|
||||
cnt: dict[str, int] = {}
|
||||
# peer → {day: 消息条数};days_list 与累积数组 cum 对齐:
|
||||
# cum[i] = 该客户 ≤days_list[i] 的历史消息数(hist 时点语义)
|
||||
day_msgs: dict[str, dict[int, int]] = {}
|
||||
for peer, day, snd, rcv in rows:
|
||||
days_map.setdefault(peer, set()).add(day)
|
||||
cnt[peer] = cnt.get(peer, 0) + int(snd or 0) + int(rcv or 0)
|
||||
interaction = {p: sorted(ds) for p, ds in days_map.items()}
|
||||
dm = day_msgs.setdefault(peer, {})
|
||||
dm[day] = dm.get(day, 0) + int(snd or 0) + int(rcv or 0)
|
||||
interaction = {p: sorted(dm) for p, dm in day_msgs.items()}
|
||||
|
||||
custs = (
|
||||
await session.execute(
|
||||
@@ -1699,14 +1704,14 @@ async def _lifecycle_daily(
|
||||
if uid is None:
|
||||
continue
|
||||
first = days_list[0]
|
||||
hist = cnt.get(peer, 0)
|
||||
cum = list(accumulate(day_msgs[peer][d] for d in days_list))
|
||||
for d in days:
|
||||
idx = bisect_right(days_list, d) - 1
|
||||
if idx < 0:
|
||||
continue
|
||||
last = days_list[idx]
|
||||
prev = days_list[idx - 1] if idx > 0 else None
|
||||
state = classify(first, last, prev, d, hist)
|
||||
state = classify(first, last, prev, d, cum[idx])
|
||||
if state:
|
||||
out[d].setdefault(uid, {}).setdefault(state, 0)
|
||||
out[d][uid][state] += 1
|
||||
@@ -1832,6 +1837,7 @@ async def recompute_metric_days(
|
||||
)
|
||||
)
|
||||
written += 1
|
||||
await session.flush() # 防御性:防 autoflush=False 配置下 ORM 对象堆积(Task6 评审修正)
|
||||
return written
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
# 云客经营管理后台·批次B(评分/异常/管理API)实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在批次A数据底座之上交付只读管理 API——期聚合查询、五维试算评分、确定性异常规则、指标元数据、6 个 `/api/management/*` 端点,OpenAPI 可验证;前端(批次C)紧随其后。
|
||||
|
||||
**Architecture:** 新建 `app/management/` 业务层(metric_definitions / employee_metrics 期聚合 / scoring / anomalies),`app/api/routers/management.py` 只做参数校验与编排(照 analytics 惯例:SessionDep + Annotated 参数 + dict 返回)。**本期只做 6 个只读端点**(dashboard / employees / employee detail / metrics sections / drilldown 白名单 / metric-definitions);reviews 与 followups 写端点属批次E。依据:[v2 计划](./2026-09-08-yunke-management-console-implementation-v2.md) §1(决策记录)/§6(API 设计)/§8(Task 3-5);批次A已交付全部数据表与回填。
|
||||
|
||||
**Tech Stack:** 同批次A(FastAPI + SQLAlchemy async;测试内存 SQLite;生产 MySQL 8.4)。
|
||||
|
||||
---
|
||||
|
||||
## 执行者必读:批次A交付物与既定口径
|
||||
|
||||
1. **数据表就绪**(迁移 b7c8d9e0f1a2,本地已回填):`first_reply_fact`(68,476 行,PK 含 reply_msg_id)、`employee_metric_daily`(12,371 行,metric_version='v1',串类列与 fact 同源)、`employee_call_daily`(1,163 行)、`employee_score_config`(空,本批次 B0 种子)。
|
||||
2. **复用模块**:`app/storage/first_reply_facts.py`(fact 表直接查询)、`app/analytics/lifecycle.py`(阈值常量)、`app/analytics/metrics.py` 的 `action_counts`(dashboard 行动入口聚合)、`app/analytics/health.py` 的 `data_as_of`。
|
||||
3. **周期口径**:start/end 为 yyyymmdd 上海自然日(与 analytics custom scope 同校验:缺一 422、日历非法 422、start>end 422、跨度 ≤366 天);上期=等长紧邻前窗;个人基线=紧邻当前窗之前的 28 天窗。
|
||||
4. **评分默认(全部标「试算」,config 里可调)**:维度权重 客户经营30/服务25/行动执行20(pending_data)/有效负载15/合规风险10(pending_data);可用维度归一化 ×100/70;样本门槛 min=30 串/soft=10 串(周期内);等级 A≥90/B≥80/C≥70/D≥60/E。
|
||||
5. **鉴权**:app 级 `require_auth` 全局生效,management 路由无需额外处理;`GET` only。
|
||||
6. **测试命令**:`cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_management_api.py -v`;提交中文 conventional;master 直提(项目惯例);仓库有并行会话活动——`git status` 后只 add 自己的文件。
|
||||
7. **前端不做**(批次C另出计划);`schemas_management.py` 留批次E写端点用(analytics 惯例 dict 返回,本期一致);错误响应沿用 analytics 的 `HTTPException(422/404, message)` 风格(v1 §12.4 的稳定 error_code 为有意偏差,前端按 HTTP 状态码分支)。
|
||||
|
||||
## 文件地图
|
||||
|
||||
| 文件 | 职责 | 动作 |
|
||||
|---|---|---|
|
||||
| `yunke-server/app/management/__init__.py` | 包标记(空) | 新建 |
|
||||
| `yunke-server/app/management/metric_definitions.py` | 指标元数据注册表 + 默认评分配置 DEFAULT_SCORE_CONFIG | 新建 |
|
||||
| `yunke-server/app/management/employee_metrics.py` | 期聚合查询(宽表 SUM/fact 窗口函数分位/期末生命周期/通话/distinct 活跃/四对比) | 新建 |
|
||||
| `yunke-server/app/management/scoring.py` | 标准化/维度分/归一化试算/样本保护/等级 | 新建 |
|
||||
| `yunke-server/app/management/anomalies.py` | 4 条确定性规则 → v1 §5.4 异常卡片 | 新建 |
|
||||
| `yunke-server/app/sync/cli.py` | `score-config` 种子子命令 | 修改 |
|
||||
| `yunke-server/app/api/routers/management.py` | 6 只读端点 | 新建 |
|
||||
| `yunke-server/app/api/main.py` | include_router | 修改 |
|
||||
| `yunke-server/tests/test_management_scoring.py` 等 4 个测试文件 | 判别测试 | 新建 |
|
||||
| `docs/superpowers/plans/2026-09-08-management-batch-a-verification.md` | 批次B冒烟数据追加 | 修改(B5) |
|
||||
|
||||
---
|
||||
|
||||
### Task B0: 指标元数据 + 默认评分配置 + 种子 CLI
|
||||
|
||||
**Files:**
|
||||
- Create: `yunke-server/app/management/__init__.py`(空文件)
|
||||
- Create: `yunke-server/app/management/metric_definitions.py`
|
||||
- Modify: `yunke-server/app/sync/cli.py`(`score-config` 子命令)
|
||||
- Test: `yunke-server/tests/test_management_scoring.py`(本任务先写配置/种子部分)
|
||||
|
||||
- [ ] **Step 1: 写测试(先失败)**
|
||||
|
||||
`tests/test_management_scoring.py`:
|
||||
|
||||
```python
|
||||
"""管理域评分配置/引擎判别测试(批次B)。"""
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from app.db.models import EmployeeScoreConfig
|
||||
from app.management.metric_definitions import (
|
||||
DEFAULT_SCORE_CONFIG,
|
||||
METRIC_DEFINITIONS,
|
||||
validate_config,
|
||||
)
|
||||
|
||||
|
||||
def test_config_weights_sum_and_structure():
|
||||
dims = DEFAULT_SCORE_CONFIG["dimensions"]
|
||||
assert sum(d["weight"] for d in dims.values()) == 100
|
||||
# 一期 pending_data 的维度不参与试算
|
||||
available = [d for d in dims.values() if d.get("status") != "pending_data"]
|
||||
assert sum(d["weight"] for d in available) == 70 # 30+25+15
|
||||
assert dims["action"]["status"] == "pending_data"
|
||||
assert dims["risk"]["status"] == "pending_data"
|
||||
|
||||
|
||||
def test_config_normalization_declared():
|
||||
assert DEFAULT_SCORE_CONFIG["normalization"] == "available_only_rescale"
|
||||
|
||||
|
||||
def test_validate_config_rejects_bad_weight_sum():
|
||||
bad = json.loads(json.dumps(DEFAULT_SCORE_CONFIG)) # deep copy
|
||||
bad["dimensions"]["service"]["weight"] = 26 # 总和 101
|
||||
problems = validate_config(bad)
|
||||
assert any("权重" in p for p in problems)
|
||||
|
||||
|
||||
def test_metric_definitions_cover_scored_metrics():
|
||||
# 每个参与评分的 metric_code 都有元数据(名称/单位/方向)
|
||||
for dim in DEFAULT_SCORE_CONFIG["dimensions"].values():
|
||||
for code in dim.get("metrics", {}):
|
||||
md = METRIC_DEFINITIONS[code]
|
||||
assert md["name"] and md["unit"] and md["direction"] in (1, -1)
|
||||
|
||||
|
||||
async def test_seed_and_idempotent(make_session):
|
||||
from app.sync.cli import _seed_score_config # 纯会话函数(见 Step 3)
|
||||
s = make_session
|
||||
v1 = await _seed_score_config(s)
|
||||
await s.commit()
|
||||
assert v1 == "v1.0"
|
||||
rows = (await s.execute(sa.select(EmployeeScoreConfig))).scalars().all()
|
||||
assert len(rows) == 1 and rows[0].status == "active"
|
||||
# 幂等:已存在同版本返回 None 不新建
|
||||
assert await _seed_score_config(s) is None
|
||||
await s.commit()
|
||||
assert len((await s.execute(sa.select(EmployeeScoreConfig))).scalars().all()) == 1
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**(ModuleNotFoundError: app.management)
|
||||
|
||||
- [ ] **Step 3: 实现 metric_definitions.py**
|
||||
|
||||
```python
|
||||
"""管理域指标元数据与默认评分配置(批次B,v2 §6/§10)。
|
||||
|
||||
METRIC_DEFINITIONS 是 /api/management/metric-definitions 的单一数据源:
|
||||
名称/单位/方向/口径说明(含与现有看板的三方口径差异标注)。
|
||||
DEFAULT_SCORE_CONFIG 为 v1.0 试算起点——所有阈值标「试算」,调整走
|
||||
新配置版本(employee_score_config),不静默改数。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# direction: +1 越大越好 / -1 越小越好;caliber 注明与看板差异
|
||||
METRIC_DEFINITIONS: dict[str, dict] = {
|
||||
"msg_sent": {"name": "发出消息(1:1)", "unit": "条", "direction": 1,
|
||||
"caliber": "仅私聊;现有看板为私聊+群聊混合"},
|
||||
"msg_recv": {"name": "收到消息(1:1)", "unit": "条", "direction": 1,
|
||||
"caliber": "仅私聊"},
|
||||
"cust_threads": {"name": "客户消息串", "unit": "串", "direction": 1,
|
||||
"caliber": "跨日精确,按串末条归属日;看板为日切口径(多日串重复计)"},
|
||||
"threads_answered": {"name": "被回串数", "unit": "串", "direction": 1,
|
||||
"caliber": "≡first_reply_fact行数(同源)"},
|
||||
"fr_avg_ms": {"name": "平均首响", "unit": "ms", "direction": -1,
|
||||
"caliber": "fr_sum_ms/threads_answered"},
|
||||
"fr_p50_ms": {"name": "首响中位数", "unit": "ms", "direction": -1,
|
||||
"caliber": "周期内 fact 精确分位(窗口函数)"},
|
||||
"fr_p90_ms": {"name": "首响P90", "unit": "ms", "direction": -1,
|
||||
"caliber": "周期内 fact 精确分位;日切口径会丢跨夜长尾"},
|
||||
"reply_2h_rate": {"name": "2h回复率", "unit": "0-1", "direction": 1,
|
||||
"formula": "count(fr_ms<=2h)/cust_threads",
|
||||
"caliber": "分子分母同源(fact/宽表同一算法)"},
|
||||
"reply_24h_rate": {"name": "24h回复率", "unit": "0-1", "direction": 1,
|
||||
"formula": "count(fr_ms<=24h)/cust_threads"},
|
||||
"active_friends": {"name": "活跃客户(周期)", "unit": "人", "direction": 1,
|
||||
"caliber": "期内distinct客户(msg_daily_stats直查),非宽表日值求和"},
|
||||
"active_rooms": {"name": "活跃群(日均)", "unit": "个", "direction": 1},
|
||||
"new_customer_count": {"name": "新客(期末)", "unit": "人", "direction": 1,
|
||||
"caliber": "期末单日持有状态,非期内求和"},
|
||||
"cooling_customer_count": {"name": "降温客户(期末)", "unit": "人", "direction": -1,
|
||||
"caliber": "距今天数口径(7-29天);频次基线口径二期"},
|
||||
"dormant_customer_count": {"name": "沉睡客户(期末)", "unit": "人", "direction": -1,
|
||||
"caliber": "≥30天且历史≥5条"},
|
||||
"reactivated_customer_count": {"name": "回流客户(期内)", "unit": "人", "direction": 1,
|
||||
"caliber": "当日回流且前次间隔>30天,期内求和"},
|
||||
"call_count": {"name": "通话数", "unit": "通", "direction": 1,
|
||||
"caliber": "语音通话(微信+WA),无消息渠道维度"},
|
||||
"answered_rate": {"name": "接通率", "unit": "0-1", "direction": 1,
|
||||
"formula": "answered_call/call_count", "caliber": "接通=duration>0"},
|
||||
"call_followup_rate": {"name": "通话后跟进率", "unit": "0-1", "direction": 1,
|
||||
"formula": "call_followup_count/answered_call",
|
||||
"caliber": "接通后24h内任一账号有1:1消息(含客户发来)"},
|
||||
"active_share": {"name": "活跃客户占比", "unit": "0-1", "direction": 1,
|
||||
"formula": "期末active_friends/(期末active+cooling+dormant)",
|
||||
"caliber": "试算口径:期末单日存量"},
|
||||
}
|
||||
|
||||
# 阈值全部为试算起点(v2 §0 决议:全默认+试算标记);type 三种:
|
||||
# target : floor..target 线性 0..100(方向已含在 target>floor)
|
||||
# reverse : ≤target_ms 100 分,≥floor_ms 0 分,中间线性降
|
||||
# band : 区间内 80..100,低于下限不评分(None),超上限不再加分
|
||||
DEFAULT_SCORE_CONFIG: dict = {
|
||||
"version": "v1.0",
|
||||
"name": "一期试算(3维可用)",
|
||||
"normalization": "available_only_rescale", # 可用维度 100/Σweight 再 ×100
|
||||
"trial": True,
|
||||
"sample_guards": {"min_threads": 30, "soft_threads": 10, "min_days": 5},
|
||||
"grade_bounds": {"A": 90, "B": 80, "C": 70, "D": 60}, # 低于 D 线为 E
|
||||
"dimensions": {
|
||||
"customer": {"weight": 30, "name": "客户经营", "metrics": {
|
||||
"active_share": {"type": "target", "floor": 0.20, "target": 0.60},
|
||||
}},
|
||||
"service": {"weight": 25, "name": "服务质量", "metrics": {
|
||||
"reply_2h_rate": {"type": "target", "floor": 0.40, "target": 0.85},
|
||||
"reply_24h_rate": {"type": "target", "floor": 0.75, "target": 0.97},
|
||||
"fr_p90_ms": {"type": "reverse", "target_ms": 1_800_000,
|
||||
"floor_ms": 14_400_000}, # 30min..4h
|
||||
}},
|
||||
"action": {"weight": 20, "name": "行动执行",
|
||||
"status": "pending_data"}, # 批次E 接 followups/任务
|
||||
"workload": {"weight": 15, "name": "有效负载", "metrics": {
|
||||
"active_friends_band": {"type": "band", "metric": "active_friends",
|
||||
"low": 5, "high": 80,
|
||||
"basis": "period_distinct"},
|
||||
"call_band": {"type": "band", "metric": "call_count",
|
||||
"low": 1, "high": 200, "basis": "period_total",
|
||||
"note": "通话低不扣分仅提示;band 得分下限 80"},
|
||||
}},
|
||||
"risk": {"weight": 10, "name": "合规风险",
|
||||
"status": "pending_data"}, # 批次E/三期接风险事件
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def validate_config(config: dict) -> list[str]:
|
||||
"""配置校验:权重总和/结构/引用的 metric_code 有元数据。返回问题清单。"""
|
||||
problems: list[str] = []
|
||||
dims = config.get("dimensions", {})
|
||||
total = sum(d.get("weight", 0) for d in dims.values())
|
||||
if total != 100:
|
||||
problems.append(f"维度权重总和须=100,当前 {total}")
|
||||
for key, dim in dims.items():
|
||||
if dim.get("status") == "pending_data":
|
||||
continue
|
||||
for code, spec in dim.get("metrics", {}).items():
|
||||
src = spec.get("metric", code)
|
||||
if src not in METRIC_DEFINITIONS:
|
||||
problems.append(f"{key}.{code} 引用未定义指标 {src}")
|
||||
return problems
|
||||
```
|
||||
|
||||
- [ ] **Step 4: cli.py 加 `score-config` 子命令**
|
||||
|
||||
parser(sensitive-scan 与 first-reply-facts 之间任意位置):
|
||||
|
||||
```python
|
||||
p_score = sub.add_parser(
|
||||
"score-config",
|
||||
help="Seed the default score config (idempotent).",
|
||||
description=(
|
||||
"Insert DEFAULT_SCORE_CONFIG (v1.0, trial) into "
|
||||
"employee_score_config as active. Skips if the version "
|
||||
"already exists. Batch B."
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
命令函数与分发(照 _cmd_daily_stats 惯例):
|
||||
|
||||
```python
|
||||
async def _seed_score_config(session) -> str | None:
|
||||
"""种子默认评分配置;已存在同版本返回 None(幂等)。纯会话函数可测。"""
|
||||
from app.db.models import EmployeeScoreConfig
|
||||
from app.management.metric_definitions import (
|
||||
DEFAULT_SCORE_CONFIG,
|
||||
validate_config,
|
||||
)
|
||||
|
||||
problems = validate_config(DEFAULT_SCORE_CONFIG)
|
||||
if problems:
|
||||
raise ValueError(f"score config invalid: {problems}")
|
||||
exists = (
|
||||
await session.execute(
|
||||
sa.select(EmployeeScoreConfig.id).where(
|
||||
EmployeeScoreConfig.version == DEFAULT_SCORE_CONFIG["version"]
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists is not None:
|
||||
return None
|
||||
session.add(
|
||||
EmployeeScoreConfig(
|
||||
version=DEFAULT_SCORE_CONFIG["version"],
|
||||
name=DEFAULT_SCORE_CONFIG["name"],
|
||||
status="active",
|
||||
config_json=DEFAULT_SCORE_CONFIG,
|
||||
created_by="seed-cli",
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
return DEFAULT_SCORE_CONFIG["version"]
|
||||
|
||||
|
||||
async def _cmd_score_config(args: argparse.Namespace) -> int:
|
||||
settings = get_settings()
|
||||
engine, session_maker = await _init_db(settings.db_url)
|
||||
try:
|
||||
async with session_maker() as session:
|
||||
version = await _seed_score_config(session)
|
||||
await session.commit()
|
||||
print(f"[score-config] {'seeded ' + version if version else 'already present, skipped'}")
|
||||
return 0
|
||||
finally:
|
||||
await engine.dispose()
|
||||
```
|
||||
|
||||
(cli.py 头部补 `import sqlalchemy as sa` 若无。)
|
||||
|
||||
- [ ] **Step 5: 测试通过 + 提交**
|
||||
|
||||
```bash
|
||||
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_management_scoring.py -v # 5 passed
|
||||
cd yunke-server && .venv/Scripts/python.exe -m app.sync.cli score-config # 真机种子一次
|
||||
git add yunke-server/app/management/ yunke-server/app/sync/cli.py yunke-server/tests/test_management_scoring.py
|
||||
git commit -m "feat(management): 指标元数据+试算评分配置v1.0+种子CLI——权重校验/幂等落库"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task B1: 期聚合查询服务
|
||||
|
||||
**Files:**
|
||||
- Create: `yunke-server/app/management/employee_metrics.py`
|
||||
- Test: `yunke-server/tests/test_management_period_metrics.py`
|
||||
|
||||
核心函数(完整实现):
|
||||
|
||||
```python
|
||||
"""管理域期聚合查询(批次B)——评分/异常/employees API 的单一数据源。
|
||||
|
||||
窗口:days=[start_day..end_day] 闭区间 yyyymmdd;上期=等长紧邻前窗;
|
||||
个人基线=紧邻当前窗之前的 28 天。所有查询走 (day, ...) 索引,不扫消息表
|
||||
(除 distinct 活跃客户走 msg_daily_stats friend 行)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from collections import namedtuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import (
|
||||
EmployeeCallDaily,
|
||||
EmployeeMetricDaily,
|
||||
FirstReplyFact,
|
||||
MsgDailyStats,
|
||||
Staff,
|
||||
)
|
||||
from app.storage.staff_summary import account_map
|
||||
from app.storage.summary import day_bounds_ms
|
||||
|
||||
_SH = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
PeriodMetrics = namedtuple("PeriodMetrics", [
|
||||
"user_id", "days_with_data", "msg_sent", "msg_recv", "cust_threads",
|
||||
"threads_answered", "fr_sum_ms", "fr_p50_ms", "fr_p90_ms",
|
||||
"reply_2h_count", "reply_24h_count",
|
||||
"active_friends", # 期内 distinct(直查,非日值求和)
|
||||
"active_rooms_avg", # 日均
|
||||
"new_customer", "cooling_customer", "dormant_customer", # 期末单日存量
|
||||
"reactivated_customer", # 期内求和
|
||||
"call_count", "call_seconds", "answered_call", "call_followup",
|
||||
])
|
||||
|
||||
|
||||
def _day_range(start: int, end: int) -> list[int]:
|
||||
"""闭区间日键列表(纯 Python,上海自然日语义由调用方保证)。"""
|
||||
def _d(k): return dt.date(k // 10000, k // 100 % 100, k % 100)
|
||||
def _k(d): return d.year * 10000 + d.month * 100 + d.day
|
||||
out, cur = [], _d(start)
|
||||
while cur <= _d(end):
|
||||
out.append(_k(cur))
|
||||
cur += dt.timedelta(days=1)
|
||||
return out
|
||||
|
||||
|
||||
def prev_window(start: int, end: int) -> tuple[int, int]:
|
||||
"""等长紧邻前窗。"""
|
||||
n = len(_day_range(start, end))
|
||||
def _d(k): return dt.date(k // 10000, k // 100 % 100, k % 100)
|
||||
def _k(d): return d.year * 10000 + d.month * 100 + d.day
|
||||
pe = _d(start) - dt.timedelta(days=1)
|
||||
ps = pe - dt.timedelta(days=n - 1)
|
||||
return _k(ps), _k(pe)
|
||||
|
||||
|
||||
def baseline_window(start: int) -> tuple[int, int]:
|
||||
"""个人基线:紧邻当前窗之前的 28 天。"""
|
||||
def _d(k): return dt.date(k // 10000, k // 100 % 100, k % 100)
|
||||
def _k(d): return d.year * 10000 + d.month * 100 + d.day
|
||||
be = _d(start) - dt.timedelta(days=1)
|
||||
bs = be - dt.timedelta(days=27)
|
||||
return _k(bs), _k(be)
|
||||
```
|
||||
|
||||
`period_metrics(session, start, end, channel, user_ids=None) -> dict[str, PeriodMetrics]` 实现要点(写全代码进计划):
|
||||
|
||||
1. **宽表 SUM**(`WHERE day BETWEEN s AND e AND channel=? AND metric_version='v1' AND user_id IN (...)` GROUP BY user_id):msg_sent/msg_recv/cust_threads/threads_answered/fr_sum_ms/active_rooms(SUM→avg=SUM/天数)、reactivated(SUM)、`days_with_data=COUNT(*)`;**期末存量**:另查 `day=(SELECT MAX(day)≤e 同 user/channel)` 的 new/cooling/dormant 行( correlated 或两段查询——用 `sa.select(...).where(day<=end).order_by(day.desc())` per user 取首行,员工 41 人循环可接受;或一次查询取 (user, MAX(day)) 再 join)。
|
||||
2. **fact 分位与回复计数**(单条 SQL,窗口函数,双方言):
|
||||
```python
|
||||
sub = (
|
||||
sa.select(
|
||||
FirstReplyFact.owner_account_id.label("owner"),
|
||||
FirstReplyFact.fr_ms.label("fr"),
|
||||
sa.func.row_number().over(
|
||||
partition_by=FirstReplyFact.owner_account_id, # 列表达式,非别名——MySQL 窗口规范不认 SELECT 别名
|
||||
order_by=FirstReplyFact.fr_ms,
|
||||
).label("rn"),
|
||||
sa.func.count().over(
|
||||
partition_by=FirstReplyFact.owner_account_id
|
||||
).label("n"),
|
||||
).where(FirstReplyFact.day.between(start, end),
|
||||
FirstReplyFact.channel == channel)
|
||||
.subquery()
|
||||
)
|
||||
# 每 owner:rn=ceil(0.5*n) 行的 fr 即 p50;rn=ceil(0.9*n) 即 p90;
|
||||
# 回复计数:SUM(fr<=2h)/SUM(fr<=24h) 另用条件聚合直查(不用窗口)。
|
||||
```
|
||||
owner→user 经 account_map(channel)。p50/p90 取最接近秩(`rn == (n+1)//2` 与 `rn == max(1, (9*n+9)//10)`——**用「最接近秩」公式并在测试里锁死**:ceil(q·n) 最近邻,与批次A `_percentile` 口径一致)。
|
||||
3. **reply 计数**:`SELECT owner, SUM(fr_ms<=7200000), SUM(fr_ms<=86400000) FROM first_reply_fact WHERE day BETWEEN ... GROUP BY owner`(MySQL 布尔求和;SQLite 同义 `SUM(CASE WHEN ...)`——用 case 写法双方言安全)。
|
||||
4. **distinct 活跃客户**:msg_daily_stats friend 行 in window → peer 集合按 account_map 归并到 user → len。
|
||||
5. **通话**:employee_call_daily SUM(无渠道维;channel 参数只影响消息侧——**通话四列在 channel 查询里也返回全渠道值,响应里标注**)。
|
||||
6. `team_median`:对每指标取全体 user 的中位数(scoring 层用)。
|
||||
7. **channel=all**:router 层把 "all" 翻译成 None 传入(照 analytics 惯例,哨兵字符串不进 SQL);channel=None 时两条渠道的 account_map 合并、宽表/fact 查询去渠道过滤、分位与活跃跨渠道合并到同一 user。
|
||||
|
||||
**测试**(seed 宽表/fact/通话行 + Staff/account,构造已知分位数集合断言 p50/p90 精确值、期末存量取 ≤e 最近行、distinct 活跃跨日去重、prev/baseline 窗口日期算术含月末)——完整测试代码进计划(约 6 个用例)。
|
||||
|
||||
- [ ] Steps: 测试先失败 → 实现 → 通过 → 全量回归 → 提交
|
||||
`feat(management): 期聚合查询——fact窗口函数精确分位/期末生命周期存量/distinct活跃/前窗基线窗口`
|
||||
|
||||
---
|
||||
|
||||
### Task B2: 评分引擎 scoring.py
|
||||
|
||||
**Files:**
|
||||
- Create: `yunke-server/app/management/scoring.py`
|
||||
- Test: 追加 `tests/test_management_scoring.py`
|
||||
|
||||
核心(完整代码进计划):
|
||||
|
||||
```python
|
||||
def normalize(spec: dict, value: float | None) -> float | None:
|
||||
"""单指标 0..100;样本不足/数据缺 → None(不评分,不默认 0/100)。"""
|
||||
t = spec["type"]
|
||||
if value is None:
|
||||
return None
|
||||
if t == "target":
|
||||
if value <= spec["floor"]:
|
||||
return 0.0
|
||||
if value >= spec["target"]:
|
||||
return 100.0
|
||||
return (value - spec["floor"]) / (spec["target"] - spec["floor"]) * 100
|
||||
if t == "reverse":
|
||||
if value <= spec["target_ms"]:
|
||||
return 100.0
|
||||
if value >= spec["floor_ms"]:
|
||||
return 0.0
|
||||
return (spec["floor_ms"] - value) / (spec["floor_ms"] - spec["target_ms"]) * 100
|
||||
if t == "band":
|
||||
if value < spec["low"]:
|
||||
return None # 负载不足→不评分(样本保护语义)
|
||||
return 100.0 if value <= spec["high"] else 80.0 # 超上限 80 不再加
|
||||
raise ValueError(t)
|
||||
|
||||
|
||||
def score_employee(config, current: PeriodMetrics, baseline: PeriodMetrics | None,
|
||||
team: dict[str, float]) -> dict:
|
||||
"""返回 {total_score, grade, score_status, trial, dimensions{...},
|
||||
metrics{code: {value, prev, baseline, median, sample_size,
|
||||
sample_status, score, formula_note}}}。"""
|
||||
```
|
||||
|
||||
规则(写死进测试):
|
||||
- 周期值派生:`reply_2h_rate = reply_2h_count/cust_threads`(threads=0 → None);`fr_p90_ms` 直取;`active_share` **分子=期末该渠道最近一行(≤end)的 active_friends,分母=同一行的 active_friends+cooling_customer+dormant_customer**(分母 0 → None;宽表无生命周期 active 存量列,active 以 active_friends 列充当);band 用 active_friends 周期 distinct(B2 决议:抗单日噪声,与 B1 口径一致)与 call_count 总量。
|
||||
- 样本保护:`cust_threads < min_threads(30)` → 该 service/customer 指标 sample_status="insufficient",不评分;soft(10)~min 之间 "low_confidence"(评分但标注)。
|
||||
- 维度分 = 各指标得分的均值(等权);任一指标 None 时该指标剔除并按剩余归一;**全部 None → 维度 pending**。
|
||||
- 总分 = Σ(可用维度 weight×dim_score)/Σ(可用维度 weight)×100;可用=pending_data 以外且维度分非 None;全 None → score_status="insufficient_sample"(total None)。
|
||||
- grade 按 grade_bounds;insufficient → grade None + status。
|
||||
- `trial=True` 恒返(一期 3 维)。
|
||||
|
||||
**测试**(≥8 用例,完整代码进计划):target 边界(floor/target/中点 50)、reverse 两端与中间、band 三段、threads=0 → None 非 0、insufficient_sample 整员工、pending 维度不参与(100/70 归一后 A 线仍可达)、试算标记、全部指标有明细含四对比位。
|
||||
|
||||
- [ ] Steps: TDD → 提交 `feat(management): 评分引擎——三类标准化/维度归一/样本保护/等级/完整明细`
|
||||
|
||||
---
|
||||
|
||||
### Task B3: 异常规则 anomalies.py
|
||||
|
||||
**Files:**
|
||||
- Create: `yunke-server/app/management/anomalies.py`
|
||||
- Test: `yunke-server/tests/test_management_anomalies.py`
|
||||
|
||||
4 条确定性规则(输入=period_metrics 当前+基线+团队中位数;输出=v1 §5.4 卡片列表,完整代码进计划):
|
||||
|
||||
| rule_code | 触发 | severity | drilldown |
|
||||
|---|---|---|---|
|
||||
| reply_p90_worsened | p90 > 基线p90×1.5 且样本≥soft;×2.5 升 critical | warning/critical | metric_code=first_reply_p90 |
|
||||
| reply_rate_dropped | reply_2h_rate < 基线×0.7 且当前≥soft 样本 | warning | metric_code=unreplied |
|
||||
| activity_drop | 日均 msg_total < 基线日均×0.5 且基线≥min_days | warning | 员工详情 |
|
||||
| workload_overload | active_friends 周期 distinct > band 上限 80 | warning | 员工详情 |
|
||||
|
||||
卡片字段照 v1 §5.4 JSON(rule_code/severity/employee_user_id/title/current_value/baseline_value/unit/sample_size/**affected_count**/reason[纯数据事实]/drilldown/suggested_action——文案固定模板,不用 LLM)。`affected_count` 按规则定义:reply_p90_worsened 与 reply_rate_dropped=期内未回串数(cust_threads−threads_answered);activity_drop=当前期活跃客户数;workload_overload=active_friends 日均。
|
||||
|
||||
**测试**(≥6 用例):每规则触发/不触发边界、基线 None 不触发、severity 升级阈值、卡片结构字段齐全。
|
||||
|
||||
- [ ] Steps: TDD → 提交 `feat(management): 确定性异常规则×4——P90恶化/回复率骤降/活动骤降/过载`
|
||||
|
||||
---
|
||||
|
||||
### Task B4: 管理 API 6 端点
|
||||
|
||||
**Files:**
|
||||
- Create: `yunke-server/app/api/routers/management.py`
|
||||
- Modify: `yunke-server/app/api/main.py`(import + include_router(management.router))
|
||||
- Test: `yunke-server/tests/test_management_api.py`
|
||||
|
||||
路由骨架(完整代码进计划,照 analytics 惯例):
|
||||
|
||||
```python
|
||||
router = APIRouter(prefix="/api/management", tags=["management"])
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
AllChannelParam = Annotated[Literal["all", "wechat", "whatsapp"], Query()]
|
||||
YmdParam = Annotated[int, Query(ge=20000101, le=29991231)]
|
||||
```
|
||||
|
||||
| 端点 | 行为要点 |
|
||||
|---|---|
|
||||
| `GET /dashboard` | 参数 channel/start/end/compare(previous\|personal_baseline\|team_median,默认 previous)。组装:kpis(活跃客户/活跃员工/待回复[复用 analytics unreplied total]/通话量/生命周期期末分布)+ communication_trend(宽表按日 SUM msg+distinct friends 近 trend 天)+ service_trend(fr 日值 p50/p90 宽表列按日 + reply_2h_rate 派生)+ lifecycle(期末存量分布)+ dimension_summary(全员 3 维分布计数)+ anomalies(B3 全员)+ action_summary(复用 analytics action_counts)+ data_as_of(复用 health)。单次响应聚合,内部并发可用 asyncio.gather |
|
||||
| `GET /employees` | 参数 channel/start/end/view/keyword/status/sort_by/sort_order/page/page_size(≤100 默认 20)。行=staff 身份(name/is_on_job) + PeriodMetrics + 四对比 + score_employee 结果摘要(total/grade/sample_status/trial) + anomaly_count + anomaly_weight。默认排序=**anomaly_severity**(权重和 critical=2/warning=1 desc,同分再比 count desc——对应 v2 §6「按异常严重度排序」,非单纯计数);keyword 匹配 staff.name/user_id |
|
||||
| `GET /employees/{user_id}` | 404 校验(staff 无此 user_id);头部身份+账号清单(wechat/whatsapp account 表)+宽表总览+评分明细+异常+基线摘要 |
|
||||
| `GET /employees/{user_id}/metrics?section=` | section ∈ overview\|service\|customer\|workload\|calls(一期 5 个,v2 §6);每 section 返回该域 PeriodMetrics 明细+四对比+trend(宽表按日序列) |
|
||||
| `GET /employees/{user_id}/drilldown?metric_code=&start=&end=&channel=&page=&page_size=` | **白名单**:`first_reply_p90|first_reply_p50`→fact 逐串明细(含 thread_end/reply ts/fr_ms+跳聊天参数 owner/talker);`unreplied`→调 analytics unreplied(user 过滤);`cooling_customers|dormant_customers`→customer 表 owner 归属+lifecycle 判定列清单;`call_followup`→phone_call 行清单。白名单外 422 |
|
||||
| `GET /metric-definitions` | METRIC_DEFINITIONS + DEFAULT_SCORE_CONFIG 摘要(权重/试算标记/样本门槛)+ 三方口径差异说明 |
|
||||
|
||||
**API 测试**(完整代码进计划,make_client fixture,≥12 用例):参数默认值/422(custom 缺参、跨度>366、metric_code 白名单外、page_size>100)/404(员工不存在)/响应结构关键字段(data_as_of、metric_version、sample_status、trial 标记)/employees 分页排序/dashboard 各段齐全/渠道过滤生效。
|
||||
|
||||
- [ ] Steps: TDD → main.py 注册 → OpenAPI 冒烟(`/docs` 可见 6 端点)→ 全量回归 → 提交
|
||||
`feat(management): 只读管理API六端点——dashboard/employees/detail/sections/drilldown白名单/指标定义`
|
||||
|
||||
---
|
||||
|
||||
### Task B5: 真机冒烟 + 性能初测 + 记录
|
||||
|
||||
- [ ] 本地起后端(先 `docker stop yunke-backend-1 yunke-frontend-1` 若在跑;`cd yunke-server && .venv/Scripts/python.exe -m uvicorn app.api.main:app --port 8000` 后台);
|
||||
- [ ] curl 六端点(带 token,从 .env API_TOKEN;无 token 模式则直接)真机各一次,记录响应体积与耗时(目标:dashboard p95≤1.5s、employees≤1.5s——v2 §9 性能线);
|
||||
- [ ] 抽查:某回测员工(王云青 369D257E…)30 天窗 employees 行——P90/回复率/维度分人工合理性检查(对照核实文档对账数字);
|
||||
- [ ] 结果(耗时表+抽查截图级数字)追加到批次A核实文档「批次B冒烟」小节;
|
||||
- [ ] 提交 `docs(management): 批次B真机冒烟——六端点耗时/回测员工抽查/记录`
|
||||
|
||||
---
|
||||
|
||||
## 批次B验收清单
|
||||
|
||||
- [ ] `score-config` 种子幂等,配置过校验,v1.0 active;
|
||||
- [ ] 期聚合:p50/p90 精确分位(窗口函数)与期末存量口径有判别测试;
|
||||
- [ ] 评分:三类标准化边界/样本保护/100-70 归一/试算标记全锁测试;
|
||||
- [ ] 异常:4 规则触发边界 + 卡片结构完整;
|
||||
- [ ] 6 端点:OpenAPI 可见、参数校验 422/404 齐全、白名单生效、dict 返回含 data_as_of/metric_version/sample_status;
|
||||
- [ ] 全量测试绿(653 + 新增 ~30);
|
||||
- [ ] 真机冒烟耗时达标并记录;
|
||||
- [ ] 未动批次A任何存储层代码(宽表/fact 生成零改动)。
|
||||
|
||||
## 批次C-F
|
||||
|
||||
批次C:前端(api/management.ts + useManagementFilters + components/management/* + ManagementDashboardView + 路由/导航「经营管理」分组)——批次B合入后出计划,消费真实 API 形态。D:员工总览/详情页。E:followups+快照(写端点+schemas)。F:调度接入(analytics_tick 第三步/full_sync 13-14 步/scheduler 镜像重建)+回测试运行。
|
||||
@@ -0,0 +1,305 @@
|
||||
# 云客经营管理后台·批次C(前端导航+管理驾驶舱)实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 交付经营管理后台的第一个可视化页面——管理驾驶舱(`/management/dashboard`)+「经营管理」导航分组;管理者 5 分钟看整体、异常两次点击进事实。
|
||||
|
||||
**Architecture:** 前端纯增量:`api/management.ts`(六端点 typed client,契约=批次B终评冻结面)+ `composables/useManagementFilters.ts`(URL query 为真源)+ `components/management/` 六个组件 + `ManagementDashboardView.vue`。复用 useChart/api client/format utils/seq 守卫惯例;不引新 UI 框架、不引雷达图(维度分布用条形图)。依据 [v2 计划](./2026-09-08-yunke-management-console-implementation-v2.md) §4/§5/§7/§13 与批次B契约清单;员工总览/详情页是批次D。
|
||||
|
||||
**Tech Stack:** Vue3 + TS + Element Plus + ECharts(useChart 按需已有 Line/Bar)+ vitest(若仓库已配)。
|
||||
|
||||
---
|
||||
|
||||
## 执行者必读
|
||||
|
||||
1. **后端就绪**(本地 8000 已跑或按 DEPLOY 起):六端点见 `/tmp` 之外的真实路径 `/api/management/*`;**契约以批次B终评清单为准**(None≠0;`scope.channel` 回显原值;drilldown 白名单六值;异常卡片 12 字段;`config_source=db`)。
|
||||
2. **前端惯例**:路由扁平 + `meta.title` + 登录守卫(public 才放行);API 走 `getJson/requestJson`(client.ts 已带 Bearer+baseURL=/api);图表用 `useChart`(容器稳定,不 v-if);请求序号 seq 守卫防旧响应覆盖(DashboardView 现有模式照抄);格式化放 `utils/format.ts` 不重复实现;el-select 空串 value 显示英文占位用 `'all'` 哨兵。
|
||||
3. **不做**:员工总览/详情页、辅导、快照(D/E 批次);`/analytics/dashboard` 不重定向(并存过渡);二期菜单不渲染不放空页。
|
||||
4. **验收基准**:v2 §5——5 分钟判总体;异常两次点击到事实;全部卡片显示数据截至与对比窗口;`npm run build` 过。
|
||||
5. 仓库有并行会话活动——`git status` 后只 add 自己文件;提交中文 conventional;master 直提。
|
||||
|
||||
## 文件地图
|
||||
|
||||
| 文件 | 职责 | 动作 |
|
||||
|---|---|---|
|
||||
| `yunkes/src/api/management.ts` | 六端点 typed client | 新建 |
|
||||
| `yunkes/src/composables/useManagementFilters.ts` | URL query ↔ 筛选(channel/start/end/compare + 预设窗) | 新建 |
|
||||
| `yunkes/src/components/management/DataAsOfBadge.vue` | 数据截至/版本/试算角标 | 新建 |
|
||||
| `yunkes/src/components/management/ManagementKpiCard.vue` | KPI 卡(主值+辅助+点击跳转) | 新建 |
|
||||
| `yunkes/src/components/management/ManagementFilterBar.vue` | 筛选栏(渠道/时间预设/自定义/口径入口按钮) | 新建 |
|
||||
| `yunkes/src/components/management/AnomalyList.vue` | 异常榜(severity 标签+数字+下钻事件) | 新建 |
|
||||
| `yunkes/src/components/management/MetricDrilldownDrawer.vue` | 下钻抽屉(drilldown 端点行清单+聊天跳转) | 新建 |
|
||||
| `yunkes/src/components/management/MetricDefsDrawer.vue` | 指标口径抽屉(metric-definitions 渲染) | 新建 |
|
||||
| `yunkes/src/views/management/ManagementDashboardView.vue` | 驾驶舱页 | 新建 |
|
||||
| `yunkes/src/router/index.ts` | /management/dashboard 路由 | 修改 |
|
||||
| `yunkes/src/layouts/MainLayout.vue` | 「经营管理」分组 | 修改 |
|
||||
|
||||
---
|
||||
|
||||
### Task C0: 后端契约补全——drilldown 行补 channel 字段
|
||||
|
||||
**Files:** Modify `yunke-server/app/management/api_assembly.py` + `yunke-server/tests/test_management_api.py`
|
||||
|
||||
批次B已提交但未部署,前端冻结前补齐(评审发现:fr_fact_rows/lifecycle_customers 的行无 channel 键,前端 channel=all 时 WA 行会跳错聊天路由):
|
||||
1. `fr_fact_rows` 每行加 `channel`(SELECT 已有该列,透传即可);
|
||||
2. `lifecycle_customers` 每行加 `channel`(c.channel 透传);
|
||||
3. 测试断言补两行(drilldown p90 与 cooling/dormant 的响应行含 channel)。
|
||||
- [ ] `cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_management_api.py -q`(23 passed)+ 全量 -q
|
||||
- [ ] commit `fix(management): drilldown行补channel字段——前端聊天路由判渠道用(契约冻结前补齐)`
|
||||
|
||||
### Task C1: api/management.ts(typed client)
|
||||
|
||||
**Files:** Create `yunkes/src/api/management.ts`
|
||||
|
||||
完整代码(契约=批次B as-built;先对照后端 router 响应键核对再落笔):
|
||||
|
||||
```typescript
|
||||
// 经营管理板块 API(批次C)。契约以批次B终评冻结面为准:
|
||||
// None≠0(无样本分位/率=null);scope.channel 回显原值;drilldown 白名单六值。
|
||||
import { getJson } from './client'
|
||||
import type { Channel } from './types'
|
||||
|
||||
export type MgmtChannel = 'all' | Channel
|
||||
export type CompareMode = 'previous' | 'personal_baseline' | 'team_median'
|
||||
export type DrilldownCode =
|
||||
| 'first_reply_p90' | 'first_reply_p50' | 'unreplied'
|
||||
| 'cooling_customers' | 'dormant_customers' | 'call_followup'
|
||||
|
||||
export interface MgmtScope { channel: MgmtChannel; start: number; end: number; compare?: CompareMode; view?: string; user_id?: string; metric_code?: string }
|
||||
export interface Kpis {
|
||||
active_customers: number; active_employees: number
|
||||
msg_total: number; cust_threads: number; threads_answered: number
|
||||
call_count: number; call_seconds: number; answered_call: number
|
||||
lifecycle: { new: number; cooling: number; dormant: number; reactivated: number }
|
||||
}
|
||||
export interface CommTrendPoint { day: number; msg_total: number; active_customers: number; call_cnt: number }
|
||||
export interface SvcTrendPoint { day: number; fr_p50_ms: number | null; fr_p90_ms: number | null; reply_2h_rate: number | null }
|
||||
export interface DimSummary { [dim: string]: { name: string; weight: number; scored_count: number; pending_count: number; insufficient_count: number; avg_score: number | null } }
|
||||
export interface AnomalyCard {
|
||||
rule_code: string; severity: 'critical' | 'warning'; employee_user_id: string
|
||||
title: string; current_value: number; baseline_value: number | null; unit: string
|
||||
sample_size: number; affected_count: number; reason: string
|
||||
drilldown: { type: string; query: { metric_code?: string; user_id?: string } }
|
||||
suggested_action: string
|
||||
}
|
||||
export interface DashboardResp {
|
||||
scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default'
|
||||
kpis: Kpis
|
||||
communication_trend: CommTrendPoint[]
|
||||
service_trend: SvcTrendPoint[]
|
||||
lifecycle: Kpis['lifecycle']
|
||||
dimension_summary: DimSummary
|
||||
anomalies: AnomalyCard[]
|
||||
action_summary: { unreplied_2h: number; churn_real_30d: number; sensitive_pending: number }
|
||||
}
|
||||
export interface EmployeeRow {
|
||||
user_id: string; name: string | null; is_on_job: boolean | null
|
||||
metrics: Record<string, number | null>
|
||||
comparisons: Record<string, { current: number | null; prev: number | null; baseline: number | null; median: number | null }>
|
||||
score: { total_score: number | null; grade: string | null; score_status: string; trial: boolean; dimensions: Record<string, { score: number | null; status: string }> }
|
||||
anomaly_count: number; anomaly_weight: number
|
||||
}
|
||||
export interface EmployeesResp { total: number; page: number; page_size: number; rows: EmployeeRow[]; scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default' }
|
||||
export interface FactDrillRow { day: number; channel: string; owner_account_id: string; peer_id: string; thread_end_ts: number; reply_ts: number; fr_ms: number }
|
||||
export interface LifecycleDrillRow { friend_id: string; channel: string; nickname: string | null; last_day: number; state: string }
|
||||
export interface CallDrillRow { call_id: string; channel: string; call_number: string | null; start_call_time: number; call_duration: number }
|
||||
export interface DrilldownResp { total: number; items: Record<string, unknown>[]; note?: string; truncated?: boolean; summary?: Record<string, number> }
|
||||
export interface MetricDefsResp {
|
||||
definitions: Record<string, { name: string; unit: string; direction: number; caliber?: string; formula?: string }>
|
||||
score_config: { version: string; name: string; trial: boolean; normalization: string; sample_guards: Record<string, number>; grade_bounds: Record<string, number>; dimensions: Record<string, { name: string; weight: number; status?: string }> }
|
||||
caliber_notes: string
|
||||
config_source: 'db' | 'default'
|
||||
}
|
||||
|
||||
export const getDashboard = (channel: MgmtChannel, start: number, end: number, compare: CompareMode = 'previous') =>
|
||||
getJson<DashboardResp>('/management/dashboard', { channel, start, end, compare })
|
||||
export const getEmployees = (params: Record<string, unknown>) =>
|
||||
getJson<EmployeesResp>('/management/employees', params)
|
||||
export const getEmployeeDrilldown = (userId: string, metricCode: DrilldownCode, start: number, end: number, channel: MgmtChannel, page = 1, pageSize = 20) =>
|
||||
getJson<DrilldownResp>(`/management/employees/${userId}/drilldown`, { metric_code: metricCode, start, end, channel, page, page_size: pageSize })
|
||||
export const getMetricDefs = () => getJson<MetricDefsResp>('/management/metric-definitions')
|
||||
```
|
||||
|
||||
(员工详情/sections 两个端点的 client 函数批次D 再加——本期页面不消费,YAGNI;本期 client 为四函数。)
|
||||
|
||||
- [ ] 步骤:写文件 → `cd yunkes && npx vue-tsc --noEmit`(或 build)类型过 → 后端(8000,dev 5173 的 /api 代理已指向它)冒烟一次 getDashboard 类型与真实响应吻合(console 对比关键字段)→ commit `feat(management): 前端API client——管理端点typed契约(批次B冻结面)`
|
||||
|
||||
### Task C2: useManagementFilters composable
|
||||
|
||||
**Files:** Create `yunkes/src/composables/useManagementFilters.ts`(+ vitest 若仓库已配测试脚本,先 `grep '"test"' yunkes/package.json` 确认)
|
||||
|
||||
完整代码:
|
||||
|
||||
```typescript
|
||||
// 管理页统一筛选(URL query 为真源——刷新/分享保持)。批次C:驾驶舱用;
|
||||
// 批次D 员工页复用(view 等额外参数由页面自行挂 query)。
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
export type Preset = 'last7' | 'last30' | 'thisMonth' | 'lastMonth' | 'custom'
|
||||
export const CHANNELS = [
|
||||
{ value: 'all', label: '全部渠道' },
|
||||
{ value: 'wechat', label: '微信' },
|
||||
{ value: 'whatsapp', label: 'WhatsApp' },
|
||||
] as const
|
||||
|
||||
function ymd(d: Date): number { return d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate() }
|
||||
function addDays(d: Date, n: number): Date { const x = new Date(d); x.setDate(x.getDate() + n); return x }
|
||||
|
||||
/** 预设 → [start,end] yyyymmdd(上海自然日由本地时区近似——与后端一致的公历日)。 */
|
||||
export function presetRange(p: Preset, today = new Date()): { start: number; end: number } {
|
||||
const t0 = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||||
switch (p) {
|
||||
case 'last7': return { start: ymd(addDays(t0, -6)), end: ymd(t0) }
|
||||
case 'last30': return { start: ymd(addDays(t0, -29)), end: ymd(t0) }
|
||||
case 'thisMonth': return { start: ymd(new Date(t0.getFullYear(), t0.getMonth(), 1)), end: ymd(t0) }
|
||||
case 'lastMonth': {
|
||||
const first = new Date(t0.getFullYear(), t0.getMonth() - 1, 1)
|
||||
const last = new Date(t0.getFullYear(), t0.getMonth(), 0)
|
||||
return { start: ymd(first), end: ymd(last) }
|
||||
}
|
||||
}
|
||||
// custom 由调用方直传
|
||||
return { start: ymd(t0), end: ymd(t0) }
|
||||
}
|
||||
|
||||
export function useManagementFilters() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const channel = computed<'all' | 'wechat' | 'whatsapp'>(() => {
|
||||
const v = route.query.channel
|
||||
return v === 'wechat' || v === 'whatsapp' ? v : 'all'
|
||||
})
|
||||
const start = computed(() => {
|
||||
const v = Number(route.query.start)
|
||||
return Number.isFinite(v) && v >= 20000101 ? v : presetRange('last30').start
|
||||
})
|
||||
const end = computed(() => {
|
||||
const v = Number(route.query.end)
|
||||
return Number.isFinite(v) && v >= 20000101 ? v : presetRange('last30').end
|
||||
})
|
||||
const compare = computed(() => {
|
||||
const v = route.query.compare
|
||||
return v === 'personal_baseline' || v === 'team_median' ? v : 'previous'
|
||||
})
|
||||
|
||||
function apply(patch: Record<string, string | number | undefined>) {
|
||||
const q: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries({ ...route.query, ...patch })) {
|
||||
if (v !== undefined && v !== null && v !== '') q[k] = String(v)
|
||||
}
|
||||
router.replace({ query: q })
|
||||
}
|
||||
|
||||
// 预设切换:清掉 start/end 让默认值生效,custom 则带具体日期
|
||||
function applyPreset(p: Preset, custom?: { start: number; end: number }) {
|
||||
if (p === 'custom' && custom) apply({ start: custom.start, end: custom.end })
|
||||
else apply({ start: undefined, end: undefined })
|
||||
}
|
||||
|
||||
return { channel, start, end, compare, apply, applyPreset, presetRange }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] vitest(若已配):presetRange 五预设的日期算术(含月末/跨月)+ query 缺省回退 last30 + apply 写回 query。未配则手测并在报告注明。
|
||||
- [ ] commit `feat(management): useManagementFilters——URL query 真源/预设窗/渠道哨兵`
|
||||
|
||||
### Task C3: 六个基础组件
|
||||
|
||||
**Files:** Create `components/management/` 下六件。规格(完整 SFC 代码进实施,要点钉死):
|
||||
|
||||
1. **DataAsOfBadge**:props `{ dataAsOf: number | null; metricVersion?: string; trial?: boolean; configSource?: string }`;渲染 el-tag 组:数据截至(`data_as_of` ms→`YYYY-MM-DD HH:mm`,null 显示「无数据」灰 tag)+ 指标版本 + 试算(warning 色,trial=true 才显示)。格式化用 utils/format.ts 现有函数,无则组件内小 helper。
|
||||
2. **ManagementKpiCard**:props `{ label: string; value: number | null; unit?: string; hint?: string; to?: string }`;主值大字(null 显示 `--`),hint 小字,有 to 则整卡 clickable `router.push`。不做趋势箭头(对比值批次D 表格里呈现)。
|
||||
3. **ManagementFilterBar**:props modelValue 透传 filters composable 的值 + `@apply`;内容:el-radio-group 渠道(CHANNELS)+ el-select 预设(四预设+自定义)+ 自定义时 el-date-picker type=daterange(value-format YYYYMMDD 字符串转 number)+「指标口径」文字按钮(emit open-defs)+ 插槽放 DataAsOfBadge。改即 apply(防抖 300ms 不需要——change 事件天然低频)。
|
||||
4. **AnomalyList**:props `{ anomalies: AnomalyCard[] }`;el-table 或列表卡片:severity(critical=红 danger tag「严重」/warning=橙 warning tag「关注」)、title、current vs baseline(unit 感知格式化:ms→时长/0-1→%/其他千分位)、sample_size、affected_count、suggested_action(灰小字);行 click → emit `drill(card)`。空态「暂无异常」。
|
||||
5. **MetricDrilldownDrawer**:props `{ userId: string; card: AnomalyCard | null; start: number; end: number; channel: MgmtChannel }`(v-model:visible);打开时按 card.drilldown.query.metric_code 调 getEmployeeDrilldown:
|
||||
- `first_reply_p90/p50` → 表格:客户(peer_id)/串末条时间/回复时间/首响时长(fr_ms 降序已由后端);行点击跳聊天:`/staff-chat/{owner_account_id}/single/{peer_id}`(channel=whatsapp 时 `/chat/whatsapp/{owner}/{peer}`)。
|
||||
- `unreplied` → 表格 peer/等待时长/预览;note/truncated 提示。
|
||||
- `cooling/dormant_customers` → 表格 客户/最后互动日/状态;行点击跳该客户聊天(同上映射)。
|
||||
- `call_followup` → 表格 通话(时间/号码/时长)+ summary 卡。
|
||||
分页 el-pagination(后端分页);员工姓名/标题显示 card.employee_user_id 对应名字由父传(dashboard anomalies 无 name——显示 user_id 短版即可,批次D 带名字)。
|
||||
6. **MetricDefsDrawer**:v-model:visible;打开调 getMetricDefs;el-collapse 按 维度/单指标 两组渲染:每指标 name+unit+direction(越大越好/越小越好)+formula+caliber;顶部 score_config 摘要(版本/试算标记/样本门槛/各维度权重与 pending 状态)+ caliber_notes 段落。
|
||||
|
||||
- [ ] 步骤:六组件逐个写 + vue-tsc 过 → 冒烟渲染(C5 页面里真用)→ commit `feat(management): 驾驶舱六组件——KPI卡/筛选栏/异常榜/下钻抽屉/口径抽屉/数据截至角标`
|
||||
|
||||
### Task C4: 路由 + 导航
|
||||
|
||||
- router/index.ts 在 analytics 四页之后加:
|
||||
|
||||
```typescript
|
||||
{
|
||||
// 经营管理板块(批次C):驾驶舱;员工分析/考核快照批次D/E再加
|
||||
path: '/management/dashboard',
|
||||
name: 'management-dashboard',
|
||||
component: () => import('@/views/management/ManagementDashboardView.vue'),
|
||||
meta: { title: '管理驾驶舱' },
|
||||
},
|
||||
```
|
||||
|
||||
- MainLayout「数据分析」el-sub-menu 之前加「经营管理」分组(单项:管理驾驶舱 → goPlain('/management/dashboard'))——分组在最上(管理优先级高于查看)。
|
||||
- 验证:登录态点菜单进页面、刷新保持、activeMenu 高亮正确。
|
||||
|
||||
- [ ] commit `feat(management): 经营管理导航分组+驾驶舱路由`
|
||||
|
||||
### Task C5: ManagementDashboardView
|
||||
|
||||
**Files:** Create `yunkes/src/views/management/ManagementDashboardView.vue`
|
||||
|
||||
结构(完整 SFC 进实施,要点钉死):
|
||||
|
||||
```text
|
||||
<ManagementFilterBar>(含 DataAsOfBadge + 口径按钮)
|
||||
六个 KPI 卡(el-row/gutter,1366px 两行三卡):
|
||||
活跃客户(kpis.active_customers,hint=串/被回)→无跳转(批次D员工页)
|
||||
活跃员工(kpis.active_employees,hint=期内有数据天数>0 口径)
|
||||
消息量(msg_total,hint=1:1口径·与数据看板口径不同)
|
||||
待回复(action_summary.unreplied_2h,红点>0)→/analytics/unreplied
|
||||
即将流失(action_summary.churn_real_30d)→/analytics/churn
|
||||
风险待处理(action_summary.sensitive_pending)→/analytics/sensitive
|
||||
双图表(el-row 两列,useChart×2):
|
||||
客户沟通趋势:Line msg_total + Bar call_cnt 双 y 轴(x=day)
|
||||
服务质量趋势:双 y 轴——左轴 ms(Line fr_p50_ms/fr_p90_ms,tooltip 时长格式化)、
|
||||
右轴 0-1(Line reply_2h_rate,%)——不注明双轴则率线贴底
|
||||
——图内空数据/None 点处理:reply_2h_rate null 断点(connectNulls=false)
|
||||
双条形图(el-row 两列):
|
||||
生命周期分布:Bar new/cooling/dormant/reactivated 四类
|
||||
员工维度分布:Bar dimension_summary 三维 avg_score(0-100)+pending 数 tooltip
|
||||
异常榜(AnomalyList @drill → MetricDrilldownDrawer)
|
||||
seq 守卫:filters 变化 → seq++ → 请求 → 回来校验 seq 才渲染(照 DashboardView 模式)
|
||||
loading:el-skeleton 首屏;错误:el-alert + 重试按钮(保留上次数据)
|
||||
```
|
||||
|
||||
要点:
|
||||
- `watch([channel, start, end, compare], load)`;`onMounted(load)`;
|
||||
- day 轴 `20260810`→`08-10` 短格式;
|
||||
- **共享格式化**:把 DashboardView 里的 `fmtFr`(ms→时长)/`fmtPct`(0-1→%) 提升为 `utils/format.ts` 的 `formatMsDuration`/`formatRatio`(AnomalyList/抽屉/图表 tooltip 三处消费,不复制第三份);
|
||||
- 异常榜点卡片 → drawer(metric_code 白名单外 type=employee 的卡片——activity_drop/overload 无 drilldown 端点,drawer 显示「员工分析批次D上线」占位说明——**两类 drilldown.type=employee 的卡片点击行为:显示 el-empty 占位**);
|
||||
- 试算角标常显(config_source 透传)。
|
||||
|
||||
- [ ] 步骤:写页面 → `npm run build` 过 → 本地前后端真机走查(起 8000 后端 + `npm run dev` 5173,Playwright 或手动):六卡有数、双图渲染、异常卡点开抽屉、抽屉行跳聊天、口径抽屉渲染、刷新 URL 保持、1366px 布局不塌 → commit `feat(management): 管理驾驶舱页——KPI/双趋势/生命周期/维度分布/异常两次点击到事实`
|
||||
|
||||
### Task C6: 收尾验收
|
||||
|
||||
- [ ] `cd yunkes && npm run build`(0 error);
|
||||
- [ ] `npm run test --silent`(若配了 vitest)或记录未配;
|
||||
- [ ] 验收清单核对(v2 §5.3/§19.1 驾驶舱子集):5 分钟判总体 ✓/异常两次点击进事实 ✓/口径入口 ✓/数据截至+对比窗口显示 ✓/渠道筛选 ✓/URL 保持 ✓/试算标记 ✓/空态与错误态 ✓;
|
||||
- [ ] 全量后端测试仍绿(前端不动后端,确认无意外);
|
||||
- [ ] commit(若剩零散修正)。
|
||||
|
||||
---
|
||||
|
||||
## 批次C验收清单
|
||||
|
||||
- [ ] 「经营管理」分组出现且仅含管理驾驶舱;
|
||||
- [ ] 六 KPI 卡数字与后端响应一致(抽查待回复/风险与 analytics 页对得上);
|
||||
- [ ] 双趋势图渲染 30 点、None 断点正确;
|
||||
- [ ] 异常卡 critical 红标在前;点击 metric 类卡片→抽屉→行→聊天定位(两次点击到事实);
|
||||
- [ ] employee 类异常卡占位不报错;
|
||||
- [ ] 口径抽屉含试算标记与三方口径差异说明;
|
||||
- [ ] URL query 刷新/分享保持筛选;
|
||||
- [ ] `npm run build` 过;后端 722 测试不受影响。
|
||||
|
||||
## 批次D-F
|
||||
|
||||
D:员工总览+详情页(消费 employees/detail/sections 端点+指标解释抽屉);E:辅导闭环+考核快照(写端点);F:调度接入+dashboard 性能修复(unreplied 索引)+部署上线。
|
||||
@@ -0,0 +1,172 @@
|
||||
# 云客经营管理后台·批次D(员工总览+员工详情)实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 补上管理闭环最大断点——员工总览(41 人多维一张表)与员工详情(五维/页签/四对比/事实下钻),打通「异常→员工全景→事实」链路;附带驾驶舱 KPI 环比与时间预设补全(体验级小项)。
|
||||
|
||||
**Architecture:** 纯前端为主(消费批次B已就绪的 employees/detail/metrics sections/drilldown 端点)+ 两处小后端增强(dashboard kpis 加 prev 块;无新端点)。新页 `EmployeeAnalyticsView`/`EmployeeAnalyticsDetailView` + 对比抽屉;复用 C3 六组件(FilterBar/DataAsOfBadge/AnomalyList/MetricDrilldownDrawer/MetricDefsDrawer)与 useManagementFilters。依据 [v2 计划](./2026-09-08-yunke-management-console-implementation-v2.md) §6/§7/§13 + 批次B契约清单。
|
||||
|
||||
**Tech Stack:** 同批次C(Vue3+TS+Element Plus+useChart+vitest);后端 FastAPI 小改。
|
||||
|
||||
---
|
||||
|
||||
## 执行者必读
|
||||
|
||||
1. **消费的端点 as-built 形态**(写 client 前已核对 management.py:321-443):
|
||||
- `GET /employees`(行结构见批次C client 的 EmployeeRow——已含 metrics/comparisons/score 摘要/anomaly_count/weight);
|
||||
- `GET /employees/{id}`:`{user_id,name,is_on_job,accounts[{channel,account_id,nick_name,user_name}],metrics(22字段dict 含 user_id 键),score{total_score,grade,score_status,trial,dimensions{name,weight,score,status,metrics{code:{value,score,sample_size,sample_status,formula_note}}},metrics_detail{code:{value,prev,baseline,median,sample_size,sample_status,score,unit,name}}},anomalies[],prev(dict|null),baseline(dict|null),scope,data_as_of,metric_version,config_source}`;
|
||||
- `GET /employees/{id}/metrics?section=`:`{section,metrics(该域字段dict),trend[{day,cols...}](无数据日计数0/分位None),comparisons(源指标名键),scope,data_as_of,metric_version}`;
|
||||
- drilldown/metric-definitions 已有 client。
|
||||
2. **前端惯例**同批次C必读(seq 守卫/useChart/URL query/csvExport.ts 全量翻页+中文 BOM/命名路由跳转)。
|
||||
3. **不做**:辅导/快照(E);员工对比不新开 API(勾选 2-5 行客户端拼数据);「指标解释抽屉」= 复用 MetricDefsDrawer(点击列头 ? 图标打开)——不做每指标单独解释面板(v1 §6.5 的完整版二期)。
|
||||
4. 仓库并行会话照旧;中文 conventional 提交;master 直提。
|
||||
|
||||
## 文件地图
|
||||
|
||||
| 文件 | 动作 |
|
||||
|---|---|
|
||||
| `yunkes/src/api/management.ts` | 补 getEmployeeDetail/getEmployeeMetrics + types(D0) |
|
||||
| `yunkes/src/composables/useManagementFilters.ts` | 预设补 今日/昨日/本周/上周(D0) |
|
||||
| `yunkes/src/views/management/EmployeeAnalyticsView.vue` | 新建(D1) |
|
||||
| `yunkes/src/components/management/EmployeeCompareDrawer.vue` | 新建(D1,对比抽屉) |
|
||||
| `yunkes/src/views/management/EmployeeAnalyticsDetailView.vue` | 新建(D2) |
|
||||
| `yunkes/src/router/index.ts` + `MainLayout.vue` | 两路由+两菜单(D1/D2 各自) |
|
||||
| `yunke-server/app/api/routers/management.py` + `api_assembly.py` | dashboard kpis 加 prev 块(D3) |
|
||||
| `yunkes/src/components/management/ManagementKpiCard.vue` + `AnomalyList.vue` + 驾驶舱页 | delta 显示 + 员工跳详情(D3) |
|
||||
|
||||
---
|
||||
|
||||
### Task D0: client 补全 + 预设扩展
|
||||
|
||||
- [ ] `api/management.ts` 追加:
|
||||
|
||||
```typescript
|
||||
export interface EmployeeAccount { channel: string; account_id: string; nick_name: string | null; user_name: string | null }
|
||||
export interface ScoreMetricDetail { value: number | null; prev: number | null; baseline: number | null; median: number | null; sample_size: number; sample_status: string; score: number | null; unit: string; name: string }
|
||||
// formula_note 在 dimensions[dim].metrics[code] 上(scoring.py 维度级明细),不在 metrics_detail
|
||||
export interface EmployeeDetailResp {
|
||||
user_id: string; name: string | null; is_on_job: boolean | null
|
||||
accounts: EmployeeAccount[]
|
||||
metrics: Record<string, number | string | null>
|
||||
score: {
|
||||
total_score: number | null; grade: string | null; score_status: string; trial: boolean
|
||||
dimensions: Record<string, { name: string; weight: number; score: number | null; status: string; metrics?: Record<string, { value: number | null; score: number | null; sample_size: number; sample_status: string; formula_note?: string }> }>
|
||||
metrics_detail: Record<string, ScoreMetricDetail>
|
||||
}
|
||||
anomalies: AnomalyCard[]
|
||||
prev: Record<string, number | string | null> | null
|
||||
baseline: Record<string, number | string | null> | null
|
||||
scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default'
|
||||
}
|
||||
export interface SectionResp {
|
||||
section: string; metrics: Record<string, number | null>
|
||||
trend: Record<string, number | null>[]
|
||||
comparisons: Record<string, { current: number | null; prev: number | null; baseline: number | null; median: number | null }>
|
||||
scope: MgmtScope; data_as_of: number | null; metric_version: string
|
||||
}
|
||||
export type MetricsSection = 'overview' | 'service' | 'customer' | 'workload' | 'calls'
|
||||
export const getEmployeeDetail = (userId: string, channel: MgmtChannel, start: number, end: number) =>
|
||||
getJson<EmployeeDetailResp>(`/management/employees/${userId}`, { channel, start, end })
|
||||
export const getEmployeeMetrics = (userId: string, section: MetricsSection, channel: MgmtChannel, start: number, end: number) =>
|
||||
getJson<SectionResp>(`/management/employees/${userId}/metrics`, { section, channel, start, end })
|
||||
```
|
||||
|
||||
(写入前 curl 真机抽查 detail 响应键核对——同批次C C1 惯例,以真实响应为准修。)
|
||||
|
||||
- [ ] `useManagementFilters.ts` 预设补:`Preset` 加 `'today' | 'yesterday' | 'thisWeek' | 'lastWeek'`;`presetRange` 四分支(today=当天单日窗;yesterday=昨日单日;thisWeek=本周一..今天;lastWeek=上周一..周日,**周一为一周起点**);vitest 补 5 例(today/yesterday/thisWeek 周中+周一/lastWeek 跨月)。FilterBar 的预设选项组同步加(el-select options),分组显示:常用(今日/昨日/近7/近30)/日历(本周/上周/本月/上月)/自定义。
|
||||
- [ ] vue-tsc + `npm run test`(23+ 过)+ commit `feat(management): client补员工详情/分段接口+时间预设补今日昨日本周上周`
|
||||
|
||||
### Task D1: 员工总览页
|
||||
|
||||
**Files:** Create `EmployeeAnalyticsView.vue` + `EmployeeCompareDrawer.vue`;改 router(`/management/employees`)+ MainLayout(管理驾驶舱下加「员工分析」菜单项)。
|
||||
|
||||
**页面结构**:
|
||||
|
||||
```text
|
||||
ManagementFilterBar(复用;compare 选择器在此页透出——el-select 对比方式:上期/个人基线/团队中位数→apply({compare}))
|
||||
工具行:keyword el-input(placeholder 姓名/ID搜索) | status el-select(全部/在职/离线) | 视图 el-radio-group×4
|
||||
(management=概览/workload=负载/service=服务/customer=客户经营——切换列组)| 指标口径按钮 | 导出 CSV | DataAsOfBadge
|
||||
主表 el-table(服务端排序 sort-change→query;行选择框多选 2-5 人→「对比」按钮开抽屉):
|
||||
固定列:姓名(链接进详情)+ID 小字、在职 tag
|
||||
management 视图列:综合分(total_score+grade tag+trial*)、异常(anomaly_count 红数字+weight)、活跃客户、消息量、串/被回、维度三列(customer/service/workload score 各 0-100 或「--」)
|
||||
workload 视图列:活跃客户、活跃群(日均)、消息收/发、通话量/秒、串
|
||||
service 视图列:首响P50/P90(formatMsDuration)、2h回复率、24h回复率(formatRatio)、被回/串
|
||||
customer 视图列:新客/降温/沉睡/回流(期末+期内注)、活跃占比
|
||||
列头 ? 图标→打开 MetricDefsDrawer(复用)
|
||||
空数据/样本不足单元格显示「--」灰字(sample_status=insufficient 时 total 亦然)
|
||||
分页 el-pagination(服务端 page/page_size 20;total)
|
||||
```
|
||||
|
||||
**行为**:query 真源(view/keyword/status/sort_by/sort_order/page 全进 URL);seq 守卫;**服务端排序仅四列**(后端 sort_by 白名单:anomaly_severity[默认]/total_score/active_friends/cust_threads——其余列头不显示排序箭头,tooltip「该列不支持服务端排序」);`sort_by=total_score` 时 null 排最后由后端保证;compare 参数仅客户端显示态(employees 端点无此参数,行恒带全部四对比值);**CSV**:扩展 `utils/csvExport.ts` 支持可选前置注释行(`exportListToCsv(..., preamble?: string[])`——含口径说明如「1:1口径·窗口 20260810-20260908·试算」)+ fetchPage 适配器(/employees 返回 `{rows,total}`,适配为 csvExport 期望的 `{items,total}`),导出当前视图列全量翻页。
|
||||
|
||||
**EmployeeCompareDrawer**:props `{ rows: EmployeeRow[] }`(勾选行已有数据,不另发请求);内容:五维分对比条(3 可用维度每人一行,横向 bar 0-100)+ 关键指标表(活跃客户/P90/2h率/异常数 每人一列)+ 顶部说明「同口径同时窗对比,不产生优劣结论」;>5 人禁开。
|
||||
|
||||
**路由/菜单**:
|
||||
|
||||
```typescript
|
||||
{ path: '/management/employees', name: 'management-employees',
|
||||
component: () => import('@/views/management/EmployeeAnalyticsView.vue'),
|
||||
meta: { title: '员工分析' } },
|
||||
```
|
||||
|
||||
- [ ] vue-tsc/test/build + 真机初查(表格渲染 41 人/切视图列变/排序翻页/keyword/CSV 打开内容对)+ commit `feat(management): 员工总览页——四视图/服务端排序分页/对比抽屉/CSV导出`
|
||||
|
||||
### Task D2: 员工详情页
|
||||
|
||||
**Files:** Create `EmployeeAnalyticsDetailView.vue`;router 加 `/management/employees/:userId`(props:true;**activeMenu 需高亮父菜单「员工分析」**——MainLayout 的 el-menu :default-active 绑定的是路径,嵌套路由时用 route.meta 或 watch 设 index——看 MainLayout 现有 active 处理方式,最简方案:菜单 index 用路径匹配 + 详情页 route path 前缀匹配高亮,若现结构不支持则 meta.activeMenu='/management/employees' 并在 el-menu :default-active 计算)。
|
||||
|
||||
**页面结构**:
|
||||
|
||||
```text
|
||||
头部卡:姓名(+在职 tag)+ID | 账号清单(渠道 tag+account_id+昵称,点击跳 staff-chat/通讯录按渠道)
|
||||
| 本期试算分大数字+grade tag+score_status(insufficient 时灰注「样本不足·不参与考核」)+trial*
|
||||
| DataAsOfBadge | 返回列表链接
|
||||
五维条(v1 §7.2 概览的核心):横向五条 bar——3 可用维度实分数值,action/risk 灰「待数据」占位
|
||||
(ScoreBreakdown 简版:直接在页内画,不新建组件,el-progress/自定义 div)
|
||||
el-tabs 五页签(懒加载:激活才调 getEmployeeMetrics):
|
||||
概览: 六数卡取自**detail.metrics**(msg/活跃/串/通话——section 端点字段有限,期值以 detail 为准)
|
||||
+ 四对比小表(comparisons 直渲染,源指标名行)
|
||||
服务质量: 数卡 P50/P90 + 2h/24h回复率(**客户端从 metrics.reply_2h_count/cust_threads 派生,
|
||||
与 derive_metrics 同式**——section 趋势无每日率序列) + 趋势图=**首响 P50/P90 双线(ms 轴)
|
||||
±串数(次轴可选)**——不做率右轴(员工级无每日率数据,率的团队级序列在驾驶舱)
|
||||
客户经营: 期末四态数卡(取自 detail.metrics) + 降温/沉睡下钻按钮(→DrilldownDrawer lifecycle 分支)
|
||||
工作负载: 数卡取 detail.metrics(收发/活跃客户/群/通话量) + 趋势图用 section=workload 的
|
||||
active_friends/active_rooms 双线(trend 列为日值 active_rooms)
|
||||
通话: 通话四数(detail.metrics) + call_followup 下钻按钮
|
||||
**页签下钻按钮与 MetricDrilldownDrawer 的衔接**:抽屉是 card 驱动——按钮合成最小伪卡片
|
||||
`{title:'首响P90明细', drilldown:{type:'metric',query:{metric_code:'first_reply_p90',user_id}}}` 传入
|
||||
异常区:AnomalyList(复用,@drill→MetricDrilldownDrawer 复用)
|
||||
评分明细折叠面板(el-collapse「评分怎么算出来的」): metrics_detail 表(code/名称/当前/上期/基线/中位/样本/得分)
|
||||
+ prev/baseline 原始 PeriodMetrics 两个 JSON 折叠(调试用,灰字注明)
|
||||
```
|
||||
|
||||
**行为**:query 真源(channel/start/end/compare/section 进 URL,页签切换 replace);seq 守卫每页签独立(切页签取消旧渲染);404 员工→el-result 错误页+返回链接;「创建辅导记录」按钮**占位禁用**(tooltip「批次E上线」)。
|
||||
|
||||
- [ ] vue-tsc/test/build + 真机初查(王云青详情:五维条/五页签懒加载/异常/评分明细展开/账号跳聊天)+ commit `feat(management): 员工详情页——五维条/五页签懒加载/四对比/评分明细/事实下钻复用`
|
||||
|
||||
### Task D3: 驾驶舱 KPI 环比 + 异常跳员工
|
||||
|
||||
**后端**(`management.py` dashboard + `api_assembly.py`):①dashboard 端点 kpis 加 `prev_kpis` 块(active_customers/active_employees/msg_total/cust_threads——dashboard 走 assemble_employees(with_prev=False),需加一次 prev 窗 period_metrics 全员调用,B5 实测三窗合计 ~0.3s,加一窗 +0.1s 量级)+ `msg_delta`((cur-prev)/prev,prev=0→null)。lifecycle/通话不加环比(期末/全渠道口径环比意义弱,注明)。②**异常卡补 `employee_name`**(assemble_employees 手里已有全员 name——组装 anomalies 时带上,一行);同步 AnomalyCard type。测试补:dashboard 响应含 prev_kpis 且数字=prev 窗聚合 + anomalies[0].employee_name 非空。
|
||||
**前端**:KpiCard 加 `delta?: number | null` prop(↑绿↓红,方向语义由调用方定——涨=好传正绿;不传不显示);驾驶舱三卡接 delta(活跃客户/活跃员工/消息量);AnomalyList 的 title 下加员工行(`employee_name` 链接→`/management/employees/{user_id}`)+ 新增 `showEmployee?: boolean` prop(默认 true;D2 员工详情页复用时传 false 免自链接)。
|
||||
- [ ] 后端 pytest(test_management_api.py 补 prev_kpis 断言)+ 前端走查三卡箭头/异常跳员工 + commit `feat(management): 驾驶舱KPI环比(prev_kpis+delta箭头)+异常卡跳员工详情`
|
||||
|
||||
### Task D4: 真机走查 + 验收
|
||||
|
||||
- [ ] 全链路走查(Playwright):驾驶舱→异常卡→**员工详情**(新链路!)→页签→下钻→聊天;员工总览→对比抽屉→CSV;1366px;刷新保持;
|
||||
- [ ] 验收对照(v1 §19.1 员工子集):能查看所有员工多维状态 ✓/任一分数展开为指标公式样本事实 ✓(评分明细面板)/四对比 ✓/样本不足不评分显示 ✓/异常两次点击进事实 ✓/默认排序非总分榜 ✓;
|
||||
- [ ] 四门禁(vue-tsc/vitest/build/后端 pytest);
|
||||
- [ ] commit 收尾零散 + 走查记录追加核实文档「批次D走查」小节。
|
||||
|
||||
---
|
||||
|
||||
## 批次D验收清单
|
||||
|
||||
- [ ] 员工总览:41 人四视图/服务端排序分页/keyword/对比 2-5 人/CSV 含口径注释;
|
||||
- [ ] 员工详情:五维条 3+2 占位/五页签懒加载/四对比/评分明细可展开/三处下钻按钮进抽屉/账号跳聊天;
|
||||
- [ ] 驾驶舱:三 KPI 有环比箭头/异常卡可跳员工详情(闭环打通);
|
||||
- [ ] 时间预设全套(今日/昨日/近7/近30/本周/上周/本月/上月/自定义);
|
||||
- [ ] URL 全参数保持;四门禁绿;后端仅 dashboard 一处小改且测试补齐。
|
||||
|
||||
## 批次E-F
|
||||
|
||||
E:followups 辅导闭环(写端点+schemas_management+详情页按钮激活)+ 考核快照(生成/冻结/列表/导出)。F:调度接入(analytics_tick 第三步+full_sync 13/14 步+scheduler 镜像重建还 9d3cc8e 旧账)+ dashboard 性能修复(unreplied 索引三选一)+ 服务器部署+回测试运行。
|
||||
@@ -0,0 +1,142 @@
|
||||
# 云客经营管理后台·批次F-调度子批(管理指标接入定时链)实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 管理域三张派生表(first_reply_fact / employee_metric_daily / employee_call_daily)进入生产定时链——4h 滚动 + 夜间全量自愈,数据每天活起来(同步审计 2026-09-10 结论 #5 的管理域部分)。
|
||||
|
||||
**Architecture:** 两处接线:`scheduler.py _analytics_tick` 加**独立事务**的管理滚动段(近 3 天,失败不回滚既有 staff/敏感词段);`yunke-server/scripts/full_sync_all.sh` 在 11/12 步后加 13/14 步(fact 全量 → 宽表+通话全量)+ 终局快照补三表行数。CLI 已有(first-reply-facts / employee-metrics),无新端点。**边界:不碰审计文档 P0 的脚本加固/实体补抓(并行会话领地)**。
|
||||
|
||||
**Tech Stack:** 同批次A(APScheduler + SQLAlchemy async + bash)。
|
||||
|
||||
---
|
||||
|
||||
## 执行者必读
|
||||
|
||||
1. **上游函数签名(as-built)**:`first_reply_facts.pairs_active_in_days(session, days) / recompute_pairs(session, pairs)`;`storage/employee_metrics.recompute_metric_days(session, days) / recompute_call_days(session, days)`;`staff_summary.rolling_days(today, 3)`(返回当日+前2天列表)。全部幂等 delete+insert。
|
||||
2. **隔离语义(v2 Task 12:指标失败不中断主流程)**:tick 内管理段用**独立 session+commit**——失败只回滚管理段自身,staff/敏感词段已提交不受影响(当前实现是单 try 单事务,改造为两段)。
|
||||
3. **锁竞争既有结论**(tick docstring 已记录):与夜间 full_sync 重叠时 DELETE+INSERT 可能锁等待,非致命,幂等下轮自愈——管理段沿用同一语义,docstring 注明。
|
||||
4. 脚本在 `yunke-server/scripts/full_sync_all.sh`(注意路径已从根 scripts/ 迁移);测试先例 `tests/test_scheduler_analytics_tick.py`。
|
||||
5. 并行会话活动照旧;中文 conventional 提交。
|
||||
|
||||
## 任务
|
||||
|
||||
### Task F1: _analytics_tick 管理段 + 测试
|
||||
|
||||
**Files:** Modify `yunke-server/app/sync/scheduler.py` + `tests/test_scheduler_analytics_tick.py`
|
||||
|
||||
改造 `_analytics_tick`(:703)为两段:
|
||||
|
||||
```python
|
||||
async def _analytics_tick(self) -> None:
|
||||
"""4h 分析链(staff 滚动3天 + 敏感词增量 + 管理域滚动)——异常只记日志不断链。
|
||||
|
||||
两段独立事务:段1 staff+敏感词;段2 管理域(fact 近3天活跃会话 + 宽表/
|
||||
通话 滚动3天,批次F 2026-09-10)。段2 失败只回滚自身,不牵连段1 已
|
||||
提交结果(v2 Task12:指标失败不中断主分析链)。纯 DB 无浏览器,不抢
|
||||
``_page_lock``;与夜间 full_sync 13/14 步重叠时的锁竞争语义同段1
|
||||
(非致命,幂等下轮自愈)。
|
||||
"""
|
||||
# ── 段1:staff 滚动 + 敏感词增量(既有,原样保留) ──
|
||||
try:
|
||||
async with self._session_maker() as session:
|
||||
from app.analytics.sensitive import scanner
|
||||
from app.storage.staff_summary import recompute_days, rolling_days
|
||||
from app.storage.summary import day_key
|
||||
|
||||
today = day_key(int(time.time() * 1000))
|
||||
n = await recompute_days(session, rolling_days(today, 3))
|
||||
scan = await scanner.run_incremental(session)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"[analytics-tick] staff_days=%s scanned=%s hits=%s",
|
||||
n, scan.get("scanned"), scan.get("hits"),
|
||||
)
|
||||
except Exception: # noqa: BLE001 — scheduler MUST NOT crash
|
||||
logger.exception("analytics tick failed")
|
||||
|
||||
# ── 段2:管理域滚动(fact 活跃会话 + 宽表/通话,当日+滚动3天) ──
|
||||
try:
|
||||
async with self._session_maker() as session:
|
||||
from app.storage.employee_metrics import (
|
||||
recompute_call_days,
|
||||
recompute_metric_days,
|
||||
)
|
||||
from app.storage.first_reply_facts import (
|
||||
pairs_active_in_days,
|
||||
recompute_pairs,
|
||||
)
|
||||
from app.storage.staff_summary import rolling_days
|
||||
from app.storage.summary import day_key
|
||||
|
||||
rd = rolling_days(day_key(int(time.time() * 1000)), 3)
|
||||
pairs = await pairs_active_in_days(session, rd)
|
||||
f = await recompute_pairs(session, pairs)
|
||||
w = await recompute_metric_days(session, rd)
|
||||
c = await recompute_call_days(session, rd)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"[analytics-tick] mgmt fact_pairs=%s wide_rows=%s call_rows=%s",
|
||||
f, w, c,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("management metrics tick failed")
|
||||
```
|
||||
|
||||
(导入合并/去重按文件现状微调;today 变量两段各自取或共享只读。注:`recompute_metric_days` 内部会再查一次 `pairs_active_in_days`(与本段显式调用重复但廉价)——非冗余待修,fact 段需要显式拿 pairs 计数记日志。)
|
||||
|
||||
**测试追加**(照既有文件 mock 模式):
|
||||
1. tick 调用顺序:staff recompute_days → 敏感 scan → pairs_active_in_days(rolling3) → recompute_pairs → recompute_metric_days → recompute_call_days;
|
||||
2. **段2 失败隔离**:recompute_metric_days 抛异常 → staff recompute_days 与敏感 scan 仍被调用且各自 commit 成功、tick 不抛、异常被记(caplog);
|
||||
3. rolling 窗口传参 = rolling_days(today,3)。
|
||||
|
||||
- [ ] `pytest tests/test_scheduler_analytics_tick.py -v` + 全量 -q → commit `feat(management): 4h分析链接入管理域滚动段——独立事务隔离/fact活跃会话+宽表+通话滚动3天`
|
||||
|
||||
### Task F2: full_sync_all.sh 13/14 步 + 快照行数
|
||||
|
||||
**Files:** Modify `yunke-server/scripts/full_sync_all.sh`
|
||||
|
||||
第 12 步之后(COMPLETE 横幅之前)加:
|
||||
|
||||
```bash
|
||||
# 13:首响事实表全量重建(管理域,跨日精确串;批次A)——会话发现读
|
||||
# msg_daily_stats(fact 行直算读 message),故须在 11 步后;与 11/12
|
||||
# 同为夜间自愈链(绕过钩子的写入由全量重算纠正)
|
||||
echo "$(date) --- 13/14 First-reply-facts rebuild ---"
|
||||
$PY -m app.sync.cli first-reply-facts
|
||||
# 14:管理宽表+通话小表全量重建(驾驶舱/员工页读路径;串类列与 fact 同
|
||||
# 算法各自直算,放 fact 后保持派生链顺序语义)
|
||||
echo "$(date) --- 14/14 Employee-metrics rebuild ---"
|
||||
$PY -m app.sync.cli employee-metrics
|
||||
```
|
||||
|
||||
终局快照 TABLES 追加三行(放 daily stats rows 后):
|
||||
|
||||
```python
|
||||
("first reply facts", "SELECT COUNT(*) FROM first_reply_fact"),
|
||||
("employee metric rows", "SELECT COUNT(*) FROM employee_metric_daily"),
|
||||
("employee call rows", "SELECT COUNT(*) FROM employee_call_daily"),
|
||||
```
|
||||
|
||||
- [ ] `bash -n yunke-server/scripts/full_sync_all.sh` 语法过 + commit `feat(management): 夜间全量脚本13/14步——fact与宽表全量自愈+终局快照三表行数`
|
||||
|
||||
### Task F3: 本地 scheduler 容器重建 + 真机验证 + 归档
|
||||
|
||||
1. **真机验证滚动段**(直接跑 tick 逻辑,不等 4h):临时脚本或 `python -c` 用 scheduler 的 session_maker 调 `_analytics_tick()`(或手动等价:按 rolling 3 天调三个 recompute + commit)——记录耗时(预期 fact 活跃会话数十秒级内)+ 三表当日行数变化(recompute 后 updated 行 freshness);
|
||||
2. **幂等复跑**:连跑两次行数一致;
|
||||
3. **本地 scheduler 容器重建**(新代码进夜间链):`docker ps` 确认 scheduler 容器名 → `docker compose -f docker-compose.yml up -d --build scheduler`——**范围仅本地**(服务器 scheduler 镜像 2026-09-09 已重建过,服务器侧等部署时随 DEPLOY.md 流程走,本任务不碰);重建前后 `docker exec <scheduler> python -c "import app.sync.scheduler"` 冒烟;(已知后果不阻塞:phone_call 尚未进 4h/每日页面同步链[审计P0-5,并行会话领地]——通话小表滚动重算在补齐前只是从静态源幂等重生成);
|
||||
4. 归档:耗时/行数/容器重建记录 → 核实文档「批次F-调度」小节 + commit。
|
||||
|
||||
- [ ] commit `docs(management): 批次F调度接入验证——滚动段耗时/幂等/容器重建(清9d3cc8e旧账)`
|
||||
|
||||
## 验收清单
|
||||
|
||||
- [ ] tick 两段隔离测试过(段2 异常不牵连段1);
|
||||
- [ ] 夜间脚本 13/14 步 + 快照三表行数;
|
||||
- [ ] 滚动段真机耗时记录(预期 <60s)+ 幂等;
|
||||
- [ ] 本地 scheduler 容器已重建(新代码),夜间 02:00 链将含管理域;
|
||||
- [ ] 全量测试绿;
|
||||
- [ ] 未触碰审计 P0 领地(脚本加固/实体补抓——并行会话负责)。
|
||||
|
||||
## 后续(不在本子批)
|
||||
|
||||
E:辅导+快照;F 余项:dashboard 性能修复(unreplied 索引)、winsorize 决策、服务器部署(届时服务器 scheduler 镜像重建走 DEPLOY.md)。
|
||||
@@ -49,6 +49,7 @@ from app.api.routers import (
|
||||
export,
|
||||
groups,
|
||||
login,
|
||||
management,
|
||||
media,
|
||||
messages,
|
||||
moments,
|
||||
@@ -101,6 +102,7 @@ app.include_router(sessions.router)
|
||||
app.include_router(export.router)
|
||||
app.include_router(voice_clips.router)
|
||||
app.include_router(analytics.router)
|
||||
app.include_router(management.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
``owner_user_id`` column) and ``q`` (substring match on ``room_name``,
|
||||
``%``/``_`` escaped as literals — the 通讯录群 tab search box), ordered by
|
||||
``last_chat_time desc`` (nulls last) so recently-active groups surface first.
|
||||
微信群统计扩展(2026-09-09,云客「微信群统计」同款):``msg_start``/``msg_end``
|
||||
统计时段(窗内输出沟通消息数/新增/退出成员数聚合)、``last_start``/
|
||||
``last_end`` 最新消息时间段、``group_remark`` 群备注子串;行补员工微信/
|
||||
员工信息列(wechat_account/staff 关联)。
|
||||
* ``GET /api/groups/{room_id}/members`` -> ``list[GroupMemberOut]`` — the
|
||||
members of a single group (small set, no pagination needed).
|
||||
"""
|
||||
@@ -19,7 +23,15 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_session
|
||||
from app.api.schemas import GroupMemberOut, GroupOut, Page
|
||||
from app.db.models import ChatGroup, GroupMember
|
||||
from app.db.models import (
|
||||
ChatGroup,
|
||||
ChatGroupMemberStats,
|
||||
GroupMember,
|
||||
MsgDailyStats,
|
||||
Staff,
|
||||
WechatAccount,
|
||||
)
|
||||
from app.storage.summary import day_key
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["groups"])
|
||||
|
||||
@@ -38,6 +50,11 @@ async def list_groups(
|
||||
user_id: Annotated[str | None, Query()] = None,
|
||||
room_id: Annotated[str | None, Query()] = None,
|
||||
q: Annotated[str | None, Query()] = None,
|
||||
group_remark: Annotated[str | None, Query()] = None,
|
||||
last_start: Annotated[int | None, Query(ge=0)] = None,
|
||||
last_end: Annotated[int | None, Query(ge=0)] = None,
|
||||
msg_start: Annotated[int | None, Query(ge=0)] = None,
|
||||
msg_end: Annotated[int | None, Query(ge=0)] = None,
|
||||
) -> Page[GroupOut]:
|
||||
"""List chat groups with optional ``channel`` / ``owner_account_id`` /
|
||||
``user_id`` / ``room_id`` / ``q`` filters.
|
||||
@@ -47,6 +64,13 @@ async def list_groups(
|
||||
filter on ``GET /api/customers``. ``room_id`` is an exact-match lookup
|
||||
(composite PK: the same room may return one row per owner; chat page
|
||||
group-name resolution). ``q`` is a substring match on ``room_name``.
|
||||
|
||||
微信群统计参数(2026-09-09):``last_start``/``last_end`` 过滤
|
||||
``last_chat_time`` 范围(最新消息时间段);``msg_start``/``msg_end``
|
||||
统计时段——窗内聚合三列(``window_msg_count`` 沟通消息数=
|
||||
msg_daily_stats 按天求和、``window_added``/``window_lost`` 成员进出=
|
||||
chat_group_member_stats 按天求和,上线日起有数据);``group_remark``
|
||||
群备注子串。员工微信/员工信息列经 wechat_account/staff 关联补齐。
|
||||
"""
|
||||
conditions = []
|
||||
if channel is not None:
|
||||
@@ -57,10 +81,17 @@ async def list_groups(
|
||||
# LIKE 通配符转义: %/_ 按字面匹配(镜像 customers/messages 的 q 处理)
|
||||
escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
conditions.append(ChatGroup.room_name.like(f"%{escaped}%", escape="\\"))
|
||||
if group_remark:
|
||||
escaped_r = group_remark.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
conditions.append(ChatGroup.group_remark.like(f"%{escaped_r}%", escape="\\"))
|
||||
if room_id is not None:
|
||||
conditions.append(ChatGroup.room_id == room_id)
|
||||
if user_id is not None:
|
||||
conditions.append(ChatGroup.owner_user_id == user_id)
|
||||
if last_start is not None:
|
||||
conditions.append(ChatGroup.last_chat_time >= last_start)
|
||||
if last_end is not None:
|
||||
conditions.append(ChatGroup.last_chat_time <= last_end)
|
||||
|
||||
total_stmt = sa.select(sa.func.count()).select_from(ChatGroup)
|
||||
if conditions:
|
||||
@@ -70,16 +101,116 @@ async def list_groups(
|
||||
list_stmt = sa.select(ChatGroup)
|
||||
if conditions:
|
||||
list_stmt = list_stmt.where(*conditions)
|
||||
list_stmt = (
|
||||
list_stmt.order_by(
|
||||
sa.nullslast(ChatGroup.last_chat_time.desc()),
|
||||
ChatGroup.room_id.asc(),
|
||||
if (channel or "wechat") == "wechat":
|
||||
# 云客「微信群统计」同款排序(2026-09-10 真机终判):owner_account_id
|
||||
# (=微信号/wxid 混存,如 Nancyjade < csf7169708 < wxid_01gv…——大写
|
||||
# 在小写前=区分大小写)**二进制字节序升序**分组,组内 room_id 字符串
|
||||
# 升序。字节序按方言:MySQL 用 CAST(col AS BINARY)(collate("BINARY")
|
||||
# 会被 SQLAlchemy 反引号成标识符);SQLite 用 COLLATE BINARY(其 CAST
|
||||
# AS BINARY 语义不符)。WA 保持最近活跃优先。
|
||||
dialect = session.bind.dialect.name if session.bind is not None else ""
|
||||
if dialect == "mysql":
|
||||
owner_order = sa.literal_column(
|
||||
"CAST(chat_group.owner_account_id AS BINARY)"
|
||||
).asc()
|
||||
else:
|
||||
owner_order = ChatGroup.owner_account_id.collate("BINARY").asc()
|
||||
list_stmt = (
|
||||
list_stmt.order_by(
|
||||
owner_order,
|
||||
ChatGroup.room_id.asc(),
|
||||
)
|
||||
.limit(page_size)
|
||||
.offset((page - 1) * page_size)
|
||||
)
|
||||
else:
|
||||
list_stmt = (
|
||||
list_stmt.order_by(
|
||||
sa.nullslast(ChatGroup.last_chat_time.desc()),
|
||||
ChatGroup.room_id.asc(),
|
||||
)
|
||||
.limit(page_size)
|
||||
.offset((page - 1) * page_size)
|
||||
)
|
||||
.limit(page_size)
|
||||
.offset((page - 1) * page_size)
|
||||
)
|
||||
rows = (await session.execute(list_stmt)).scalars().all()
|
||||
items = [GroupOut.model_validate(row) for row in rows]
|
||||
|
||||
# ---- 员工微信/员工信息列(wechat_account/staff 关联,本页行集) ----
|
||||
acc_map: dict[str, tuple[str | None, str | None, str | None]] = {}
|
||||
owners = {r.owner_account_id for r in rows if r.owner_account_id}
|
||||
if owners:
|
||||
for wx, nick, alias, raw in (await session.execute(
|
||||
sa.select(
|
||||
WechatAccount.wx_id, WechatAccount.nick_name,
|
||||
WechatAccount.alias, WechatAccount.raw_json,
|
||||
).where(WechatAccount.wx_id.in_(owners))
|
||||
)).all():
|
||||
avatar = None
|
||||
if isinstance(raw, dict):
|
||||
h = raw.get("headImageUrl")
|
||||
avatar = h if isinstance(h, str) else None
|
||||
acc_map[wx] = (nick, alias, avatar)
|
||||
staff_map: dict[str, tuple[str | None, str | None]] = {}
|
||||
uids = {r.owner_user_id for r in rows if r.owner_user_id}
|
||||
if uids:
|
||||
for uid, name, phone in (await session.execute(
|
||||
sa.select(Staff.user_id, Staff.name, Staff.phone)
|
||||
.where(Staff.user_id.in_(uids))
|
||||
)).all():
|
||||
staff_map[uid] = (name, phone)
|
||||
|
||||
# ---- 统计时段窗内聚合(msg_daily_stats 沟通消息数 + member_stats 进出) ----
|
||||
msg_cnt: dict[tuple[str, str], int] = {}
|
||||
added_map: dict[tuple[str, str], int] = {}
|
||||
lost_map: dict[tuple[str, str], int] = {}
|
||||
if msg_start is not None and msg_end is not None and rows:
|
||||
d0, d1 = day_key(msg_start), day_key(msg_end)
|
||||
pairs = [(r.owner_account_id, r.room_id) for r in rows]
|
||||
owners_in = {p[0] for p in pairs}
|
||||
rooms_in = {p[1] for p in pairs}
|
||||
for owner, room, cnt in (await session.execute(
|
||||
sa.select(
|
||||
MsgDailyStats.owner_account_id, MsgDailyStats.peer_id,
|
||||
sa.func.coalesce(sa.func.sum(
|
||||
MsgDailyStats.send_cnt + MsgDailyStats.recv_cnt), 0),
|
||||
).where(
|
||||
MsgDailyStats.channel == (channel or "wechat"),
|
||||
MsgDailyStats.peer_kind == "room",
|
||||
MsgDailyStats.owner_account_id.in_(owners_in),
|
||||
MsgDailyStats.peer_id.in_(rooms_in),
|
||||
MsgDailyStats.day >= d0, MsgDailyStats.day <= d1,
|
||||
).group_by(MsgDailyStats.owner_account_id, MsgDailyStats.peer_id)
|
||||
)).all():
|
||||
msg_cnt[(owner, room)] = int(cnt)
|
||||
for owner, room, a, l in (await session.execute(
|
||||
sa.select(
|
||||
ChatGroupMemberStats.owner_account_id, ChatGroupMemberStats.room_id,
|
||||
sa.func.coalesce(sa.func.sum(ChatGroupMemberStats.added), 0),
|
||||
sa.func.coalesce(sa.func.sum(ChatGroupMemberStats.lost), 0),
|
||||
).where(
|
||||
ChatGroupMemberStats.channel == (channel or "wechat"),
|
||||
ChatGroupMemberStats.owner_account_id.in_(owners_in),
|
||||
ChatGroupMemberStats.room_id.in_(rooms_in),
|
||||
ChatGroupMemberStats.day >= d0, ChatGroupMemberStats.day <= d1,
|
||||
).group_by(ChatGroupMemberStats.owner_account_id, ChatGroupMemberStats.room_id)
|
||||
)).all():
|
||||
added_map[(owner, room)] = int(a)
|
||||
lost_map[(owner, room)] = int(l)
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
out = GroupOut.model_validate(r)
|
||||
acc = acc_map.get(r.owner_account_id or "")
|
||||
if acc:
|
||||
out.staff_nickname, out.staff_wx_alias, out.staff_avatar = acc
|
||||
st = staff_map.get(r.owner_user_id or "")
|
||||
if st:
|
||||
out.staff_name, out.staff_phone = st
|
||||
if msg_start is not None and msg_end is not None:
|
||||
key = (r.owner_account_id or "", r.room_id)
|
||||
out.window_msg_count = msg_cnt.get(key, 0)
|
||||
out.window_added = added_map.get(key, 0)
|
||||
out.window_lost = lost_map.get(key, 0)
|
||||
items.append(out)
|
||||
return Page(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -199,6 +199,17 @@ class GroupOut(BaseModel):
|
||||
owner_avatar: str | None = None
|
||||
friend_count: int | None = None
|
||||
create_time: int | None = None # 建群时间 ms epoch
|
||||
# 微信群统计页(2026-09-09,云客 getChatroomList 同款)
|
||||
group_remark: str | None = None # 群备注
|
||||
staff_nickname: str | None = None # 员工微信·昵称(wechat_account.nick_name)
|
||||
staff_wx_alias: str | None = None # 员工微信·微信号(wechat_account.alias)
|
||||
staff_avatar: str | None = None # 员工微信·头像
|
||||
staff_name: str | None = None # 员工信息·姓名(staff.name)
|
||||
staff_phone: str | None = None # 员工信息·手机(staff.phone)
|
||||
# 统计时段窗内聚合(仅请求带 msg_start/msg_end 时非 None):
|
||||
window_msg_count: int | None = None # 沟通消息数(窗内,msg_daily_stats)
|
||||
window_added: int | None = None # 新增成员数(窗内,按天表求和)
|
||||
window_lost: int | None = None # 退出成员数(窗内)
|
||||
raw_json: dict[str, Any] | None = None
|
||||
|
||||
|
||||
|
||||
@@ -344,6 +344,9 @@ class ChatGroup(Base):
|
||||
owner_avatar: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
friend_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 群成员中员工好友数
|
||||
create_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 建群时间 ms epoch
|
||||
# 群备注(微信群统计页「群备注」列;getChatroomList.remark /
|
||||
# friend_list/es 的 friendWxRemark,2026-09-09)
|
||||
group_remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
@@ -362,6 +365,34 @@ class ChatGroup(Base):
|
||||
)
|
||||
|
||||
|
||||
class ChatGroupMemberStats(Base):
|
||||
"""群成员进出按天统计(chat_group_member_stats,2026-09-09)。
|
||||
|
||||
云客「微信群统计」的新增/退出成员数是 ``getChatroomList`` 按
|
||||
``startTime~endTime`` 任意窗实时算的;本地无成员进出事件流,改为每夜
|
||||
抓一次「当日窗」的 addMembers/lossMembers 落一行,页面任意日窗=按天
|
||||
求和。**上线日起积累**(历史日期无数据;chat_group.add_members/
|
||||
loss_members 旧列保留作最近一次抓取窗的快照)。
|
||||
"""
|
||||
|
||||
__tablename__ = "chat_group_member_stats"
|
||||
|
||||
channel: Mapped[str] = mapped_column(String(16), primary_key=True)
|
||||
owner_account_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
room_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
# yyyymmdd(上海自然日,同 msg_daily_stats 口径)
|
||||
day: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
added: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default=sa.text("0")
|
||||
)
|
||||
lost: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default=sa.text("0")
|
||||
)
|
||||
|
||||
fetched_at: Mapped[dt.datetime] = _fetched_at()
|
||||
updated_at: Mapped[dt.datetime] = _updated_at()
|
||||
|
||||
|
||||
class GroupMember(Base):
|
||||
"""群成员 — composite PK (channel, room_id, member_id).
|
||||
|
||||
@@ -506,6 +537,18 @@ class Message(Base):
|
||||
"ix_message_fetched_at",
|
||||
"fetched_at",
|
||||
),
|
||||
# 该回未回决赛圈点查(owner+talker 定位末条消息)在 channel=None
|
||||
# (全平台计数——管理驾驶舱 action_counts 复用)时无渠道前缀条件,
|
||||
# 上面全部以 channel 打头的索引用不上,逐候选退化扫表+filesort:
|
||||
# 2026-09-10 服务器实测 dashboard 34~37s 撞前端 15s 超时
|
||||
# (ECONNABORTED);补无渠道前缀索引后 1.3~1.8s(100 万行在线建
|
||||
# 索引 49s)。「模型+迁移双声明」防 alembic autogenerate 反 DROP。
|
||||
Index(
|
||||
"ix_message_owner_talker_time",
|
||||
"owner_account_id",
|
||||
"talker_id",
|
||||
"timestamp",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1090,7 +1133,8 @@ class EmployeeMetricDaily(Base):
|
||||
reactivated_customer_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default=sa.text("0")
|
||||
)
|
||||
# fact 是否已覆盖当日(宽表生成前置条件;False=串类列可能滞后)
|
||||
# 预留完整性标记:宽表生成当前恒写 True(串类列与 fact 同一算法直算,
|
||||
# 不读 fact 表)——真实语义(如校验 fact 覆盖/同步完整)由批次B定义
|
||||
data_complete: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default=sa.text("0")
|
||||
)
|
||||
@@ -1102,8 +1146,9 @@ class EmployeeMetricDaily(Base):
|
||||
class EmployeeCallDaily(Base):
|
||||
"""员工×日通话小表(v2 §1.2 #9)——通话无消息渠道维度,独立于宽表。
|
||||
|
||||
phone_call.channel 是通话类型(phone_call/voice_call)非消息渠道,
|
||||
塞进 (day,channel,user) 宽表会在 channel=all 聚合时双计。接通 =
|
||||
phone_call.channel 是通话类型(实测值 voice_call/wa_voice_call,
|
||||
无蜂窝 phone_call 行)非消息渠道,塞进 (day,channel,user) 宽表会在
|
||||
channel=all 聚合时双计。接通 =
|
||||
call_duration > 0;followup = 通话后 24h 内该员工任一账号对该客户
|
||||
有消息(Task 0 已核实:1:1 通话可匹配率 100%,@chatroom 群通话除外)。
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""管理域确定性异常规则(批次B Task B3,v1 §5.4 异常卡片)。
|
||||
|
||||
4 条规则,输入=当前+个人基线 PeriodMetrics,输出异常卡片 dict 列表。
|
||||
全部确定性——不使用 LLM 推断,reason/title/suggested_action 均为固定
|
||||
中文模板填数,reason 只描述数据事实(百分比/样本数/未回串数)。
|
||||
|
||||
规则表(触发比较均为严格 > / <):
|
||||
- reply_p90_worsened : cur.fr_p90_ms > base×1.5 且 cur.cust_threads≥
|
||||
soft_threads;>×2.5 升 critical
|
||||
- reply_rate_dropped : derive(reply_2h_rate) cur < base 同式×0.7 且
|
||||
cur.cust_threads≥soft_threads(率由 scoring.
|
||||
derive_metrics 派生,与评分同一口径)
|
||||
- activity_drop : cur 日均 (msg_sent+msg_recv)/n_days < base 日均
|
||||
(…/28)×0.5 且 base.days_with_data≥min_days
|
||||
(基线太稀疏不可比;基线窗口恒 28 天=baseline_window
|
||||
语义,当前窗口天数由 n_days 传入)
|
||||
- workload_overload : cur.active_friends(周期 distinct) > workload 维
|
||||
active_friends_band.high(与评分 band 同源阈值)
|
||||
|
||||
无基线不判:baseline=None 直接返回 [](新员工无个人对比位即不出卡,
|
||||
含与基线无关的 overload——新入职负载爬坡期误报多,不判);某侧值
|
||||
None/不可算(无串样本的率、无 fact 的分位、0ms 退化基线)→ 对应规则
|
||||
不触发。
|
||||
|
||||
纯函数(无 session/IO):B4 dashboard/employees 对全员逐个调用。
|
||||
阈值不硬编码:soft_threads/min_days 取 config.sample_guards,过载上限取
|
||||
config.dimensions.workload.metrics.active_friends_band.high;乘数
|
||||
(1.5/2.5/0.7/0.5)是规则语义本身,定义为模块常量而非可调参数。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.management.employee_metrics import PeriodMetrics
|
||||
from app.management.scoring import derive_metrics
|
||||
|
||||
# 规则乘数(语义常量,非可调参数——调整即换规则版本)
|
||||
P90_WARN_MULTIPLIER = 1.5
|
||||
P90_CRITICAL_MULTIPLIER = 2.5
|
||||
RATE_DROP_MULTIPLIER = 0.7
|
||||
ACTIVITY_DROP_MULTIPLIER = 0.5
|
||||
BASELINE_WINDOW_DAYS = 28 # 个人基线窗口恒 28 天(employee_metrics.baseline_window)
|
||||
|
||||
# ---- 固定文案模板(.format 填数,reason 只含数据事实) ----
|
||||
TITLE_P90 = "回复 P90 较个人基线上升 {pct}%"
|
||||
TITLE_RATE = "2h 回复率较个人基线下降 {pct}%"
|
||||
TITLE_ACTIVITY = "日均消息量较个人基线下降 {pct}%"
|
||||
TITLE_OVERLOAD = "活跃客户 {current} 人超过负载上限 {limit} 人"
|
||||
|
||||
REASON_P90 = (
|
||||
"近 {n_days} 天首响 P90 为 {cur}(基线前 28 天为 {base}),上升 {pct}%;"
|
||||
"期内 {threads} 串客户消息中 {unreplied} 串未回"
|
||||
)
|
||||
REASON_RATE = (
|
||||
"近 {n_days} 天 2h 回复率为 {cur:.0%}({cur_cnt}/{cur_thr} 串),"
|
||||
"基线(前 28 天)为 {base:.0%}({base_cnt}/{base_thr} 串);"
|
||||
"期内 {unreplied} 串未回"
|
||||
)
|
||||
REASON_ACTIVITY = (
|
||||
"近 {n_days} 天日均消息 {cur:.1f} 条/天,基线(前 28 天)日均 {base:.1f} 条/天,"
|
||||
"下降 {pct}%;当前活跃客户 {friends} 人"
|
||||
)
|
||||
REASON_OVERLOAD = (
|
||||
"近 {n_days} 天活跃客户 {current} 人(周期 distinct 口径),"
|
||||
"超过配置上限 {limit} 人"
|
||||
)
|
||||
|
||||
ACTION_P90 = "查看超时未回客户明细,与员工确认近期负载与接待安排"
|
||||
ACTION_RATE = "查看未回串明细,排查回复流程与时段覆盖问题"
|
||||
ACTION_ACTIVITY = "与员工确认在岗状态与客户维护情况,并核对账号数据接入是否正常"
|
||||
ACTION_OVERLOAD = "评估客户分配与负载均衡,考虑分流部分客户或安排协助"
|
||||
|
||||
|
||||
def _fmt_ms(ms: float) -> str:
|
||||
# 与 scoring._fmt_ms 同式(模块私有不跨文件引用)
|
||||
minutes = ms / 60_000
|
||||
if minutes >= 60 and minutes % 60 == 0:
|
||||
return f"{minutes // 60:g}h"
|
||||
return f"{minutes:g}min"
|
||||
|
||||
|
||||
def _pct_up(cur: float, base: float) -> int:
|
||||
"""较基线上升百分比(四舍五入取整;触发后调用,base>0 已保证)。"""
|
||||
return int(round((cur - base) / base * 100))
|
||||
|
||||
|
||||
def _pct_down(cur: float, base: float) -> int:
|
||||
"""较基线下降百分比(触发后调用,base>0 已由触发条件保证)。"""
|
||||
return int(round((base - cur) / base * 100))
|
||||
|
||||
|
||||
def detect_anomalies(
|
||||
user_id: str,
|
||||
current: PeriodMetrics,
|
||||
baseline: PeriodMetrics | None,
|
||||
*,
|
||||
n_days: int,
|
||||
config: dict,
|
||||
) -> list[dict]:
|
||||
"""单员工确定性异常检测(纯函数,卡片结构见 v1 §5.4/模块 docstring)。
|
||||
|
||||
输出按规则表序:p90 恶化 → 回复率骤降 → 活动骤降 → 过载
|
||||
(排序/聚合由 B4 调用方按 severity/affected_count 处理)。
|
||||
"""
|
||||
if baseline is None:
|
||||
return [] # 无基线不判(新员工)
|
||||
guards = config.get("sample_guards", {})
|
||||
soft = guards.get("soft_threads", 10)
|
||||
min_days = guards.get("min_days", 5)
|
||||
band_high = (
|
||||
config.get("dimensions", {})
|
||||
.get("workload", {})
|
||||
.get("metrics", {})
|
||||
.get("active_friends_band", {})
|
||||
.get("high")
|
||||
)
|
||||
cards: list[dict] = []
|
||||
unreplied = current.cust_threads - current.threads_answered
|
||||
threads_ok = current.cust_threads >= soft
|
||||
|
||||
# ---- 1. reply_p90_worsened:P90 较个人基线恶化 ×1.5/×2.5 ----
|
||||
if (
|
||||
threads_ok
|
||||
and current.fr_p90_ms is not None
|
||||
and baseline.fr_p90_ms is not None
|
||||
and baseline.fr_p90_ms > 0 # 0ms 基线为退化数据,不可比
|
||||
):
|
||||
base_p90 = baseline.fr_p90_ms
|
||||
if current.fr_p90_ms > base_p90 * P90_CRITICAL_MULTIPLIER:
|
||||
severity = "critical"
|
||||
elif current.fr_p90_ms > base_p90 * P90_WARN_MULTIPLIER:
|
||||
severity = "warning"
|
||||
else:
|
||||
severity = None
|
||||
if severity is not None:
|
||||
pct = _pct_up(current.fr_p90_ms, base_p90)
|
||||
cards.append({
|
||||
"rule_code": "reply_p90_worsened",
|
||||
"severity": severity,
|
||||
"employee_user_id": user_id,
|
||||
"title": TITLE_P90.format(pct=pct),
|
||||
"current_value": current.fr_p90_ms,
|
||||
"baseline_value": base_p90,
|
||||
"unit": "ms",
|
||||
"sample_size": current.cust_threads,
|
||||
"affected_count": unreplied,
|
||||
"reason": REASON_P90.format(
|
||||
n_days=n_days, cur=_fmt_ms(current.fr_p90_ms),
|
||||
base=_fmt_ms(base_p90), pct=pct,
|
||||
threads=current.cust_threads, unreplied=unreplied,
|
||||
),
|
||||
"drilldown": {"type": "metric", "query": {
|
||||
"metric_code": "first_reply_p90", "user_id": user_id}},
|
||||
"suggested_action": ACTION_P90,
|
||||
})
|
||||
|
||||
# ---- 2. reply_rate_dropped:2h 回复率较基线降 30%+ ----
|
||||
if threads_ok:
|
||||
cur_rate = derive_metrics(current)["reply_2h_rate"]
|
||||
base_rate = derive_metrics(baseline)["reply_2h_rate"]
|
||||
if (
|
||||
cur_rate is not None and base_rate is not None
|
||||
and cur_rate < base_rate * RATE_DROP_MULTIPLIER
|
||||
):
|
||||
cards.append({
|
||||
"rule_code": "reply_rate_dropped",
|
||||
"severity": "warning",
|
||||
"employee_user_id": user_id,
|
||||
"title": TITLE_RATE.format(
|
||||
pct=_pct_down(cur_rate, base_rate)),
|
||||
"current_value": cur_rate,
|
||||
"baseline_value": base_rate,
|
||||
"unit": "0-1",
|
||||
"sample_size": current.cust_threads,
|
||||
"affected_count": unreplied,
|
||||
"reason": REASON_RATE.format(
|
||||
n_days=n_days, cur=cur_rate, cur_cnt=current.reply_2h_count,
|
||||
cur_thr=current.cust_threads, base=base_rate,
|
||||
base_cnt=baseline.reply_2h_count,
|
||||
base_thr=baseline.cust_threads, unreplied=unreplied,
|
||||
),
|
||||
"drilldown": {"type": "metric", "query": {
|
||||
"metric_code": "unreplied", "user_id": user_id}},
|
||||
"suggested_action": ACTION_RATE,
|
||||
})
|
||||
|
||||
# ---- 3. activity_drop:日均消息量较基线腰斩 ----
|
||||
if n_days > 0 and baseline.days_with_data >= min_days:
|
||||
cur_daily = (current.msg_sent + current.msg_recv) / n_days
|
||||
base_daily = (
|
||||
baseline.msg_sent + baseline.msg_recv
|
||||
) / BASELINE_WINDOW_DAYS
|
||||
if cur_daily < base_daily * ACTIVITY_DROP_MULTIPLIER:
|
||||
cards.append({
|
||||
"rule_code": "activity_drop",
|
||||
"severity": "warning",
|
||||
"employee_user_id": user_id,
|
||||
"title": TITLE_ACTIVITY.format(
|
||||
pct=_pct_down(cur_daily, base_daily)),
|
||||
"current_value": cur_daily,
|
||||
"baseline_value": base_daily,
|
||||
"unit": "条",
|
||||
"sample_size": current.active_friends,
|
||||
"affected_count": current.active_friends,
|
||||
"reason": REASON_ACTIVITY.format(
|
||||
n_days=n_days, cur=cur_daily, base=base_daily,
|
||||
pct=_pct_down(cur_daily, base_daily),
|
||||
friends=current.active_friends,
|
||||
),
|
||||
"drilldown": {"type": "employee",
|
||||
"query": {"user_id": user_id}},
|
||||
"suggested_action": ACTION_ACTIVITY,
|
||||
})
|
||||
|
||||
# ---- 4. workload_overload:活跃客户超 band 上限 ----
|
||||
if band_high is not None and current.active_friends > band_high:
|
||||
cards.append({
|
||||
"rule_code": "workload_overload",
|
||||
"severity": "warning",
|
||||
"employee_user_id": user_id,
|
||||
"title": TITLE_OVERLOAD.format(
|
||||
current=current.active_friends, limit=band_high),
|
||||
"current_value": current.active_friends,
|
||||
"baseline_value": None, # 与个人基线无关,阈值在 band 配置
|
||||
"unit": "人",
|
||||
"sample_size": current.active_friends,
|
||||
"affected_count": current.active_friends,
|
||||
"reason": REASON_OVERLOAD.format(
|
||||
n_days=n_days, current=current.active_friends,
|
||||
limit=band_high,
|
||||
),
|
||||
"drilldown": {"type": "employee", "query": {"user_id": user_id}},
|
||||
"suggested_action": ACTION_OVERLOAD,
|
||||
})
|
||||
|
||||
return cards
|
||||
@@ -0,0 +1,533 @@
|
||||
"""管理 API 组装层(批次B Task B4)——router 六端点共用的重查询与行构造。
|
||||
|
||||
拆出 router 的原因:employees/dashboard 的组装(多窗聚合 + 评分 + 异常 +
|
||||
对比派生)上百行,放 router 会淹没端点定义;本模块只做数据组装,参数
|
||||
校验/HTTP 语义留在 router。
|
||||
|
||||
查询纪律(与 analytics.metrics 同):**禁止 asyncio.gather**——单
|
||||
AsyncSession 不能并发执行查询,全部顺序 await。重端点不做 N+1:
|
||||
period_metrics 每窗一次调用全员;员工循环(评分/异常)是纯函数。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import statistics
|
||||
from bisect import bisect_right
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.analytics.lifecycle import classify
|
||||
from app.db.models import (
|
||||
Customer,
|
||||
EmployeeCallDaily,
|
||||
EmployeeMetricDaily,
|
||||
EmployeeScoreConfig,
|
||||
FirstReplyFact,
|
||||
MsgDailyStats,
|
||||
PhoneCall,
|
||||
Staff,
|
||||
)
|
||||
from app.management.anomalies import detect_anomalies
|
||||
from app.management.employee_metrics import (
|
||||
PeriodMetrics,
|
||||
baseline_window,
|
||||
day_range,
|
||||
percentile,
|
||||
period_metrics,
|
||||
prev_window,
|
||||
team_medians,
|
||||
)
|
||||
from app.management.metric_definitions import DEFAULT_SCORE_CONFIG
|
||||
from app.management.scoring import derive_metrics, score_employee
|
||||
from app.storage.employee_metrics import METRIC_VERSION
|
||||
from app.storage.staff_summary import account_map
|
||||
from app.storage.summary import day_bounds_ms
|
||||
|
||||
# 异常权重(anomaly_severity 排序/汇总):critical=2 / warning=1
|
||||
SEVERITY_WEIGHT = {"critical": 2, "warning": 1}
|
||||
|
||||
# 派生指标中需对「员工派生值」另取中位的键(scoring.team 键=派生指标名;
|
||||
# 其余如 fr_p90_ms/active_friends/call_count 直接用 team_medians 输出)
|
||||
_DERIVED_MEDIAN_KEYS = ("reply_2h_rate", "reply_24h_rate", "active_share")
|
||||
|
||||
|
||||
async def load_score_config(session: AsyncSession) -> tuple[dict, str]:
|
||||
"""激活评分配置(DB)→ (config_json, "db");无 active 行 →
|
||||
(DEFAULT_SCORE_CONFIG 深拷贝, "default")——未跑种子的环境也能出分。"""
|
||||
row = (await session.execute(
|
||||
sa.select(EmployeeScoreConfig)
|
||||
.where(EmployeeScoreConfig.status == "active")
|
||||
.order_by(EmployeeScoreConfig.id.desc())
|
||||
.limit(1)
|
||||
)).scalar_one_or_none()
|
||||
if row is not None:
|
||||
return row.config_json, "db"
|
||||
return copy.deepcopy(DEFAULT_SCORE_CONFIG), "default"
|
||||
|
||||
|
||||
def team_derived_medians(metrics: dict[str, PeriodMetrics]) -> dict[str, float | None]:
|
||||
"""评分用团队中位:PeriodMetrics 字段中位 + 派生率/占比的员工级中位
|
||||
(scoring 要求 team 键=派生指标名,见其模块 docstring)。"""
|
||||
team = dict(team_medians(metrics))
|
||||
for key in _DERIVED_MEDIAN_KEYS:
|
||||
vals = [
|
||||
v for v in (derive_metrics(pm).get(key) for pm in metrics.values())
|
||||
if v is not None
|
||||
]
|
||||
team[key] = statistics.median(vals) if vals else None
|
||||
return team
|
||||
|
||||
|
||||
def score_with_guards(
|
||||
config: dict,
|
||||
cur: PeriodMetrics,
|
||||
baseline: PeriodMetrics | None = None,
|
||||
team: dict[str, float] | None = None,
|
||||
prev: PeriodMetrics | None = None,
|
||||
) -> dict:
|
||||
"""score_employee + min_days 接线(B2 评审遗留):当前窗天数 <
|
||||
config.sample_guards.min_days → score_status 覆盖 insufficient_sample,
|
||||
total/明细保留展示(按天稀疏的数据不参与考核但可查看)。"""
|
||||
score = score_employee(config, cur, baseline=baseline, team=team, prev=prev)
|
||||
min_days = config.get("sample_guards", {}).get("min_days", 5)
|
||||
if cur.days_with_data < min_days:
|
||||
score["score_status"] = "insufficient_sample"
|
||||
return score
|
||||
|
||||
|
||||
async def assemble_employees(
|
||||
session: AsyncSession,
|
||||
config: dict,
|
||||
start_day: int,
|
||||
end_day: int,
|
||||
channel: str | None,
|
||||
*,
|
||||
with_prev: bool = True,
|
||||
) -> dict[str, dict]:
|
||||
"""全员组装:staff → 当前/基线(/前)窗 period_metrics → 逐员工
|
||||
score_with_guards + detect_anomalies(纯函数循环)。
|
||||
|
||||
dashboard 传 with_prev=False(只需评分+异常,省一次前窗聚合);
|
||||
employees/detail 需要 prev/团队中位时传 True。返回
|
||||
{uid: {name, is_on_job, cur, prev, baseline, score, anomalies,
|
||||
anomaly_count, anomaly_weight}}——过滤/排序/分页由调用方做。
|
||||
"""
|
||||
staff_rows = (await session.execute(
|
||||
sa.select(Staff.user_id, Staff.name, Staff.is_on_job)
|
||||
)).all()
|
||||
staff = {uid: (name, onjob) for uid, name, onjob in staff_rows if uid}
|
||||
if not staff:
|
||||
return {}
|
||||
|
||||
n_days = len(day_range(start_day, end_day))
|
||||
cur = await period_metrics(session, start_day, end_day, channel)
|
||||
bs, be = baseline_window(start_day)
|
||||
base = await period_metrics(session, bs, be, channel)
|
||||
prev: dict[str, PeriodMetrics] | None = None
|
||||
team: dict[str, float | None] | None = None
|
||||
if with_prev:
|
||||
ps, pe = prev_window(start_day, end_day)
|
||||
prev = await period_metrics(session, ps, pe, channel)
|
||||
team = team_derived_medians(cur)
|
||||
|
||||
out: dict[str, dict] = {}
|
||||
for uid, (name, onjob) in staff.items():
|
||||
c = cur.get(uid)
|
||||
if c is None: # 防御:period_metrics 恒含全员,此处不该走到
|
||||
continue
|
||||
b = base.get(uid)
|
||||
p = prev.get(uid) if prev is not None else None
|
||||
score = score_with_guards(config, c, baseline=b, team=team, prev=p)
|
||||
anomalies = detect_anomalies(uid, c, b, n_days=n_days, config=config)
|
||||
out[uid] = {
|
||||
"name": name,
|
||||
"is_on_job": onjob,
|
||||
"cur": c,
|
||||
"prev": p,
|
||||
"baseline": b,
|
||||
"score": score,
|
||||
"anomalies": anomalies,
|
||||
"anomaly_count": len(anomalies),
|
||||
"anomaly_weight": sum(
|
||||
SEVERITY_WEIGHT.get(a["severity"], 0) for a in anomalies
|
||||
),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def _code_to_source(config: dict) -> dict[str, str]:
|
||||
"""配置指标 code → 源指标名(spec.metric,无则 code 本身)。
|
||||
|
||||
comparisons 键域统一为源指标名(与 derive_metrics / sections 端点一致,
|
||||
冻结批次C契约):active_friends_band→active_friends、call_band→call_count,
|
||||
其余 code 本身即源名。"""
|
||||
out: dict[str, str] = {}
|
||||
for dim in config.get("dimensions", {}).values():
|
||||
for code, spec in dim.get("metrics", {}).items():
|
||||
out[code] = spec.get("metric", code)
|
||||
return out
|
||||
|
||||
|
||||
def build_employee_row(uid: str, data: dict, config: dict) -> dict:
|
||||
"""employees 列表行:metrics 全字段 + comparisons 四值(自评分
|
||||
metrics_detail 透传,键映射为源指标名)+ score 摘要 + 异常计数/权重。"""
|
||||
score = data["score"]
|
||||
code_src = _code_to_source(config)
|
||||
return {
|
||||
"user_id": uid,
|
||||
"name": data["name"],
|
||||
"is_on_job": data["is_on_job"],
|
||||
"metrics": data["cur"]._asdict(),
|
||||
"comparisons": {
|
||||
code_src.get(code, code): {
|
||||
"current": d["value"],
|
||||
"prev": d["prev"],
|
||||
"baseline": d["baseline"],
|
||||
"median": d["median"],
|
||||
}
|
||||
for code, d in score["metrics_detail"].items()
|
||||
},
|
||||
"score": {
|
||||
"total_score": score["total_score"],
|
||||
"grade": score["grade"],
|
||||
"score_status": score["score_status"],
|
||||
"trial": score["trial"],
|
||||
"dimensions": {
|
||||
key: {"score": dim["score"], "status": dim["status"]}
|
||||
for key, dim in score["dimensions"].items()
|
||||
},
|
||||
},
|
||||
"anomaly_count": data["anomaly_count"],
|
||||
"anomaly_weight": data["anomaly_weight"],
|
||||
}
|
||||
|
||||
|
||||
def derived_comparisons(
|
||||
cur: PeriodMetrics,
|
||||
prev: PeriodMetrics | None,
|
||||
baseline: PeriodMetrics | None,
|
||||
team: dict[str, float | None] | None,
|
||||
) -> dict[str, dict]:
|
||||
"""四对比(current/prev/baseline/median),派生口径=scoring.derive_metrics
|
||||
——评分、异常、展示三方同一派生式。"""
|
||||
cv = derive_metrics(cur)
|
||||
pv = derive_metrics(prev) if prev is not None else {}
|
||||
bv = derive_metrics(baseline) if baseline is not None else {}
|
||||
return {
|
||||
key: {
|
||||
"current": cv.get(key),
|
||||
"prev": pv.get(key),
|
||||
"baseline": bv.get(key),
|
||||
"median": team.get(key) if team else None,
|
||||
}
|
||||
for key in cv
|
||||
}
|
||||
|
||||
|
||||
async def wide_daily_rows(
|
||||
session: AsyncSession,
|
||||
start_day: int,
|
||||
end_day: int,
|
||||
channel: str | None,
|
||||
user_id: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""宽表逐日聚合(加性列跨渠道求和;user_id=None 为全员合计)。
|
||||
|
||||
first_reply_p50/p90_ms 是 (day,channel,user) 行内单值不可求和:
|
||||
单员工视图(channel=None 双渠道同日)取当日 cust_threads 较大渠道的
|
||||
值(当日主力渠道,并列取 wechat);全员视图不取用这两列(分位由
|
||||
service_daily_trend 从 fact 精确算)。
|
||||
"""
|
||||
conds = [
|
||||
EmployeeMetricDaily.day.between(start_day, end_day),
|
||||
EmployeeMetricDaily.metric_version == METRIC_VERSION,
|
||||
]
|
||||
if channel is not None:
|
||||
conds.append(EmployeeMetricDaily.channel == channel)
|
||||
if user_id is not None:
|
||||
conds.append(EmployeeMetricDaily.user_id == user_id)
|
||||
rows = (await session.execute(
|
||||
sa.select(
|
||||
EmployeeMetricDaily.day.label("day"),
|
||||
EmployeeMetricDaily.channel.label("ch"),
|
||||
sa.func.sum(EmployeeMetricDaily.msg_sent).label("sent"),
|
||||
sa.func.sum(EmployeeMetricDaily.msg_recv).label("recv"),
|
||||
sa.func.sum(EmployeeMetricDaily.active_friends).label("af"),
|
||||
sa.func.sum(EmployeeMetricDaily.active_rooms).label("ar"),
|
||||
sa.func.sum(EmployeeMetricDaily.cust_threads).label("thr"),
|
||||
sa.func.sum(EmployeeMetricDaily.threads_answered).label("ans"),
|
||||
sa.func.sum(EmployeeMetricDaily.fr_sum_ms).label("frs"),
|
||||
sa.func.sum(EmployeeMetricDaily.new_customer_count).label("nw"),
|
||||
sa.func.sum(EmployeeMetricDaily.cooling_customer_count).label("cool"),
|
||||
sa.func.sum(EmployeeMetricDaily.dormant_customer_count).label("dorm"),
|
||||
sa.func.sum(EmployeeMetricDaily.reactivated_customer_count).label("react"),
|
||||
sa.func.max(EmployeeMetricDaily.first_reply_p50_ms).label("p50"),
|
||||
sa.func.max(EmployeeMetricDaily.first_reply_p90_ms).label("p90"),
|
||||
).where(*conds).group_by(EmployeeMetricDaily.day, EmployeeMetricDaily.channel)
|
||||
)).all()
|
||||
|
||||
per_day: dict[int, dict] = {}
|
||||
|
||||
def _blank(day: int) -> dict:
|
||||
return {
|
||||
"day": day, "msg_sent": 0, "msg_recv": 0, "active_friends": 0,
|
||||
"active_rooms": 0, "cust_threads": 0, "threads_answered": 0,
|
||||
"fr_sum_ms": 0, "new_customer_count": 0,
|
||||
"cooling_customer_count": 0, "dormant_customer_count": 0,
|
||||
"reactivated_customer_count": 0,
|
||||
"first_reply_p50_ms": None, "first_reply_p90_ms": None,
|
||||
"_pick": (-1, -1), # 主力渠道追踪 (cust_threads, wechat 加分)
|
||||
}
|
||||
|
||||
for (day, ch, sent, recv, af, ar, thr, ans, frs, nw, cool, dorm,
|
||||
react, p50, p90) in rows:
|
||||
d = per_day.setdefault(day, _blank(day))
|
||||
d["msg_sent"] += int(sent or 0)
|
||||
d["msg_recv"] += int(recv or 0)
|
||||
d["active_friends"] += int(af or 0)
|
||||
d["active_rooms"] += int(ar or 0)
|
||||
d["cust_threads"] += int(thr or 0)
|
||||
d["threads_answered"] += int(ans or 0)
|
||||
d["fr_sum_ms"] += int(frs or 0)
|
||||
d["new_customer_count"] += int(nw or 0)
|
||||
d["cooling_customer_count"] += int(cool or 0)
|
||||
d["dormant_customer_count"] += int(dorm or 0)
|
||||
d["reactivated_customer_count"] += int(react or 0)
|
||||
if user_id is not None:
|
||||
pick = (int(thr or 0), 1 if ch == "wechat" else 0)
|
||||
if pick > d["_pick"]:
|
||||
d["_pick"] = pick
|
||||
d["first_reply_p50_ms"] = p50
|
||||
d["first_reply_p90_ms"] = p90
|
||||
|
||||
out = []
|
||||
for day in sorted(per_day):
|
||||
d = per_day[day]
|
||||
d.pop("_pick", None)
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
async def call_daily_rows(
|
||||
session: AsyncSession,
|
||||
start_day: int,
|
||||
end_day: int,
|
||||
user_id: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""通话小表逐日聚合(恒全渠道——表无渠道维);user_id=None 为全员。"""
|
||||
conds = [EmployeeCallDaily.day.between(start_day, end_day)]
|
||||
if user_id is not None:
|
||||
conds.append(EmployeeCallDaily.user_id == user_id)
|
||||
rows = (await session.execute(
|
||||
sa.select(
|
||||
EmployeeCallDaily.day,
|
||||
sa.func.sum(EmployeeCallDaily.call_count),
|
||||
sa.func.sum(EmployeeCallDaily.call_seconds),
|
||||
sa.func.sum(EmployeeCallDaily.answered_call_count),
|
||||
sa.func.sum(EmployeeCallDaily.call_followup_count),
|
||||
).where(*conds).group_by(EmployeeCallDaily.day)
|
||||
)).all()
|
||||
return [
|
||||
{
|
||||
"day": day, "call_count": int(cc or 0),
|
||||
"call_seconds": int(cs or 0),
|
||||
"answered_call_count": int(ca or 0),
|
||||
"call_followup_count": int(cf or 0),
|
||||
}
|
||||
for day, cc, cs, ca, cf in rows
|
||||
]
|
||||
|
||||
|
||||
async def service_daily_trend(
|
||||
session: AsyncSession,
|
||||
start_day: int,
|
||||
end_day: int,
|
||||
channel: str | None,
|
||||
threads_by_day: dict[int, int],
|
||||
) -> list[dict]:
|
||||
"""服务趋势(dashboard 全团队):fact 逐日 (day, fr_ms) 行拉回 Python
|
||||
最近邻秩分位(跨员工合并样本——团队级分位);reply_2h_rate = fact
|
||||
当日 ≤2h 计数 / 宽表当日 cust_threads 合计(分母 0 → None)。
|
||||
|
||||
输出全窗口逐日(无数据日 p50/p90 为 None),threads_by_day 来自
|
||||
wide_daily_rows 的全员 cust_threads 列。
|
||||
"""
|
||||
conds = [FirstReplyFact.day.between(start_day, end_day)]
|
||||
if channel is not None:
|
||||
conds.append(FirstReplyFact.channel == channel)
|
||||
rows = (await session.execute(
|
||||
sa.select(FirstReplyFact.day, FirstReplyFact.fr_ms).where(*conds)
|
||||
)).all()
|
||||
by_day: dict[int, list[int]] = {}
|
||||
for day, fr in rows:
|
||||
by_day.setdefault(day, []).append(int(fr))
|
||||
out = []
|
||||
for day in day_range(start_day, end_day):
|
||||
frs = by_day.get(day, [])
|
||||
cnt_2h = sum(1 for f in frs if f <= 7_200_000)
|
||||
denom = threads_by_day.get(day, 0)
|
||||
out.append({
|
||||
"day": day,
|
||||
"fr_p50_ms": percentile(frs, 1, 2),
|
||||
"fr_p90_ms": percentile(frs, 9, 10),
|
||||
"reply_2h_rate": (cnt_2h / denom) if denom else None,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
async def user_owner_accounts(
|
||||
session: AsyncSession, user_id: str, channel: str | None
|
||||
) -> list[str]:
|
||||
"""员工在所选渠道下的 owner 账号(channel=None → 两渠道合并)。"""
|
||||
chs = ("wechat", "whatsapp") if channel is None else (channel,)
|
||||
out: list[str] = []
|
||||
for ch in chs:
|
||||
amap = await account_map(session, ch)
|
||||
out.extend(owner for owner, uid in amap.items() if uid == user_id)
|
||||
return out
|
||||
|
||||
|
||||
async def lifecycle_customers(
|
||||
session: AsyncSession,
|
||||
owner_accounts: list[str],
|
||||
as_of_day: int,
|
||||
want_state: str,
|
||||
) -> list[dict]:
|
||||
"""drilldown cooling/dormant:该员工账号归属客户(delete_flag 过滤)+
|
||||
msg_daily_stats friend 行全历史 → lifecycle.classify(as_of=窗口末日),
|
||||
返回状态匹配行 {friend_id, channel, nickname, last_day, state},按 last_day asc
|
||||
(最陈旧在前)、friend_id asc 排序。
|
||||
|
||||
历史按 (channel, peer) 键控、跨 owner 合并——与批次A _lifecycle_daily
|
||||
的 peer 键控口径一致(归属由 Customer.owner_account_id 决定,互动史
|
||||
不随归属走:共享/换归属客户的全部历史都算,否则 KPI 计了而名单丢人)。
|
||||
hist 为 ≤as_of 的历史消息数(时点语义,as_of 之后的消息不虚增沉睡门槛)。"""
|
||||
custs = (await session.execute(
|
||||
sa.select(Customer).where(
|
||||
sa.or_(Customer.delete_flag.is_(None), Customer.delete_flag == 0),
|
||||
Customer.owner_account_id.in_(owner_accounts),
|
||||
)
|
||||
)).scalars().all()
|
||||
if not custs:
|
||||
return []
|
||||
ids = [c.friend_id for c in custs]
|
||||
rows = (await session.execute(
|
||||
sa.select(
|
||||
MsgDailyStats.channel,
|
||||
MsgDailyStats.peer_id,
|
||||
MsgDailyStats.day,
|
||||
MsgDailyStats.send_cnt,
|
||||
MsgDailyStats.recv_cnt,
|
||||
).where(
|
||||
MsgDailyStats.peer_kind == "friend",
|
||||
MsgDailyStats.peer_id.in_(ids),
|
||||
)
|
||||
)).all()
|
||||
hist: dict[tuple[str, str], dict[int, int]] = {}
|
||||
for ch, peer, day, snd, rcv in rows:
|
||||
dm = hist.setdefault((ch, peer), {})
|
||||
dm[day] = dm.get(day, 0) + int(snd or 0) + int(rcv or 0)
|
||||
|
||||
matches: list[dict] = []
|
||||
for c in custs:
|
||||
dm = hist.get((c.channel, c.friend_id))
|
||||
if not dm:
|
||||
continue
|
||||
days_list = sorted(dm)
|
||||
idx = bisect_right(days_list, as_of_day) - 1
|
||||
if idx < 0:
|
||||
continue # 窗口末日之前从未互动
|
||||
last = days_list[idx]
|
||||
prev_d = days_list[idx - 1] if idx > 0 else None
|
||||
hist_msgs = sum(dm[d] for d in days_list[: idx + 1])
|
||||
state = classify(days_list[0], last, prev_d, as_of_day, hist_msgs)
|
||||
if state == want_state:
|
||||
matches.append({
|
||||
"friend_id": c.friend_id,
|
||||
"channel": c.channel,
|
||||
"nickname": c.friend_nickname or c.friend_remark or c.friend_id,
|
||||
"last_day": last,
|
||||
"state": state,
|
||||
})
|
||||
matches.sort(key=lambda m: (m["last_day"], m["friend_id"]))
|
||||
return matches
|
||||
|
||||
|
||||
async def fr_fact_rows(
|
||||
session: AsyncSession,
|
||||
owner_accounts: list[str],
|
||||
channel: str | None,
|
||||
start_day: int,
|
||||
end_day: int,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> tuple[int, list[dict]]:
|
||||
"""drilldown 首响分位:该员工账号窗内 fact 逐串证据行,fr_ms desc
|
||||
(同值按日/串末时刻 desc 决胜)分页。chat_hint 给出会话定位参数
|
||||
(owner+talker 直达聊天记录)。返回 (total, items)。"""
|
||||
conds = [
|
||||
FirstReplyFact.day.between(start_day, end_day),
|
||||
FirstReplyFact.owner_account_id.in_(owner_accounts),
|
||||
]
|
||||
if channel is not None:
|
||||
conds.append(FirstReplyFact.channel == channel)
|
||||
total = (await session.execute(
|
||||
sa.select(sa.func.count()).select_from(FirstReplyFact).where(*conds)
|
||||
)).scalar_one()
|
||||
rows = (await session.execute(
|
||||
sa.select(FirstReplyFact).where(*conds)
|
||||
.order_by(FirstReplyFact.fr_ms.desc(), FirstReplyFact.day.desc(),
|
||||
FirstReplyFact.thread_end_ts.desc())
|
||||
.offset((page - 1) * page_size).limit(page_size)
|
||||
)).scalars().all()
|
||||
items = [{
|
||||
"day": f.day,
|
||||
"channel": f.channel,
|
||||
"owner_account_id": f.owner_account_id,
|
||||
"peer_id": f.peer_id,
|
||||
"thread_end_ts": f.thread_end_ts,
|
||||
"reply_ts": f.reply_ts,
|
||||
"fr_ms": f.fr_ms,
|
||||
"chat_hint": {"owner_account_id": f.owner_account_id,
|
||||
"talker_id": f.peer_id},
|
||||
} for f in rows]
|
||||
return int(total), items
|
||||
|
||||
|
||||
async def user_calls_page(
|
||||
session: AsyncSession,
|
||||
user_id: str,
|
||||
start_day: int,
|
||||
end_day: int,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> tuple[int, list[dict]]:
|
||||
"""drilldown 通话跟进:该员工窗内通话行(start_call_time desc,
|
||||
call_id desc 决胜)分页。返回 (total, items)。"""
|
||||
lo, _ = day_bounds_ms(start_day)
|
||||
_, hi = day_bounds_ms(end_day) # 末日次日 00:00(左闭右开上界)
|
||||
conds = [
|
||||
PhoneCall.user_id == user_id,
|
||||
PhoneCall.start_call_time.is_not(None),
|
||||
PhoneCall.start_call_time >= lo,
|
||||
PhoneCall.start_call_time < hi,
|
||||
]
|
||||
total = (await session.execute(
|
||||
sa.select(sa.func.count()).select_from(PhoneCall).where(*conds)
|
||||
)).scalar_one()
|
||||
rows = (await session.execute(
|
||||
sa.select(PhoneCall).where(*conds)
|
||||
.order_by(PhoneCall.start_call_time.desc(), PhoneCall.call_id.desc())
|
||||
.offset((page - 1) * page_size).limit(page_size)
|
||||
)).scalars().all()
|
||||
items = [{
|
||||
"call_id": c.call_id,
|
||||
"channel": c.channel,
|
||||
"call_number": c.call_number,
|
||||
"start_call_time": c.start_call_time,
|
||||
"call_duration": c.call_duration,
|
||||
} for c in rows]
|
||||
return int(total), items
|
||||
@@ -0,0 +1,342 @@
|
||||
"""管理域期聚合查询(批次B)——评分/异常/employees API 的单一数据源。
|
||||
|
||||
窗口:days=[start_day..end_day] 闭区间 yyyymmdd;上期=等长紧邻前窗;
|
||||
个人基线=紧邻当前窗之前的 28 天。所有查询走 (day, ...) 索引,不扫消息表
|
||||
(除 distinct 活跃客户走 msg_daily_stats friend 行)。
|
||||
|
||||
口径要点:
|
||||
- 分位/回复计数:first_reply_fact 窗内行拉回 Python 按员工合并排序取
|
||||
最近邻秩 ceil(q·n)(整除算术 (n+1)//2 / (9n+9)//10——SQLite 无原生
|
||||
ceil,MySQL `/` 返回浮点,Python 侧取秩零方言风险)。合并按员工而非
|
||||
owner:一员工多账号(wx1+wx2→u1,channel=None 时人人如此)的样本必须
|
||||
跨账号归并,逐 owner 取分位再映射会互相覆盖。fact 全库全期 ≤6.8万行,
|
||||
Python 侧聚合可行。
|
||||
- 期末存量(new/cooling/dormant/end_active_friends):状态快照取
|
||||
「每 (user, channel) 最新 ≤end 行」——channel=None 时两渠道各取各的
|
||||
最新行再相加(直接取全渠道最大 day 的那一行会丢另一渠道的存量)。
|
||||
end_active_friends 与 new/cooling/dormant 同源同行(该渠道最新行的
|
||||
active_friends 列),供评分 active_share 用——区别于 active_friends
|
||||
字段的周期 distinct 口径。
|
||||
- active_rooms_avg = SUM(active_rooms)/全窗天数(含无数据日摊薄),
|
||||
非有数据天数——「日均负载」语义。
|
||||
- days_with_data = COUNT(DISTINCT day):单渠道时与 COUNT(*) 等价
|
||||
(PK 含 day+channel,每日至多一行),channel=None 双渠道同日也只计 1 天。
|
||||
- 通话四列恒全渠道(employee_call_daily 无渠道维),channel 参数不过滤。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import statistics
|
||||
from collections import namedtuple
|
||||
from collections.abc import Iterable
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import (
|
||||
EmployeeCallDaily,
|
||||
EmployeeMetricDaily,
|
||||
FirstReplyFact,
|
||||
MsgDailyStats,
|
||||
Staff,
|
||||
)
|
||||
from app.storage.employee_metrics import METRIC_VERSION
|
||||
from app.storage.staff_summary import account_map
|
||||
|
||||
PeriodMetrics = namedtuple("PeriodMetrics", [
|
||||
"user_id", "days_with_data", "msg_sent", "msg_recv", "cust_threads",
|
||||
"threads_answered", "fr_sum_ms", "fr_p50_ms", "fr_p90_ms",
|
||||
"reply_2h_count", "reply_24h_count",
|
||||
"active_friends", # 期内 distinct(直查,非日值求和)
|
||||
"active_rooms_avg", # 日均(全窗天数摊薄)
|
||||
"new_customer", "cooling_customer", "dormant_customer", # 期末单日存量
|
||||
"end_active_friends", # 期末行 active_friends 跨渠道 SUM(active_share 分子)
|
||||
"reactivated_customer", # 期内求和
|
||||
"call_count", "call_seconds", "answered_call", "call_followup",
|
||||
])
|
||||
|
||||
|
||||
def _to_date(k: int) -> dt.date:
|
||||
return dt.date(k // 10000, k // 100 % 100, k % 100)
|
||||
|
||||
|
||||
def _to_key(d: dt.date) -> int:
|
||||
return d.year * 10000 + d.month * 100 + d.day
|
||||
|
||||
|
||||
def day_range(start: int, end: int) -> list[int]:
|
||||
"""闭区间日键列表。"""
|
||||
out, cur = [], _to_date(start)
|
||||
while cur <= _to_date(end):
|
||||
out.append(_to_key(cur))
|
||||
cur += dt.timedelta(days=1)
|
||||
return out
|
||||
|
||||
|
||||
def prev_window(start: int, end: int) -> tuple[int, int]:
|
||||
"""等长紧邻前窗。"""
|
||||
n = len(day_range(start, end))
|
||||
pe = _to_date(start) - dt.timedelta(days=1)
|
||||
ps = pe - dt.timedelta(days=n - 1)
|
||||
return _to_key(ps), _to_key(pe)
|
||||
|
||||
|
||||
def baseline_window(start: int) -> tuple[int, int]:
|
||||
"""个人基线:紧邻当前窗之前的 28 天。"""
|
||||
be = _to_date(start) - dt.timedelta(days=1)
|
||||
bs = be - dt.timedelta(days=27)
|
||||
return _to_key(bs), _to_key(be)
|
||||
|
||||
|
||||
def percentile(vals: list[int], q_num: int, q_den: int) -> int | None:
|
||||
"""最近邻秩分位 ceil(q·n) 的样本值,q=q_num/q_den(1/2、9/10)。
|
||||
|
||||
公有供同包 api_assembly(B4 服务趋势)复用;整数算术
|
||||
rank=(q_num*n + q_den - 1)//q_den 对正整数 n 恒落在 [1,n]
|
||||
(与 math.ceil(q·n) 等价,与批次A storage 层 _percentile 同口径——
|
||||
后者为不同层单独保留);空列表→None(与 0 严格区分)。
|
||||
"""
|
||||
if not vals:
|
||||
return None
|
||||
s = sorted(vals)
|
||||
n = len(s)
|
||||
rank = max(1, min(n, (q_num * n + q_den - 1) // q_den))
|
||||
return s[rank - 1]
|
||||
|
||||
|
||||
# 累加器初值:全员(staff)都出结果行,无数据员工为零值而非缺行——
|
||||
# employees API 全员列表语义。
|
||||
_ZERO: dict[str, object] = {
|
||||
"days_with_data": 0, "msg_sent": 0, "msg_recv": 0, "cust_threads": 0,
|
||||
"threads_answered": 0, "fr_sum_ms": 0,
|
||||
"fr_p50_ms": None, "fr_p90_ms": None,
|
||||
"reply_2h_count": 0, "reply_24h_count": 0,
|
||||
"active_friends": 0, "active_rooms_sum": 0,
|
||||
"new_customer": 0, "cooling_customer": 0, "dormant_customer": 0,
|
||||
"end_active_friends": 0,
|
||||
"reactivated_customer": 0,
|
||||
"call_count": 0, "call_seconds": 0, "answered_call": 0, "call_followup": 0,
|
||||
}
|
||||
|
||||
|
||||
async def period_metrics(
|
||||
session: AsyncSession,
|
||||
start_day: int,
|
||||
end_day: int,
|
||||
channel: str | None,
|
||||
user_ids: Iterable[str] | None = None,
|
||||
) -> dict[str, PeriodMetrics]:
|
||||
"""窗口 [start_day, end_day] × channel(None=全渠道)的员工期指标。
|
||||
|
||||
user_ids 传入时与 staff 表求交(幽灵过滤);返回含全部目标员工的
|
||||
PeriodMetrics(无数据员工为零值行)。channel=None 时两渠道
|
||||
account_map 合并(wx_id 与 WA uid 命名空间不撞,无冲突)。
|
||||
"""
|
||||
staff_ids = {
|
||||
u for u in (await session.execute(sa.select(Staff.user_id))).scalars() if u
|
||||
}
|
||||
target = staff_ids if user_ids is None else staff_ids & set(user_ids)
|
||||
if not target:
|
||||
return {}
|
||||
|
||||
if channel is None:
|
||||
amap: dict[str, str] = {}
|
||||
for ch in ("wechat", "whatsapp"):
|
||||
amap.update(await account_map(session, ch))
|
||||
else:
|
||||
amap = await account_map(session, channel)
|
||||
|
||||
n_days = len(day_range(start_day, end_day))
|
||||
acc: dict[str, dict] = {uid: dict(_ZERO) for uid in target}
|
||||
|
||||
# ---- 宽表 SUM:消息计数/串类/日均负载/复活 ----
|
||||
wide_conds = [
|
||||
EmployeeMetricDaily.day.between(start_day, end_day),
|
||||
EmployeeMetricDaily.metric_version == METRIC_VERSION,
|
||||
EmployeeMetricDaily.user_id.in_(target),
|
||||
]
|
||||
if channel is not None:
|
||||
wide_conds.append(EmployeeMetricDaily.channel == channel)
|
||||
wide = (await session.execute(
|
||||
sa.select(
|
||||
EmployeeMetricDaily.user_id.label("uid"),
|
||||
sa.func.count(sa.distinct(EmployeeMetricDaily.day)).label("nd"),
|
||||
sa.func.sum(EmployeeMetricDaily.msg_sent).label("sent"),
|
||||
sa.func.sum(EmployeeMetricDaily.msg_recv).label("recv"),
|
||||
sa.func.sum(EmployeeMetricDaily.cust_threads).label("thr"),
|
||||
sa.func.sum(EmployeeMetricDaily.threads_answered).label("ans"),
|
||||
sa.func.sum(EmployeeMetricDaily.fr_sum_ms).label("frs"),
|
||||
sa.func.sum(EmployeeMetricDaily.active_rooms).label("rooms"),
|
||||
sa.func.sum(
|
||||
EmployeeMetricDaily.reactivated_customer_count
|
||||
).label("react"),
|
||||
).where(*wide_conds).group_by(EmployeeMetricDaily.user_id)
|
||||
)).all()
|
||||
for uid, nd, sent, recv, thr, ans, frs, rooms, react in wide:
|
||||
a = acc[uid]
|
||||
a["days_with_data"] = int(nd or 0)
|
||||
a["msg_sent"] = int(sent or 0)
|
||||
a["msg_recv"] = int(recv or 0)
|
||||
a["cust_threads"] = int(thr or 0)
|
||||
a["threads_answered"] = int(ans or 0)
|
||||
a["fr_sum_ms"] = int(frs or 0)
|
||||
a["active_rooms_sum"] = int(rooms or 0)
|
||||
a["reactivated_customer"] = int(react or 0)
|
||||
|
||||
# ---- 期末存量:每 (user, channel) 最新 ≤end 行,跨渠道相加 ----
|
||||
stock_conds = [
|
||||
EmployeeMetricDaily.day <= end_day,
|
||||
EmployeeMetricDaily.metric_version == METRIC_VERSION,
|
||||
EmployeeMetricDaily.user_id.in_(target),
|
||||
]
|
||||
if channel is not None:
|
||||
stock_conds.append(EmployeeMetricDaily.channel == channel)
|
||||
latest = (await session.execute(
|
||||
sa.select(
|
||||
EmployeeMetricDaily.user_id.label("uid"),
|
||||
EmployeeMetricDaily.channel.label("ch"),
|
||||
sa.func.max(EmployeeMetricDaily.day).label("d"),
|
||||
).where(*stock_conds).group_by(
|
||||
EmployeeMetricDaily.user_id, EmployeeMetricDaily.channel
|
||||
)
|
||||
)).all()
|
||||
if latest:
|
||||
# (user, channel, day) 含 metric_version 唯一(主键)——OR 点查
|
||||
# 精确取回各渠道最新行,再按 user 跨渠道 SUM
|
||||
stocks = (await session.execute(
|
||||
sa.select(
|
||||
EmployeeMetricDaily.user_id.label("uid"),
|
||||
sa.func.sum(EmployeeMetricDaily.new_customer_count).label("nw"),
|
||||
sa.func.sum(
|
||||
EmployeeMetricDaily.cooling_customer_count
|
||||
).label("cool"),
|
||||
sa.func.sum(
|
||||
EmployeeMetricDaily.dormant_customer_count
|
||||
).label("dorm"),
|
||||
sa.func.sum(EmployeeMetricDaily.active_friends).label("af"),
|
||||
).where(sa.or_(*(
|
||||
sa.and_(
|
||||
EmployeeMetricDaily.user_id == uid,
|
||||
EmployeeMetricDaily.channel == ch,
|
||||
EmployeeMetricDaily.day == d,
|
||||
EmployeeMetricDaily.metric_version == METRIC_VERSION,
|
||||
)
|
||||
for uid, ch, d in latest
|
||||
))).group_by(EmployeeMetricDaily.user_id)
|
||||
)).all()
|
||||
for uid, nw, cool, dorm, af in stocks:
|
||||
a = acc[uid]
|
||||
a["new_customer"] = int(nw or 0)
|
||||
a["cooling_customer"] = int(cool or 0)
|
||||
a["dormant_customer"] = int(dorm or 0)
|
||||
a["end_active_friends"] = int(af or 0)
|
||||
|
||||
# ---- fact 分位 + 2h/24h 回复计数(同一过滤,Python 侧一次聚合) ----
|
||||
fr_conds = [FirstReplyFact.day.between(start_day, end_day)]
|
||||
if channel is not None:
|
||||
fr_conds.append(FirstReplyFact.channel == channel)
|
||||
fr_rows = (await session.execute(
|
||||
sa.select(
|
||||
FirstReplyFact.owner_account_id.label("owner"),
|
||||
FirstReplyFact.fr_ms.label("fr"),
|
||||
).where(*fr_conds)
|
||||
)).all()
|
||||
fr_by_user: dict[str, list[int]] = {}
|
||||
for owner, fr in fr_rows:
|
||||
uid = amap.get(owner or "")
|
||||
if uid is not None and uid in target:
|
||||
fr_by_user.setdefault(uid, []).append(int(fr))
|
||||
for uid, frs in fr_by_user.items():
|
||||
acc[uid]["fr_p50_ms"] = percentile(frs, 1, 2)
|
||||
acc[uid]["fr_p90_ms"] = percentile(frs, 9, 10)
|
||||
# 2h/24h 计数与分位同源同窗——回复率分子分母口径一致
|
||||
acc[uid]["reply_2h_count"] = sum(1 for fr in frs if fr <= 7_200_000)
|
||||
acc[uid]["reply_24h_count"] = sum(1 for fr in frs if fr <= 86_400_000)
|
||||
|
||||
# ---- distinct 活跃客户:账号归并到员工后按 peer 去重 ----
|
||||
af_conds = [
|
||||
MsgDailyStats.day.between(start_day, end_day),
|
||||
MsgDailyStats.peer_kind == "friend",
|
||||
]
|
||||
if channel is not None:
|
||||
af_conds.append(MsgDailyStats.channel == channel)
|
||||
pairs = (await session.execute(
|
||||
sa.select(MsgDailyStats.owner_account_id, MsgDailyStats.peer_id)
|
||||
.distinct().where(*af_conds)
|
||||
)).all()
|
||||
active: dict[str, set[str]] = {}
|
||||
for owner, peer in pairs:
|
||||
uid = amap.get(owner or "")
|
||||
if uid is None or uid not in target:
|
||||
continue
|
||||
# (uid, peer) 粒度:同一员工多账号(wx1+wx2)的共享客户只计 1
|
||||
# ——与宽表日值口径一致(storage 侧账号归并后 add(peer))
|
||||
active.setdefault(uid, set()).add(peer or "")
|
||||
for uid, pset in active.items():
|
||||
acc[uid]["active_friends"] = len(pset)
|
||||
|
||||
# ---- 通话:恒全渠道(表无渠道维,channel 参数不过滤) ----
|
||||
calls = (await session.execute(
|
||||
sa.select(
|
||||
EmployeeCallDaily.user_id.label("uid"),
|
||||
sa.func.sum(EmployeeCallDaily.call_count).label("cc"),
|
||||
sa.func.sum(EmployeeCallDaily.call_seconds).label("cs"),
|
||||
sa.func.sum(EmployeeCallDaily.answered_call_count).label("ca"),
|
||||
sa.func.sum(EmployeeCallDaily.call_followup_count).label("cf"),
|
||||
).where(
|
||||
EmployeeCallDaily.day.between(start_day, end_day),
|
||||
EmployeeCallDaily.user_id.in_(target),
|
||||
).group_by(EmployeeCallDaily.user_id)
|
||||
)).all()
|
||||
for uid, cc, cs, ca, cf in calls:
|
||||
a = acc[uid]
|
||||
a["call_count"] = int(cc or 0)
|
||||
a["call_seconds"] = int(cs or 0)
|
||||
a["answered_call"] = int(ca or 0)
|
||||
a["call_followup"] = int(cf or 0)
|
||||
|
||||
return {
|
||||
uid: PeriodMetrics(
|
||||
user_id=uid,
|
||||
days_with_data=a["days_with_data"],
|
||||
msg_sent=a["msg_sent"],
|
||||
msg_recv=a["msg_recv"],
|
||||
cust_threads=a["cust_threads"],
|
||||
threads_answered=a["threads_answered"],
|
||||
fr_sum_ms=a["fr_sum_ms"],
|
||||
fr_p50_ms=a["fr_p50_ms"],
|
||||
fr_p90_ms=a["fr_p90_ms"],
|
||||
reply_2h_count=a["reply_2h_count"],
|
||||
reply_24h_count=a["reply_24h_count"],
|
||||
active_friends=a["active_friends"],
|
||||
active_rooms_avg=(a["active_rooms_sum"] / n_days) if n_days else 0.0,
|
||||
new_customer=a["new_customer"],
|
||||
cooling_customer=a["cooling_customer"],
|
||||
dormant_customer=a["dormant_customer"],
|
||||
end_active_friends=a["end_active_friends"],
|
||||
reactivated_customer=a["reactivated_customer"],
|
||||
call_count=a["call_count"],
|
||||
call_seconds=a["call_seconds"],
|
||||
answered_call=a["answered_call"],
|
||||
call_followup=a["call_followup"],
|
||||
)
|
||||
for uid, a in acc.items()
|
||||
}
|
||||
|
||||
|
||||
def team_medians(metrics: dict[str, PeriodMetrics]) -> dict[str, float | None]:
|
||||
"""每数值字段的全员中位数(纯函数,评分归一用)。
|
||||
|
||||
None(无样本的分位等)剔除后取中位——statistics.median:奇数取中位
|
||||
样本、偶数取中间两数均值;全员 None → None(调用方按无基线处理)。
|
||||
"""
|
||||
out: dict[str, float | None] = {}
|
||||
for field in PeriodMetrics._fields:
|
||||
if field == "user_id":
|
||||
continue
|
||||
vals = [
|
||||
v for v in (getattr(m, field) for m in metrics.values())
|
||||
if v is not None
|
||||
]
|
||||
out[field] = statistics.median(vals) if vals else None
|
||||
return out
|
||||
@@ -0,0 +1,205 @@
|
||||
"""管理域指标元数据与默认评分配置(批次B,v2 §6/§10)。
|
||||
|
||||
METRIC_DEFINITIONS 是 /api/management/metric-definitions 的单一数据源:
|
||||
名称/单位/方向/口径说明(含与现有看板的三方口径差异标注)。
|
||||
DEFAULT_SCORE_CONFIG 为 v1.0 试算起点——所有阈值标「试算」,调整走
|
||||
新配置版本(employee_score_config),不静默改数。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
# direction: +1 越大越好 / -1 越小越好;caliber 注明与看板差异
|
||||
METRIC_DEFINITIONS: dict[str, dict] = {
|
||||
"msg_sent": {"name": "发出消息(1:1)", "unit": "条", "direction": 1,
|
||||
"caliber": "仅私聊;现有看板为私聊+群聊混合"},
|
||||
"msg_recv": {"name": "收到消息(1:1)", "unit": "条", "direction": 1,
|
||||
"caliber": "仅私聊"},
|
||||
"cust_threads": {"name": "客户消息串", "unit": "串", "direction": 1,
|
||||
"caliber": "跨日精确,按串末条归属日;看板为日切口径(多日串重复计)"},
|
||||
"threads_answered": {"name": "被回串数", "unit": "串", "direction": 1,
|
||||
"caliber": "≡first_reply_fact行数(同源)"},
|
||||
"fr_avg_ms": {"name": "平均首响", "unit": "ms", "direction": -1,
|
||||
"caliber": "fr_sum_ms/threads_answered"},
|
||||
"fr_p50_ms": {"name": "首响中位数", "unit": "ms", "direction": -1,
|
||||
"caliber": "周期内 fact 精确分位(窗口函数)"},
|
||||
"fr_p90_ms": {"name": "首响P90", "unit": "ms", "direction": -1,
|
||||
"caliber": "周期内 fact 精确分位;日切口径会丢跨夜长尾"},
|
||||
"reply_2h_rate": {"name": "2h回复率", "unit": "0-1", "direction": 1,
|
||||
"formula": "count(fr_ms<=2h)/cust_threads",
|
||||
"caliber": "分子分母同源(fact/宽表同一算法)"},
|
||||
"reply_24h_rate": {"name": "24h回复率", "unit": "0-1", "direction": 1,
|
||||
"formula": "count(fr_ms<=24h)/cust_threads"},
|
||||
"active_friends": {"name": "活跃客户(周期)", "unit": "人", "direction": 1,
|
||||
"caliber": "期内distinct客户(msg_daily_stats直查),非宽表日值求和"},
|
||||
"active_rooms": {"name": "活跃群(日均)", "unit": "个", "direction": 1},
|
||||
"new_customer_count": {"name": "新客(期末)", "unit": "人", "direction": 1,
|
||||
"caliber": "期末单日持有状态,非期内求和"},
|
||||
"cooling_customer_count": {"name": "降温客户(期末)", "unit": "人", "direction": -1,
|
||||
"caliber": "距今天数口径(7-29天);频次基线口径二期"},
|
||||
"dormant_customer_count": {"name": "沉睡客户(期末)", "unit": "人", "direction": -1,
|
||||
"caliber": "≥30天且历史≥5条"},
|
||||
"reactivated_customer_count": {"name": "回流客户(期内)", "unit": "人", "direction": 1,
|
||||
"caliber": "当日回流且前次间隔>30天,期内求和"},
|
||||
"call_count": {"name": "通话数", "unit": "通", "direction": 1,
|
||||
"caliber": "语音通话(微信+WA),无消息渠道维度"},
|
||||
"answered_rate": {"name": "接通率", "unit": "0-1", "direction": 1,
|
||||
"formula": "answered_call/call_count", "caliber": "接通=duration>0"},
|
||||
"call_followup_rate": {"name": "通话后跟进率", "unit": "0-1", "direction": 1,
|
||||
"formula": "call_followup_count/answered_call",
|
||||
"caliber": "接通后24h内任一账号有1:1消息(含客户发来)"},
|
||||
"active_share": {"name": "活跃客户占比", "unit": "0-1", "direction": 1,
|
||||
"formula": "期末active_friends/(期末active+cooling+dormant)",
|
||||
"caliber": "试算口径:期末单日存量;宽表无生命周期active存量列,active以active_friends列充当"},
|
||||
}
|
||||
|
||||
# 阈值全部为试算起点(v2 §0 决议:全默认+试算标记);type 三种:
|
||||
# target : floor..target 线性 0..100(方向已含在 target>floor)
|
||||
# reverse : ≤target_ms 100 分,≥floor_ms 0 分,中间线性降
|
||||
# band : 区间内 100,超上限 80(不再加分),低于下限 None(负载
|
||||
# 不足→不评分)——得分取值范围 80..100
|
||||
DEFAULT_SCORE_CONFIG: dict = {
|
||||
"version": "v1.0",
|
||||
"name": "一期试算(3维可用)",
|
||||
"normalization": "available_only_rescale", # 可用维度 100/Σweight 再 ×100
|
||||
"trial": True,
|
||||
"sample_guards": {"min_threads": 30, "soft_threads": 10, "min_days": 5},
|
||||
"grade_bounds": {"A": 90, "B": 80, "C": 70, "D": 60}, # 低于 D 线为 E
|
||||
"dimensions": {
|
||||
"customer": {"weight": 30, "name": "客户经营", "metrics": {
|
||||
"active_share": {"type": "target", "floor": 0.20, "target": 0.60},
|
||||
}},
|
||||
"service": {"weight": 25, "name": "服务质量", "metrics": {
|
||||
"reply_2h_rate": {"type": "target", "floor": 0.40, "target": 0.85},
|
||||
"reply_24h_rate": {"type": "target", "floor": 0.75, "target": 0.97},
|
||||
"fr_p90_ms": {"type": "reverse", "target_ms": 1_800_000,
|
||||
"floor_ms": 14_400_000}, # 30min..4h
|
||||
}},
|
||||
"action": {"weight": 20, "name": "行动执行",
|
||||
"status": "pending_data"}, # 批次E 接 followups/任务
|
||||
"workload": {"weight": 15, "name": "有效负载", "metrics": {
|
||||
# basis=period_distinct:用周期 distinct active_friends(B1 口径),
|
||||
# 比单日期末值更抗单日噪声(2026-09-09 B2 决议)
|
||||
"active_friends_band": {"type": "band", "metric": "active_friends",
|
||||
"low": 5, "high": 80,
|
||||
"basis": "period_distinct"},
|
||||
"call_band": {"type": "band", "metric": "call_count",
|
||||
"low": 1, "high": 200, "basis": "period_total",
|
||||
"note": "通话低不扣分仅提示;band 得分下限 80"},
|
||||
}},
|
||||
"risk": {"weight": 10, "name": "合规风险",
|
||||
"status": "pending_data"}, # 批次E/三期接风险事件
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def validate_config(config: dict) -> list[str]:
|
||||
"""配置校验:权重总和/结构/引用的 metric_code 有元数据/阈值形状/
|
||||
grade_bounds 形状/指标 code 跨维度唯一。
|
||||
|
||||
阈值形状(随评分引擎落地的 B0 评审预埋要求):
|
||||
- type 须为 target/reverse/band 三者之一
|
||||
- target : floor < target
|
||||
- reverse: target_ms < floor_ms(100 分线在 0 分线之前)
|
||||
- band : low < high
|
||||
B4 预埋(配置将自 DB 加载,写入侧不再重跑种子校验):
|
||||
- grade_bounds 须含 A/B/C/D 且严格降序
|
||||
- 指标 code 跨维度不得重复:metrics_detail 为扁平 dict,
|
||||
同名 code 会相互覆盖(后写胜出)
|
||||
- 源指标名跨维度不得重复:employees 行 comparisons 按源名建键
|
||||
(api_assembly._code_to_source),两个 code 指向同一源名会静默覆盖
|
||||
"""
|
||||
problems: list[str] = []
|
||||
dims = config.get("dimensions", {})
|
||||
total = sum(d.get("weight", 0) for d in dims.values())
|
||||
if total != 100:
|
||||
problems.append(f"维度权重总和须=100,当前 {total}")
|
||||
gb = config.get("grade_bounds")
|
||||
if not isinstance(gb, dict) or not all(
|
||||
isinstance(gb.get(k), int) for k in ("A", "B", "C", "D")
|
||||
):
|
||||
problems.append("grade_bounds 须含 A/B/C/D 且严格降序")
|
||||
elif not (gb["A"] > gb["B"] > gb["C"] > gb["D"]):
|
||||
problems.append(
|
||||
f"grade_bounds 须严格降序 A>B>C>D"
|
||||
f"(当前 A={gb['A']} B={gb['B']} C={gb['C']} D={gb['D']})"
|
||||
)
|
||||
seen_codes: dict[str, str] = {}
|
||||
seen_sources: dict[str, str] = {}
|
||||
for key, dim in dims.items():
|
||||
if dim.get("status") == "pending_data":
|
||||
continue
|
||||
for code, spec in dim.get("metrics", {}).items():
|
||||
if code in seen_codes:
|
||||
problems.append(f"指标 code 重复定义于多维度: {code}")
|
||||
seen_codes[code] = key
|
||||
src = spec.get("metric", code)
|
||||
if src in seen_sources:
|
||||
problems.append(
|
||||
f"{key}.{code} 与 {seen_sources[src]} 指向同一源指标 {src}"
|
||||
f"(comparisons 键域将相互覆盖)"
|
||||
)
|
||||
else:
|
||||
seen_sources[src] = f"{key}.{code}"
|
||||
if src not in METRIC_DEFINITIONS:
|
||||
problems.append(f"{key}.{code} 引用未定义指标 {src}")
|
||||
mtype = spec.get("type")
|
||||
if mtype not in ("target", "reverse", "band"):
|
||||
problems.append(
|
||||
f"{key}.{code} 类型非法:{mtype!r}(须 target/reverse/band)"
|
||||
)
|
||||
elif mtype == "target" and not spec.get("floor", 0) < spec.get("target", 0):
|
||||
problems.append(
|
||||
f"{key}.{code} target 型须 floor<target"
|
||||
f"(当前 floor={spec.get('floor')} target={spec.get('target')})"
|
||||
)
|
||||
elif mtype == "reverse" and not (
|
||||
spec.get("target_ms", 0) < spec.get("floor_ms", 0)
|
||||
):
|
||||
problems.append(
|
||||
f"{key}.{code} reverse 型须 target_ms<floor_ms"
|
||||
f"(当前 target_ms={spec.get('target_ms')}"
|
||||
f" floor_ms={spec.get('floor_ms')})"
|
||||
)
|
||||
elif mtype == "band" and not spec.get("low", 0) < spec.get("high", 0):
|
||||
problems.append(
|
||||
f"{key}.{code} band 型须 low<high"
|
||||
f"(当前 low={spec.get('low')} high={spec.get('high')})"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
async def _seed_score_config(session) -> str | None:
|
||||
"""种子默认评分配置;已存在同版本返回 None(幂等)。纯会话函数可测。
|
||||
|
||||
同 ensure_starter_words 先例:域包提供纯会话种子函数,CLI 只做接线。
|
||||
config_json 用 deepcopy 冻结快照——JSON 列须整列重赋值,深拷贝防止
|
||||
长驻 API 进程未来的原地修改波及模块常量。
|
||||
"""
|
||||
from app.db.models import EmployeeScoreConfig
|
||||
|
||||
problems = validate_config(DEFAULT_SCORE_CONFIG)
|
||||
if problems:
|
||||
raise ValueError(f"score config invalid: {problems}")
|
||||
exists = (
|
||||
await session.execute(
|
||||
sa.select(EmployeeScoreConfig.id).where(
|
||||
EmployeeScoreConfig.version == DEFAULT_SCORE_CONFIG["version"]
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists is not None:
|
||||
return None
|
||||
session.add(
|
||||
EmployeeScoreConfig(
|
||||
version=DEFAULT_SCORE_CONFIG["version"],
|
||||
name=DEFAULT_SCORE_CONFIG["name"],
|
||||
status="active",
|
||||
config_json=copy.deepcopy(DEFAULT_SCORE_CONFIG),
|
||||
created_by="seed-cli",
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
return DEFAULT_SCORE_CONFIG["version"]
|
||||
@@ -0,0 +1,230 @@
|
||||
"""管理域评分引擎(批次B)——试算版(v2 §0 决议:全默认+试算标记)。
|
||||
|
||||
三类标准化(v1 §10.2):
|
||||
- target : floor..target 线性 0..100(≤floor 得 0,≥target 得 100)
|
||||
- reverse : ≤target_ms 100 分,≥floor_ms 0 分,中间线性降
|
||||
- band : 区间内 100,超上限 80(不再加),低于下限 None(负载不足→不评分)
|
||||
样本保护(v1 §10.3):指标样本量=串类 cust_threads/band 类 active_friends;
|
||||
<soft_threads(10) insufficient 不评分;soft(10)~min_threads(30) 低置信照评。
|
||||
维度分=指标均分(等权,None 剔除按剩余归一);总分=可用维度加权重归一
|
||||
(满分 Σweight×100/Σweight,一期可用 70 → 100/70 放大回百分制)——
|
||||
action/risk pending_data 不参与,不默认 0/100。
|
||||
|
||||
band 基数=周期 distinct active_friends(basis=period_distinct,B2 决议:
|
||||
比单日期末值更抗噪声且与 B1 口径一致)。
|
||||
|
||||
team 中位数键=派生指标名(spec 的 metric 字段,无则 code 本身):
|
||||
fr_p90_ms/active_friends/call_count 可直接用 team_medians 输出,
|
||||
reply_*_rate/active_share 需调用方对派生值取中位后传入。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from app.management.employee_metrics import PeriodMetrics
|
||||
from app.management.metric_definitions import METRIC_DEFINITIONS
|
||||
|
||||
_GRADE_ORDER = ("A", "B", "C", "D") # 低于 D 线为 E
|
||||
|
||||
|
||||
def normalize(spec: dict, value: float | None) -> float | None:
|
||||
"""三类标准化(v1 §10.2):value None→None;band 低于下限→None。
|
||||
|
||||
- target : (v-floor)/(target-floor) 线性 0..100,两端截断
|
||||
- reverse : (floor_ms-v)/(floor_ms-target_ms) 线性降 100..0,两端截断
|
||||
- band : [low,high] 内 100,>high 80(不再加),<low None(不评分)
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
mtype = spec["type"]
|
||||
if mtype == "target":
|
||||
floor, target = spec["floor"], spec["target"]
|
||||
if value <= floor:
|
||||
return 0.0
|
||||
if value >= target:
|
||||
return 100.0
|
||||
return (value - floor) / (target - floor) * 100.0
|
||||
if mtype == "reverse":
|
||||
target_ms, floor_ms = spec["target_ms"], spec["floor_ms"]
|
||||
if value <= target_ms:
|
||||
return 100.0
|
||||
if value >= floor_ms:
|
||||
return 0.0
|
||||
return (floor_ms - value) / (floor_ms - target_ms) * 100.0
|
||||
if mtype == "band":
|
||||
low, high = spec["low"], spec["high"]
|
||||
if value < low:
|
||||
return None
|
||||
if value > high:
|
||||
return 80.0
|
||||
return 100.0
|
||||
raise ValueError(f"未知指标类型 {mtype!r}(须 target/reverse/band)")
|
||||
|
||||
|
||||
def derive_metrics(pm: PeriodMetrics) -> dict[str, float | None]:
|
||||
"""从 PeriodMetrics 派生可评分指标值(键=指标名,None=无值≠0)。
|
||||
|
||||
公有供 anomalies(B3)/dashboard(B4) 复用——评分与异常对比同一派生口径。
|
||||
"""
|
||||
out: dict[str, float | None] = {
|
||||
"fr_p90_ms": pm.fr_p90_ms,
|
||||
"active_friends": pm.active_friends, # band 用,周期 distinct
|
||||
"call_count": pm.call_count, # band 用,period_total
|
||||
}
|
||||
if pm.cust_threads:
|
||||
out["reply_2h_rate"] = pm.reply_2h_count / pm.cust_threads
|
||||
out["reply_24h_rate"] = pm.reply_24h_count / pm.cust_threads
|
||||
else:
|
||||
# 无串样本→无率(严格区分 0%),分子分母同源
|
||||
out["reply_2h_rate"] = None
|
||||
out["reply_24h_rate"] = None
|
||||
denom = pm.end_active_friends + pm.cooling_customer + pm.dormant_customer
|
||||
out["active_share"] = (
|
||||
pm.end_active_friends / denom if denom > 0 else None
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _fmt_ms(ms: float) -> str:
|
||||
minutes = ms / 60_000
|
||||
if minutes >= 60 and minutes % 60 == 0:
|
||||
return f"{minutes // 60:g}h"
|
||||
return f"{minutes:g}min"
|
||||
|
||||
|
||||
def _formula_note(spec: dict) -> str:
|
||||
"""指标计分公式的中文说明(B4 透出,阈值全部来自 config)。"""
|
||||
mtype = spec["type"]
|
||||
if mtype == "target":
|
||||
return (
|
||||
f"floor {spec['floor']}..target {spec['target']} 线性 0..100 分"
|
||||
"(≤floor 0,≥target 100)"
|
||||
)
|
||||
if mtype == "reverse":
|
||||
return (
|
||||
f"≤{_fmt_ms(spec['target_ms'])} 100 分,"
|
||||
f"≥{_fmt_ms(spec['floor_ms'])} 0 分,中间线性降"
|
||||
)
|
||||
return (
|
||||
f"{spec['low']}..{spec['high']} 区间内 100 分,超上限 80 分,"
|
||||
f"低于 {spec['low']} 不评分(负载不足);basis={spec.get('basis', '-')}"
|
||||
)
|
||||
|
||||
|
||||
def score_employee(
|
||||
config: dict,
|
||||
current: PeriodMetrics,
|
||||
baseline: PeriodMetrics | None = None,
|
||||
team: dict[str, float] | None = None,
|
||||
prev: PeriodMetrics | None = None,
|
||||
) -> dict:
|
||||
"""单员工试算评分(纯函数,B4 端点直接透出)。
|
||||
|
||||
prev/baseline/team 为四对比位槽位:prev/baseline 按同口径派生后对比,
|
||||
team 为指标级中位数(键=派生指标名,可 None)。返回结构见模块 docstring
|
||||
——metrics_detail 键=配置里的 metric_code(band 型如 active_friends_band)。
|
||||
"""
|
||||
cur_vals = derive_metrics(current)
|
||||
prev_vals = derive_metrics(prev) if prev is not None else None
|
||||
base_vals = derive_metrics(baseline) if baseline is not None else None
|
||||
guards = config.get("sample_guards", {})
|
||||
soft = guards.get("soft_threads", 10)
|
||||
minimum = guards.get("min_threads", 30)
|
||||
|
||||
dimensions: dict[str, dict] = {}
|
||||
metrics_detail: dict[str, dict] = {}
|
||||
|
||||
for dim_key, dim in config.get("dimensions", {}).items():
|
||||
entry: dict = {
|
||||
"name": dim.get("name", dim_key),
|
||||
"weight": dim.get("weight", 0),
|
||||
"score": None,
|
||||
"status": "pending",
|
||||
}
|
||||
if dim.get("status") == "pending_data" or not dim.get("metrics"):
|
||||
# 一期 action/risk:数据未接入——不参与归一,不带 metrics
|
||||
dimensions[dim_key] = entry
|
||||
continue
|
||||
|
||||
dim_scores: list[float] = []
|
||||
any_insufficient = False # 有值但样本不足→维度 insufficient
|
||||
dim_metrics: dict[str, dict] = {}
|
||||
for code, spec in dim["metrics"].items():
|
||||
src = spec.get("metric", code)
|
||||
value = cur_vals.get(src)
|
||||
# 样本量:串类=cust_threads,band 类=active_friends
|
||||
sample_size = (
|
||||
current.active_friends
|
||||
if spec.get("type") == "band" else current.cust_threads
|
||||
)
|
||||
if sample_size >= minimum:
|
||||
sample_status = "ok"
|
||||
elif sample_size >= soft:
|
||||
sample_status = "low_confidence"
|
||||
else:
|
||||
sample_status = "insufficient"
|
||||
score: float | None = None
|
||||
if value is not None:
|
||||
if sample_status == "insufficient":
|
||||
any_insufficient = True # 有可评值但不评分
|
||||
else:
|
||||
score = normalize(spec, value)
|
||||
if score is not None:
|
||||
dim_scores.append(score)
|
||||
dim_metrics[code] = {
|
||||
"value": value,
|
||||
"score": score,
|
||||
"sample_size": sample_size,
|
||||
"sample_status": sample_status,
|
||||
"formula_note": _formula_note(spec),
|
||||
}
|
||||
metrics_detail[code] = {
|
||||
"value": value,
|
||||
"prev": prev_vals.get(src) if prev_vals else None,
|
||||
"baseline": base_vals.get(src) if base_vals else None,
|
||||
"median": team.get(src) if team else None,
|
||||
"sample_size": sample_size,
|
||||
"sample_status": sample_status,
|
||||
"score": score,
|
||||
"unit": METRIC_DEFINITIONS.get(src, {}).get("unit", ""),
|
||||
"name": METRIC_DEFINITIONS.get(src, {}).get("name", code),
|
||||
}
|
||||
|
||||
if dim_scores:
|
||||
entry["score"] = sum(dim_scores) / len(dim_scores)
|
||||
entry["status"] = "scored"
|
||||
elif any_insufficient:
|
||||
entry["status"] = "insufficient"
|
||||
else:
|
||||
entry["status"] = "pending" # 全部无值(期末无存量/无串样本等)
|
||||
entry["metrics"] = dim_metrics
|
||||
dimensions[dim_key] = entry
|
||||
|
||||
# 可用维度加权重归一:Σ(w×s)/Σw(满分=Σw×100/Σw=100),四舍五入取整
|
||||
available = [
|
||||
(d["weight"], d["score"]) for d in dimensions.values()
|
||||
if d["score"] is not None
|
||||
]
|
||||
total: int | None = None
|
||||
if available:
|
||||
wsum = sum(w for w, _ in available)
|
||||
total = int(math.floor(
|
||||
sum(w * s for w, s in available) / wsum + 0.5
|
||||
))
|
||||
grade: str | None = None
|
||||
if total is not None:
|
||||
grade = "E"
|
||||
bounds = config.get("grade_bounds", {})
|
||||
for g in _GRADE_ORDER: # A..D 阈值递减,首中即停
|
||||
if total >= bounds[g]:
|
||||
grade = g
|
||||
break
|
||||
|
||||
return {
|
||||
"total_score": total,
|
||||
"grade": grade,
|
||||
"score_status": "scored" if total is not None else "insufficient_sample",
|
||||
"trial": bool(config.get("trial")),
|
||||
"dimensions": dimensions,
|
||||
"metrics_detail": metrics_detail,
|
||||
}
|
||||
@@ -901,7 +901,7 @@ def _normalize_chat_group(record: dict[str, Any]) -> dict[str, Any]:
|
||||
``'YYYY-MM-DD HH:mm:ss'`` 字符串,另兼容 ms-int/数字串,统一归一为
|
||||
ms-epoch int,复用 ``_parse_add_time`` 的双形态解析)
|
||||
"""
|
||||
return {
|
||||
rec = {
|
||||
"room_id": record.get("friendWxId"),
|
||||
"channel": "wechat",
|
||||
"room_name": record.get("friendWxNickname"),
|
||||
@@ -929,6 +929,12 @@ def _normalize_chat_group(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"version": record.get("version"),
|
||||
"raw_json": record,
|
||||
}
|
||||
# 群备注(2026-09-09 微信群统计):friend_list/es 是唯一备注源
|
||||
# (getChatroomList 行无备注键);**有值才写**——空值不放键,upsert 不
|
||||
# 更新该列,不会被无备注的抓取轮次清空
|
||||
if record.get("friendWxRemark"):
|
||||
rec["group_remark"] = record["friendWxRemark"]
|
||||
return rec
|
||||
|
||||
|
||||
async def scrape_wechat_groups(
|
||||
|
||||
@@ -15,6 +15,7 @@ import logging
|
||||
import math
|
||||
from bisect import bisect_right
|
||||
from collections.abc import Iterable
|
||||
from itertools import accumulate
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -170,8 +171,10 @@ async def _lifecycle_daily(
|
||||
) -> dict[int, dict[str, dict[str, int]]]:
|
||||
"""逐日 {day: {user: {state: count}}}——customer 当前归属 + 互动日 sweep。
|
||||
|
||||
数据源:msg_daily_stats friend 行全历史(互动日/条数)。16k 客户 × 窗口
|
||||
天数 bisect,夜间全量约秒级;4h tick 只算当日+3 天。
|
||||
数据源:msg_daily_stats friend 行全历史(互动日/条数)。hist 为 ≤as_of
|
||||
的历史消息数(时点语义:与 last 同一 bisect 位取按日累积数组 cum[idx],
|
||||
as_of 之后的消息不虚增历史日的沉睡门槛)。16k 客户 × 窗口天数
|
||||
bisect+classify,夜间全量实测见批次A核实文档;4h tick 只算当日+3 天。
|
||||
"""
|
||||
rows = (
|
||||
await session.execute(
|
||||
@@ -184,12 +187,13 @@ async def _lifecycle_daily(
|
||||
)
|
||||
)
|
||||
).all()
|
||||
days_map: dict[str, set[int]] = {}
|
||||
cnt: dict[str, int] = {}
|
||||
# peer → {day: 消息条数};days_list 与累积数组 cum 对齐:
|
||||
# cum[i] = 该客户 ≤days_list[i] 的历史消息数(hist 时点语义)
|
||||
day_msgs: dict[str, dict[int, int]] = {}
|
||||
for peer, day, snd, rcv in rows:
|
||||
days_map.setdefault(peer, set()).add(day)
|
||||
cnt[peer] = cnt.get(peer, 0) + int(snd or 0) + int(rcv or 0)
|
||||
interaction = {p: sorted(ds) for p, ds in days_map.items()}
|
||||
dm = day_msgs.setdefault(peer, {})
|
||||
dm[day] = dm.get(day, 0) + int(snd or 0) + int(rcv or 0)
|
||||
interaction = {p: sorted(dm) for p, dm in day_msgs.items()}
|
||||
|
||||
custs = (
|
||||
await session.execute(
|
||||
@@ -212,14 +216,15 @@ async def _lifecycle_daily(
|
||||
if uid is None:
|
||||
continue
|
||||
first = days_list[0]
|
||||
hist = cnt.get(peer, 0)
|
||||
cum = list(accumulate(day_msgs[peer][d] for d in days_list))
|
||||
for d in days:
|
||||
idx = bisect_right(days_list, d) - 1
|
||||
if idx < 0:
|
||||
continue
|
||||
last = days_list[idx]
|
||||
prev = days_list[idx - 1] if idx > 0 else None
|
||||
state = classify(first, last, prev, d, hist)
|
||||
hist_asof = cum[idx]
|
||||
state = classify(first, last, prev, d, hist_asof)
|
||||
if state:
|
||||
out[d].setdefault(uid, {}).setdefault(state, 0)
|
||||
out[d][uid][state] += 1
|
||||
@@ -345,4 +350,7 @@ async def recompute_metric_days(
|
||||
)
|
||||
)
|
||||
written += 1
|
||||
# 防御性 flush:防 autoflush=False 类配置下逐日 add 的 ORM 对象
|
||||
# 攒到事务尾(夜间全量约 52k 行;与 Task3 周期性 flush 同教训)
|
||||
await session.flush()
|
||||
return written
|
||||
|
||||
@@ -62,6 +62,7 @@ from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.db.models import (
|
||||
ChatGroup,
|
||||
ChatGroupMemberStats,
|
||||
Customer,
|
||||
CustomerAccountLink,
|
||||
Department,
|
||||
@@ -82,6 +83,7 @@ _logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"upsert_chat_group",
|
||||
"upsert_chat_group_member_stats",
|
||||
"upsert_customer",
|
||||
"upsert_customers",
|
||||
"prune_customer_links",
|
||||
@@ -631,6 +633,18 @@ async def upsert_chat_group(session: AsyncSession, rec: dict[str, Any]) -> None:
|
||||
await _upsert_one(session, GroupMember, member_rec)
|
||||
|
||||
|
||||
async def upsert_chat_group_member_stats(
|
||||
session: AsyncSession, rec: dict[str, Any]
|
||||
) -> None:
|
||||
"""Idempotently upsert one :class:`ChatGroupMemberStats` day row.
|
||||
|
||||
PK = (channel, owner_account_id, room_id, day)。每夜「当日窗」抓取的
|
||||
addMembers/lossMembers 落一行;同日重跑刷新(幂等)。页面任意日窗的
|
||||
新增/退出 = 按天求和(2026-09-09 微信群统计对齐)。
|
||||
"""
|
||||
await _upsert_one(session, ChatGroupMemberStats, rec)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PhoneCall (voice_call) upsert (M2, Task 2.4)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -789,8 +803,13 @@ async def upsert_media(
|
||||
|
||||
dialect = await _dialect_name(session)
|
||||
pk_cols = _pk_col_names(Media)
|
||||
# 只更新实际插入的列(2026-09-10 生产修复):此前取全部表列,server_default
|
||||
# 列(fetched_at/updated_at)不在 values 里 → MySQL 编译出
|
||||
# ``new.fetched_at`` 悬空引用 → 每夜 call_recordings 步骤 1054 静默
|
||||
# 失败,录音本地化停更多日。
|
||||
update_cols = [
|
||||
c.name for c in Media.__table__.columns if c.name not in pk_cols
|
||||
c.name for c in Media.__table__.columns
|
||||
if c.name not in pk_cols and c.name in values
|
||||
]
|
||||
media_cols = Media.__table__.c
|
||||
|
||||
@@ -798,7 +817,8 @@ async def upsert_media(
|
||||
"""Map each updated column to its conflict-set expression.
|
||||
|
||||
Volatile download artifacts use ``COALESCE(incoming, existing)``; every
|
||||
other column is refreshed directly from the incoming row.
|
||||
other column is refreshed directly from the incoming row. ``updated_at``
|
||||
刷新为 now()(冲突重下时反映最新一次落盘)。
|
||||
"""
|
||||
set_: dict[str, Any] = {}
|
||||
for name in update_cols:
|
||||
@@ -806,6 +826,7 @@ async def upsert_media(
|
||||
set_[name] = sa.func.coalesce(other_cols[name], media_cols[name])
|
||||
else:
|
||||
set_[name] = other_cols[name]
|
||||
set_["updated_at"] = sa.func.now()
|
||||
return set_
|
||||
|
||||
if dialect == "sqlite":
|
||||
|
||||
@@ -15,6 +15,9 @@ Three invocations (plus analytics backfills, see below)::
|
||||
python -m app.sync.cli daily-stats
|
||||
python -m app.sync.cli staff-daily-stats
|
||||
python -m app.sync.cli sensitive-scan --full
|
||||
python -m app.sync.cli first-reply-facts
|
||||
python -m app.sync.cli employee-metrics
|
||||
python -m app.sync.cli score-config
|
||||
|
||||
Auth + DB
|
||||
---------
|
||||
@@ -49,6 +52,8 @@ import signal
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import models # noqa: F401 (register tables on Base.metadata)
|
||||
from app.db.base import Base
|
||||
@@ -136,6 +141,43 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
help="Full backfill scan (first deploy / new-word history).",
|
||||
)
|
||||
|
||||
# ``first-reply-facts`` ----------------------------------------------
|
||||
p_fr = sub.add_parser(
|
||||
"first-reply-facts",
|
||||
help="Rebuild first_reply_fact (cross-day exact threads).",
|
||||
description=(
|
||||
"Full rebuild of the first-reply fact table from the message "
|
||||
"table (cross-day exact thread algorithm — overnight samples "
|
||||
"not lost, unlike msg_daily_stats day-cut). Deploy backfill + "
|
||||
"nightly self-heal step. Idempotent."
|
||||
),
|
||||
)
|
||||
|
||||
# ``employee-metrics`` ------------------------------------------------
|
||||
p_emp = sub.add_parser(
|
||||
"employee-metrics",
|
||||
help="Rebuild employee_metric_daily + employee_call_daily.",
|
||||
description=(
|
||||
"Full rebuild of the management wide table (per-day x channel "
|
||||
"x staff, 1:1 caliber) and the call daily table. Must run "
|
||||
"AFTER first-reply-facts (the fact table feeds Batch-B "
|
||||
"scoring and drilldown; both recompute threads from messages "
|
||||
"via one shared algorithm). Deploy backfill + nightly "
|
||||
"self-heal. Idempotent."
|
||||
),
|
||||
)
|
||||
|
||||
# ``score-config`` ----------------------------------------------------
|
||||
p_score = sub.add_parser(
|
||||
"score-config",
|
||||
help="Seed the default score config (idempotent).",
|
||||
description=(
|
||||
"Insert DEFAULT_SCORE_CONFIG (v1.0, trial) into "
|
||||
"employee_score_config as active. Skips if the version "
|
||||
"already exists. Batch B."
|
||||
),
|
||||
)
|
||||
|
||||
# ``scheduler`` -----------------------------------------------------------
|
||||
p_sched = sub.add_parser(
|
||||
"scheduler",
|
||||
@@ -374,6 +416,77 @@ async def _cmd_sensitive_scan(args: argparse.Namespace) -> int:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _cmd_first_reply_facts(args: argparse.Namespace) -> int:
|
||||
"""全量重建 first_reply_fact(跨日精确串;部署回填/夜间自愈,幂等)。"""
|
||||
settings = get_settings()
|
||||
engine, session_maker = await _init_db(settings.db_url)
|
||||
try:
|
||||
from app.storage.first_reply_facts import recompute_all
|
||||
|
||||
async with session_maker() as session:
|
||||
pairs = await recompute_all(session)
|
||||
await session.commit()
|
||||
print(f"[first-reply-facts] rebuilt {pairs} conversations")
|
||||
return 0
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _cmd_employee_metrics(args: argparse.Namespace) -> int:
|
||||
"""全量重建 employee_metric_daily + employee_call_daily(幂等)。"""
|
||||
settings = get_settings()
|
||||
engine, session_maker = await _init_db(settings.db_url)
|
||||
try:
|
||||
from app.db.models import MsgDailyStats, PhoneCall
|
||||
from app.storage.employee_metrics import (
|
||||
recompute_call_days,
|
||||
recompute_metric_days,
|
||||
)
|
||||
from app.storage.summary import day_key
|
||||
|
||||
async with session_maker() as session:
|
||||
msg_days = (
|
||||
await session.execute(sa.select(MsgDailyStats.day).distinct())
|
||||
).scalars().all()
|
||||
# 宽表全走消息派生源,只算消息日。
|
||||
rows = await recompute_metric_days(session, list(msg_days))
|
||||
# 通话日 = 消息日 ∪ 通话日:消息空窗日(节假日/同步缺口)的通话
|
||||
# 也要进小表。start_call_time→上海日必须在 Python 侧走 day_key
|
||||
# ——MySQL 会话时区是 UTC,SQL 日期函数会切错日。
|
||||
call_ts = (
|
||||
await session.execute(
|
||||
sa.select(PhoneCall.start_call_time)
|
||||
.where(PhoneCall.start_call_time.is_not(None))
|
||||
.distinct()
|
||||
)
|
||||
).scalars().all()
|
||||
call_days = sorted(set(msg_days) | {day_key(ts) for ts in call_ts})
|
||||
calls = await recompute_call_days(session, call_days)
|
||||
await session.commit()
|
||||
print(
|
||||
f"[employee-metrics] rebuilt {rows} wide rows, {calls} call rows "
|
||||
f"({len(call_days)} days)"
|
||||
)
|
||||
return 0
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _cmd_score_config(args: argparse.Namespace) -> int:
|
||||
settings = get_settings()
|
||||
engine, session_maker = await _init_db(settings.db_url)
|
||||
try:
|
||||
from app.management.metric_definitions import _seed_score_config
|
||||
|
||||
async with session_maker() as session:
|
||||
version = await _seed_score_config(session)
|
||||
await session.commit()
|
||||
print(f"[score-config] {'seeded ' + version if version else 'already present, skipped'}")
|
||||
return 0
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""CLI entry point. Returns a process exit code."""
|
||||
# Configure logging so the scheduler's JSON line + the orchestrator's
|
||||
@@ -398,6 +511,12 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return asyncio.run(_cmd_staff_daily_stats(args))
|
||||
if args.command == "sensitive-scan":
|
||||
return asyncio.run(_cmd_sensitive_scan(args))
|
||||
if args.command == "first-reply-facts":
|
||||
return asyncio.run(_cmd_first_reply_facts(args))
|
||||
if args.command == "employee-metrics":
|
||||
return asyncio.run(_cmd_employee_metrics(args))
|
||||
if args.command == "score-config":
|
||||
return asyncio.run(_cmd_score_config(args))
|
||||
except KeyboardInterrupt:
|
||||
# So `python -m app.sync.cli scheduler` exits cleanly on Ctrl-C.
|
||||
return 0
|
||||
|
||||
@@ -602,50 +602,77 @@ class SyncScheduler:
|
||||
if not bash:
|
||||
logger.error("page sync 'full': bash not found on PATH")
|
||||
return
|
||||
commands: list[list[str]] = [[bash, "scripts/full_sync_all.sh"]]
|
||||
commands = [([bash, "scripts/full_sync_all.sh"], 22 * 3600)] # 22h 上限:防极端情况拖进次日相位(全量常态 10-20h)
|
||||
elif kind == "messages":
|
||||
# 先刷客户列表(最近沟通时间是云客列表的实时排序键,friends 快照
|
||||
# 滞后会让本地排序与云客第一页完全不同,2026-08-20),再抓全部四类
|
||||
# 消息增量(2026-08-21 全接入:水位增量后单对话 1 页,可承受);
|
||||
# 末尾追加员工朋友圈浅扫(2026-08-25,max-pages 3 即每员工最多
|
||||
# 3 页,新帖通常落第一页;全量翻页由每日 full 走 full_sync_all.sh)
|
||||
# 2026-09-10 调度根治(用户报「员工列表时间差 2 天」):
|
||||
# ① 序列头部刷员工账号表(accounts ~1min)——员工列表「数据
|
||||
# 更新时间/最新消息时间」从此最多滞后 4h(此前只在每日
|
||||
# full 刷,长任务挤掉 full 就停更);
|
||||
# ② 消息实体只扫**近 24h 活跃**对话(--active-window-hours;
|
||||
# 从未扫过的不受限)——全对话遍历(WA 9,357 个×~3.5s≈9h)
|
||||
# 曾把序列拖到 15h+,超过 4h 间隔→与 02:00 full 背靠背
|
||||
# 互相挤死;陈旧对话无新消息,每日 full 兜底;
|
||||
# ③ 每命令超时保险丝(超时 kill 下轮补,增量幂等)。
|
||||
# friends 仍先刷(最近沟通时间是排序键+活跃窗口的判定源)。
|
||||
msg_entities = [
|
||||
("accounts", None, 900),
|
||||
("wa-accounts", None, 900),
|
||||
("friends", None, 2400),
|
||||
("wa-friends", None, 2400),
|
||||
("messages", 24, 4800),
|
||||
("wa-messages", 24, 4800),
|
||||
# chatroom-stats 全窗(~1min):刷 chat_group.last_chat_time——
|
||||
# 群消息活跃窗的判定源。不加则窗口用的是昨晚 full 的快照,
|
||||
# 「昨天安静、今天突然活跃」的群会被窗口漏到明晚(2026-09-10)
|
||||
("chatroom-stats", None, 1800),
|
||||
("group-messages", 24, 4800),
|
||||
("wa-group-messages", 24, 2400),
|
||||
("moments", None, 2400),
|
||||
]
|
||||
commands = [
|
||||
[sys.executable, "scripts/scrape_via_page.py", "--entity", "friends"],
|
||||
[sys.executable, "scripts/scrape_via_page.py", "--entity", "wa-friends"],
|
||||
] + [
|
||||
[
|
||||
sys.executable,
|
||||
"scripts/scrape_via_page.py",
|
||||
"--entity",
|
||||
e,
|
||||
"--max-conversations",
|
||||
"99999",
|
||||
]
|
||||
for e in ("messages", "wa-messages", "group-messages", "wa-group-messages")
|
||||
] + [
|
||||
[
|
||||
sys.executable,
|
||||
"scripts/scrape_via_page.py",
|
||||
"--entity",
|
||||
"moments",
|
||||
"--max-pages",
|
||||
"3",
|
||||
]
|
||||
(
|
||||
[
|
||||
sys.executable,
|
||||
"scripts/scrape_via_page.py",
|
||||
"--entity",
|
||||
entity,
|
||||
]
|
||||
+ (
|
||||
[
|
||||
"--max-conversations",
|
||||
"99999",
|
||||
"--active-window-hours",
|
||||
str(window),
|
||||
]
|
||||
if window
|
||||
else (
|
||||
["--max-pages", "3"]
|
||||
if entity == "moments"
|
||||
else []
|
||||
)
|
||||
),
|
||||
timeout,
|
||||
)
|
||||
for entity, window, timeout in msg_entities
|
||||
]
|
||||
elif kind == "full-history":
|
||||
# 每周全量对齐(周日 05:00):强制全量翻历史,对齐增量模式抓不到的
|
||||
# 撤回/编辑消息 + 彻底消除 lastChatTime 滞后窗口(2026-08-21)
|
||||
entities = ["messages", "group-messages", "wa-messages", "wa-group-messages"]
|
||||
commands = [
|
||||
[
|
||||
sys.executable,
|
||||
"scripts/scrape_via_page.py",
|
||||
"--entity",
|
||||
e,
|
||||
"--max-conversations",
|
||||
"99999",
|
||||
"--full-history",
|
||||
]
|
||||
(
|
||||
[
|
||||
sys.executable,
|
||||
"scripts/scrape_via_page.py",
|
||||
"--entity",
|
||||
e,
|
||||
"--max-conversations",
|
||||
"99999",
|
||||
"--full-history",
|
||||
],
|
||||
6 * 3600, # 单实体 6h 上限(2026-09-10:防周日的全历史
|
||||
# 对齐拖进周一,把 02:00 full 又挤掉——同类事故根治)
|
||||
)
|
||||
for e in entities
|
||||
]
|
||||
else:
|
||||
@@ -653,8 +680,8 @@ class SyncScheduler:
|
||||
return
|
||||
|
||||
rc = 0
|
||||
for args in commands:
|
||||
rc = await self._page_subprocess(args) or rc
|
||||
for args, timeout in commands:
|
||||
rc = await self._page_subprocess(args, timeout=timeout) or rc
|
||||
if rc != 0:
|
||||
logger.warning("page sync %s: command failed rc=%s, skipping rest", kind, rc)
|
||||
break
|
||||
@@ -674,25 +701,27 @@ class SyncScheduler:
|
||||
print(line, flush=True)
|
||||
|
||||
async def _analytics_tick(self) -> None:
|
||||
"""4h 分析链(stat_daily_staff 滚动3天 + 敏感词增量)——异常只记日志不断链。
|
||||
"""4h 分析链(staff 滚动3天 + 敏感词增量 + 管理域滚动)——异常只记日志不断链。
|
||||
|
||||
纯 DB 无浏览器子进程,不抢 ``_page_lock``(DB 写毫秒级;
|
||||
``max_instances=1`` 防自身重叠即可)。与夜间 full_sync 的
|
||||
staff-daily-stats 步骤重叠时,两侧 DELETE+INSERT 在重叠 PK 范围上
|
||||
可能死锁/锁等待——非致命:tick 吞异常下轮重试,夜间脚本无 set -e
|
||||
次日自愈(UNIQUE/PK 幂等只保证串行提交下不重复,不消锁竞争)。
|
||||
两段独立事务:段1 staff+敏感词(既有);段2 管理域(fact 近3天活跃会话
|
||||
+ 宽表/通话 滚动3天,批次F 2026-09-10)。段2 失败只回滚自身,不牵连
|
||||
段1 已提交结果(v2 Task12:指标失败不中断主分析链)。纯 DB 无浏览器
|
||||
子进程,不抢 ``_page_lock``;``max_instances=1`` 防自身重叠即可。与
|
||||
夜间 full_sync 的汇总/管理步重叠时,两侧 DELETE+INSERT 在重叠 PK
|
||||
范围可能锁等待——非致命:tick 吞异常下轮重试,夜间脚本幂等自愈。
|
||||
|
||||
时区:``day_key`` 内部钉死 Asia/Shanghai,喂 UTC epoch
|
||||
(``time.time()*1000``)即无歧义——用 epoch 而非 naive datetime
|
||||
只为读法直接,数学上两者经本地时区往返等价。
|
||||
(``time.time()*1000``)即无歧义。
|
||||
"""
|
||||
from app.storage.summary import day_key
|
||||
|
||||
today = day_key(int(time.time() * 1000))
|
||||
# ── 段1:staff 滚动 + 敏感词增量(既有,语义原样) ──
|
||||
try:
|
||||
async with self._session_maker() as session:
|
||||
from app.analytics.sensitive import scanner
|
||||
from app.storage.staff_summary import recompute_days, rolling_days
|
||||
from app.storage.summary import day_key
|
||||
|
||||
today = day_key(int(time.time() * 1000))
|
||||
n = await recompute_days(session, rolling_days(today, 3))
|
||||
scan = await scanner.run_incremental(session)
|
||||
await session.commit()
|
||||
@@ -703,8 +732,39 @@ class SyncScheduler:
|
||||
except Exception: # noqa: BLE001 — scheduler MUST NOT crash
|
||||
logger.exception("analytics tick failed")
|
||||
|
||||
async def _page_subprocess(self, args: list[str]) -> int:
|
||||
"""启动页面子进程并等待结束;继承环境 + 补 DB_URL/PYTHONIOENCODING。"""
|
||||
# ── 段2:管理域滚动(fact 活跃会话 + 宽表/通话,当日+滚动3天) ──
|
||||
try:
|
||||
async with self._session_maker() as session:
|
||||
from app.storage.employee_metrics import (
|
||||
recompute_call_days,
|
||||
recompute_metric_days,
|
||||
)
|
||||
from app.storage.first_reply_facts import (
|
||||
pairs_active_in_days,
|
||||
recompute_pairs,
|
||||
)
|
||||
from app.storage.staff_summary import rolling_days
|
||||
|
||||
rd = rolling_days(today, 3)
|
||||
pairs = await pairs_active_in_days(session, rd)
|
||||
f = await recompute_pairs(session, pairs)
|
||||
w = await recompute_metric_days(session, rd)
|
||||
c = await recompute_call_days(session, rd)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"[analytics-tick] mgmt fact_pairs=%s wide_rows=%s call_rows=%s",
|
||||
f, w, c,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("management metrics tick failed")
|
||||
|
||||
async def _page_subprocess(self, args: list[str], *, timeout: float | None = None) -> int:
|
||||
"""启动页面子进程并等待结束;继承环境 + 补 DB_URL/PYTHONIOENCODING。
|
||||
|
||||
``timeout``(秒,2026-09-10 调度根治保险丝):超时 kill 子进程返回
|
||||
非零——上游 break 跳过本序列剩余命令,增量幂等下轮自动补,防止
|
||||
单命令拖死整个调度相位(4h 序列曾因此 15h+ 与 full 互相挤死)。
|
||||
"""
|
||||
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
|
||||
settings = get_settings()
|
||||
if settings.db_url and "DB_URL" not in env:
|
||||
@@ -714,7 +774,18 @@ class SyncScheduler:
|
||||
cwd=str(REPO_ROOT),
|
||||
env=env,
|
||||
)
|
||||
return await proc.wait()
|
||||
if timeout is None:
|
||||
return await proc.wait()
|
||||
try:
|
||||
return await asyncio.wait_for(proc.wait(), timeout)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(
|
||||
"page sync subprocess timeout after %.0fs (killed): %s",
|
||||
timeout, " ".join(args[-6:]),
|
||||
)
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
return 124 # 超时语义(同 shell timeout 惯例)
|
||||
|
||||
async def _safe_run(
|
||||
self, entity: str, *, log: bool = True
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""chat_group 补 group_remark 列 + chat_group_member_stats 按天进出表
|
||||
|
||||
Revision ID: c9d0e1f2a3b4
|
||||
Revises: b7c8d9e0f1a2
|
||||
Create Date: 2026-09-09
|
||||
|
||||
内容
|
||||
----
|
||||
微信群列表对齐云客「微信群统计」页(2026-09-09 真机探明,数据源切
|
||||
``getChatroomList``):
|
||||
|
||||
* ``chat_group.group_remark`` —— 群备注列(getChatroomList.remark /
|
||||
friend_list/es 的 friendWxRemark,此前只存 raw_json);
|
||||
* ``chat_group_member_stats`` —— 群成员进出**按天**统计表。云客的新增/
|
||||
退出成员数是按任意时间窗实时算的,本地无成员进出事件流,改为每夜抓
|
||||
一次「当日窗」的 addMembers/lossMembers 落行,页面任意日窗=按天求和;
|
||||
上线日起积累,历史日期无数据。
|
||||
|
||||
幂等:列/表存在即跳过。SQLite/MySQL 通用。
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c9d0e1f2a3b4'
|
||||
down_revision: str | Sequence[str] | None = 'b7c8d9e0f1a2'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_TABLE = 'chat_group_member_stats'
|
||||
_COL = 'group_remark'
|
||||
|
||||
|
||||
def _has_table(table: str) -> bool:
|
||||
insp = sa.inspect(op.get_bind())
|
||||
return table in insp.get_table_names()
|
||||
|
||||
|
||||
def _has_column(table: str, column: str) -> bool:
|
||||
insp = sa.inspect(op.get_bind())
|
||||
return column in {c["name"] for c in insp.get_columns(table)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
if not _has_column("chat_group", _COL):
|
||||
op.add_column("chat_group", sa.Column(_COL, sa.String(255), nullable=True))
|
||||
# 回填:群备注从存量 raw_json.friendWxRemark 提取(friend_list/es 来源;
|
||||
# getChatroomList 行无备注键)。幂等:只补 NULL 行。MySQL 方言
|
||||
# (JSON_UNQUOTE),SQLite(测试库,不走生产迁移)跳过。
|
||||
if op.get_bind().dialect.name == "mysql":
|
||||
op.execute(
|
||||
"UPDATE chat_group SET group_remark = JSON_UNQUOTE(JSON_EXTRACT(raw_json, '$.friendWxRemark')) "
|
||||
"WHERE group_remark IS NULL "
|
||||
"AND JSON_UNQUOTE(JSON_EXTRACT(raw_json, '$.friendWxRemark')) IS NOT NULL "
|
||||
"AND JSON_UNQUOTE(JSON_EXTRACT(raw_json, '$.friendWxRemark')) != ''"
|
||||
)
|
||||
if _has_table(_TABLE):
|
||||
return # 已建过,幂等跳过
|
||||
op.create_table(
|
||||
_TABLE,
|
||||
sa.Column('channel', sa.String(16), primary_key=True),
|
||||
sa.Column('owner_account_id', sa.String(128), primary_key=True),
|
||||
sa.Column('room_id', sa.String(128), primary_key=True),
|
||||
sa.Column('day', sa.Integer(), primary_key=True),
|
||||
sa.Column('added', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('lost', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('fetched_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
if _has_table(_TABLE):
|
||||
op.drop_table(_TABLE)
|
||||
if _has_column("chat_group", _COL):
|
||||
op.drop_column("chat_group", _COL)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""message 补无渠道前缀索引 (owner_account_id, talker_id, timestamp)
|
||||
|
||||
Revision ID: d8e9f0a1b2c3
|
||||
Revises: c9d0e1f2a3b4
|
||||
Create Date: 2026-09-10
|
||||
|
||||
背景:该回未回决赛圈点查在 channel=None(管理驾驶舱 action_counts)时
|
||||
无渠道前缀条件,现有索引全部以 channel 打头用不上——服务器 100 万行
|
||||
实测 dashboard 34~37s 撞前端 15s 超时;索引落地后 1.3~1.8s。
|
||||
幂等:sa.inspect 先查,存在即跳过(服务器已先行手工建同名索引,本迁移
|
||||
兜底对齐 schema 版本)。「模型+迁移双声明」防 autogenerate 反 DROP。
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "d8e9f0a1b2c3"
|
||||
down_revision: str | Sequence[str] | None = "c9d0e1f2a3b4"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_IDX = "ix_message_owner_talker_time"
|
||||
|
||||
|
||||
def _has_index(bind) -> bool:
|
||||
insp = sa.inspect(bind)
|
||||
if not insp.has_table("message"):
|
||||
return False
|
||||
return _IDX in {ix["name"] for ix in insp.get_indexes("message")}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _has_index(op.get_bind()):
|
||||
op.create_index(_IDX, "message",
|
||||
["owner_account_id", "talker_id", "timestamp"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_index(op.get_bind()):
|
||||
op.drop_index(_IDX, table_name="message")
|
||||
@@ -1,12 +1,15 @@
|
||||
#!/usr/bin/env python
|
||||
"""从群消息派生 group_member(发言者=成员)。
|
||||
"""从群消息派生 group_member(发言者=成员)+ customer 兜底补全昵称/头像。
|
||||
|
||||
云客 API 不单独给群成员列表(chatroom payload 的 memberIds 我们抓不到,
|
||||
2026-08-20 确认),但群消息的 talker 就是发言成员的 wxid、sender_name 是其
|
||||
群昵称——368k 群消息足够派生出高质量成员表(覆盖所有发过言的成员)。
|
||||
2026-08-20 确认),但群消息的 talker 就是发言成员的 wxid、sender_name/
|
||||
sender_head_url 是其群昵称/头像——群消息足够派生出高质量成员表(覆盖
|
||||
所有发过言的成员)。昵称/头像取该成员在群里**最新一条**消息的值。
|
||||
|
||||
昵称取该成员在群里最新一条消息的 sender_name(SQLite 的 max()+裸列行为,
|
||||
取 max(timestamp) 同行的值)。幂等可重跑;建议在 group-messages 之后执行。
|
||||
未发言的成员(被拉进群从没说话)从 customer 表兜底:member_id=friend_id
|
||||
命中本司好友时补好友昵称/头像(2026-09-10 群详情成员墙)。
|
||||
|
||||
幂等可重跑;建议在 group-messages 之后执行(full_sync_all.sh 8b 已挂)。
|
||||
|
||||
用法:
|
||||
cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" \
|
||||
@@ -26,13 +29,14 @@ from app.storage.repos import _upsert_one
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger("backfill_group_members")
|
||||
|
||||
# row_number() 窗口取 timestamp 最新一行的 sender_name:MySQL 8 的
|
||||
# only_full_group_by 拒绝 SQLite 式「max()+裸列」(1055),窗口写法两库通用
|
||||
# 且语义严格(最新一条的昵称),不依赖方言碰巧行为
|
||||
# row_number() 窗口取 timestamp 最新一行的 sender_name/sender_head_url:
|
||||
# MySQL 8 的 only_full_group_by 拒绝 SQLite 式「max()+裸列」(1055),窗口
|
||||
# 写法两库通用且语义严格(最新一条的昵称/头像),不依赖方言碰巧行为
|
||||
SQL = sa_text("""
|
||||
SELECT room_id, talker_id, sender_name, last_ts, msg_count
|
||||
SELECT room_id, talker_id, sender_name, sender_head_url, last_ts, msg_count
|
||||
FROM (
|
||||
SELECT room_id, talker_id, sender_name, timestamp AS last_ts,
|
||||
SELECT room_id, talker_id, sender_name, sender_head_url,
|
||||
timestamp AS last_ts,
|
||||
count(*) OVER (PARTITION BY room_id, talker_id) AS msg_count,
|
||||
row_number() OVER (
|
||||
PARTITION BY room_id, talker_id ORDER BY timestamp DESC
|
||||
@@ -44,6 +48,17 @@ FROM (
|
||||
WHERE rn = 1
|
||||
""")
|
||||
|
||||
# 未发言成员兜底:member_id 命中 customer(本司好友)时补 NULL 的昵称/头像。
|
||||
# customer 全局一人一行(PK=friend_id),join 不放大行数;COALESCE 只补不清。
|
||||
CUSTOMER_FALLBACK = sa_text("""
|
||||
UPDATE group_member gm
|
||||
JOIN customer c ON c.friend_id = gm.member_id AND c.channel = gm.channel
|
||||
SET gm.nickname = COALESCE(gm.nickname, c.friend_nickname),
|
||||
gm.avatar = COALESCE(gm.avatar, c.head_image_url)
|
||||
WHERE gm.channel = 'wechat'
|
||||
AND (gm.nickname IS NULL OR gm.avatar IS NULL)
|
||||
""")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with async_engine.connect() as conn:
|
||||
@@ -51,12 +66,13 @@ async def main() -> None:
|
||||
log.info(f"派生出 {len(rows)} 个 (群, 成员) 对")
|
||||
|
||||
async with async_session_maker() as session:
|
||||
for room_id, talker_id, sender_name, last_ts, msg_count in rows:
|
||||
for room_id, talker_id, sender_name, head_url, last_ts, msg_count in rows:
|
||||
await _upsert_one(session, GroupMember, {
|
||||
"channel": "wechat",
|
||||
"room_id": room_id,
|
||||
"member_id": talker_id,
|
||||
"nickname": sender_name,
|
||||
"avatar": head_url,
|
||||
# raw_json 附加派生统计,抽屉可见
|
||||
"raw_json": {
|
||||
"derived_from": "group_messages",
|
||||
@@ -65,7 +81,13 @@ async def main() -> None:
|
||||
},
|
||||
})
|
||||
await session.commit()
|
||||
log.info(f"DONE: group_member upserted={len(rows)}")
|
||||
|
||||
# 兜底补未发言成员(独立事务,幂等)
|
||||
async with async_engine.begin() as conn:
|
||||
res = await conn.execute(CUSTOMER_FALLBACK)
|
||||
log.info(f"customer 兜底补全 {res.rowcount} 行昵称/头像")
|
||||
|
||||
log.info("DONE: group_member upsert 完成")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -26,6 +26,12 @@ echo "$(date) --- 5/10 WeChat groups ---"
|
||||
$PY scripts/scrape_via_page.py --entity groups --max-employees 100
|
||||
echo "$(date) --- 5b/10 WA groups ---"
|
||||
$PY scripts/scrape_via_page.py --entity wa-groups
|
||||
# 5c: 微信群统计(getChatroomList,云客「微信群统计」同款)——全窗刷档案列
|
||||
# (真成员数/群备注/员工信息)+ 昨日窗落成员进出日行(页面窗内新增/退出)
|
||||
echo "$(date) --- 5c/10 WeChat chatroom stats ---"
|
||||
$PY scripts/scrape_via_page.py --entity chatroom-stats
|
||||
YDAY=$(date -d "yesterday" +%F)
|
||||
$PY scripts/scrape_via_page.py --entity chatroom-stats --day "$YDAY"
|
||||
echo "$(date) --- 6/10 Voice calls (WeChat + WA) ---"
|
||||
$PY scripts/scrape_via_page.py --entity voice-calls
|
||||
# 语音重扫会把 record_media_hash 覆盖回 None —— 必须紧跟录音重链接
|
||||
@@ -53,6 +59,15 @@ $PY -m app.sync.cli daily-stats
|
||||
# A1 员工指标读路径依赖此表,2026-09-04)
|
||||
echo "$(date) --- 12/12 Staff-daily-stats rebuild ---"
|
||||
$PY -m app.sync.cli staff-daily-stats
|
||||
# 13:首响事实表全量重建(管理域,跨日精确串;批次A)——会话发现读
|
||||
# msg_daily_stats(fact 行直算读 message),故须在 11 步后;与 11/12
|
||||
# 同为夜间自愈链(绕过钩子的写入由全量重算纠正)
|
||||
echo "$(date) --- 13/14 First-reply-facts rebuild ---"
|
||||
$PY -m app.sync.cli first-reply-facts
|
||||
# 14:管理宽表+通话小表全量重建(驾驶舱/员工页读路径;串类列与 fact 同
|
||||
# 算法各自直算,放 fact 后保持派生链顺序语义)
|
||||
echo "$(date) --- 14/14 Employee-metrics rebuild ---"
|
||||
$PY -m app.sync.cli employee-metrics
|
||||
|
||||
echo "=============================================="
|
||||
echo "$(date) === FULL RE-SYNC COMPLETE ==="
|
||||
@@ -78,6 +93,9 @@ TABLES = [
|
||||
("wechat messages", "SELECT COUNT(*) FROM message WHERE channel='wechat'"),
|
||||
("whatsapp messages", "SELECT COUNT(*) FROM message WHERE channel='whatsapp'"),
|
||||
("daily stats rows", "SELECT COUNT(*) FROM msg_daily_stats"),
|
||||
("first reply facts", "SELECT COUNT(*) FROM first_reply_fact"),
|
||||
("employee metric rows", "SELECT COUNT(*) FROM employee_metric_daily"),
|
||||
("employee call rows", "SELECT COUNT(*) FROM employee_call_daily"),
|
||||
("voice calls", "SELECT COUNT(*) FROM phone_call WHERE channel IN ('voice_call','wa_voice_call')"),
|
||||
("voice clips", "SELECT COUNT(*) FROM voice_clip"),
|
||||
("moments posts", "SELECT COUNT(*) FROM moments_post"),
|
||||
|
||||
@@ -514,7 +514,50 @@ async def paginate_chats(fetch_page, end_ms: int, max_pages: int = 200, sleep=No
|
||||
await sleep(1)
|
||||
|
||||
|
||||
async def _discover_conversations(channel: str, voice_channel: str) -> list[tuple[str, str]]:
|
||||
# ---------------------------------------------------------------------------
|
||||
# 空对话/空群「已扫」记忆(2026-09-10 同步审计):从未有消息的对话(纯语音/
|
||||
# 未聊过客户,WA 实测 2,468 个)水位恒 0,每轮都被当 unscanned 全量翻史
|
||||
# (~2.6s/个)——时长黑洞本体。拉到 0 条有效消息即记 sync_state 扫描标记;
|
||||
# 发现层排除「已扫空且不活跃」的——客户一旦开始发消息(last_chat_time 进
|
||||
# 活跃窗)自动回到访问集;周日 full_history 强制全访问兜底(云客补历史)。
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _mark_scan_empty(kind: str, scope: str) -> None:
|
||||
"""记录「该对话/群已扫且无消息」。UPDATE 0 行则 INSERT(sync_state 无唯一键)。"""
|
||||
import time as _t
|
||||
|
||||
from app.db.base import async_engine
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
now_ms = int(_t.time() * 1000)
|
||||
async with async_engine.begin() as conn:
|
||||
res = await conn.execute(sa_text(
|
||||
"UPDATE sync_state SET last_timestamp = :ts "
|
||||
"WHERE entity_type = :et AND scope_id = :sid"
|
||||
), {"et": kind, "sid": scope, "ts": now_ms})
|
||||
if not res.rowcount:
|
||||
await conn.execute(sa_text(
|
||||
"INSERT INTO sync_state (entity_type, channel, scope_id, last_timestamp) "
|
||||
"VALUES (:et, NULL, :sid, :ts)"
|
||||
), {"et": kind, "sid": scope, "ts": now_ms})
|
||||
|
||||
|
||||
async def _load_scan_empty(kind: str) -> set[str]:
|
||||
from app.db.base import async_engine
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
async with async_engine.connect() as conn:
|
||||
rows = (await conn.execute(sa_text(
|
||||
"SELECT scope_id FROM sync_state WHERE entity_type = :et"
|
||||
), {"et": kind})).fetchall()
|
||||
return {r[0] for r in rows if r[0]}
|
||||
|
||||
|
||||
async def _discover_conversations(
|
||||
channel: str, voice_channel: str, active_since_ms: int | None = None,
|
||||
*, exclude_scanned_empty: bool = True,
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Conversation-pair discovery for the message scrapers (wechat + WA).
|
||||
|
||||
Sources (union, dedup):
|
||||
@@ -527,6 +570,11 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
|
||||
|
||||
Order: pairs with no messages yet FIRST (new coverage survives an
|
||||
interruption), then the rest by customer last_chat_time DESC.
|
||||
|
||||
``active_since_ms``(2026-09-10 调度根治):4h 增量模式传「now-24h」——
|
||||
**已扫过**的对话仅保留 last_chat_time 在窗内的(活跃的),陈旧对话本轮
|
||||
跳过(没有新消息,每日全量兜底);**从未扫过**的(fresh)无论新旧全保
|
||||
(新覆盖优先原则不变)。None=不过滤(全量模式)。
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
@@ -536,14 +584,14 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
|
||||
async with async_engine.connect() as conn:
|
||||
cust = (
|
||||
await conn.execute(sa_text(
|
||||
"SELECT owner_account_id, friend_id FROM customer "
|
||||
"SELECT owner_account_id, friend_id, last_chat_time FROM customer "
|
||||
"WHERE channel=:ch AND owner_account_id IS NOT NULL AND friend_id IS NOT NULL "
|
||||
"ORDER BY last_chat_time DESC"
|
||||
), {"ch": channel})
|
||||
).fetchall()
|
||||
voice = (
|
||||
await conn.execute(sa_text(
|
||||
"SELECT raw_json FROM phone_call WHERE channel=:vc"
|
||||
"SELECT raw_json, start_call_time FROM phone_call WHERE channel=:vc"
|
||||
), {"vc": voice_channel})
|
||||
).fetchall()
|
||||
with_msgs = (
|
||||
@@ -554,20 +602,45 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
|
||||
).fetchall()
|
||||
|
||||
have = {(r[0], r[1]) for r in with_msgs}
|
||||
scanned_empty = (
|
||||
await _load_scan_empty(f"{channel}_conv_empty") if exclude_scanned_empty else set()
|
||||
)
|
||||
seen: set[tuple[str, str]] = set()
|
||||
fresh: list[tuple[str, str]] = []
|
||||
known: list[tuple[str, str]] = []
|
||||
|
||||
def _add(key: tuple[str, str]) -> None:
|
||||
def _active(last_ts: int | None) -> bool:
|
||||
"""窗口判定:未开窗口恒真;有窗时 last_ts 缺失(从未聊过)也保(fresh)。"""
|
||||
if active_since_ms is None:
|
||||
return True
|
||||
return last_ts is None or last_ts >= active_since_ms
|
||||
|
||||
def _add(key: tuple[str, str], last_ts: int | None) -> None:
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
(fresh if key not in have else known).append(key)
|
||||
if key not in have:
|
||||
# 从未扫过:新覆盖优先,不受窗口限制。但「已扫空且不活跃」的除外
|
||||
# (2026-09-10:WA 2,468 个无消息对话每轮全翻史的黑洞)——一旦
|
||||
# 活跃(last_chat_time 进窗)自动回到访问集;full_history 强制全访。
|
||||
scope = f"{key[0]}:{key[1]}"
|
||||
if (
|
||||
exclude_scanned_empty
|
||||
and scope in scanned_empty
|
||||
and active_since_ms is not None
|
||||
and not _active(last_ts)
|
||||
):
|
||||
return
|
||||
fresh.append(key)
|
||||
elif _active(last_ts):
|
||||
known.append(key)
|
||||
# else: 已扫过且陈旧 → 本轮跳过
|
||||
|
||||
for owner, friend in cust:
|
||||
_add((owner, friend))
|
||||
for owner, friend, last_ts in cust:
|
||||
_add((owner, friend), last_ts)
|
||||
voice_only = 0
|
||||
for (rj,) in voice:
|
||||
cust_keys = {(r[0], r[1]) for r in cust}
|
||||
for rj, call_ts in voice:
|
||||
try:
|
||||
d = _json.loads(rj)
|
||||
except (TypeError, ValueError):
|
||||
@@ -576,13 +649,15 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
|
||||
if not owner or not talker or str(talker).endswith("@chatroom"):
|
||||
continue
|
||||
before = len(seen)
|
||||
_add((str(owner), str(talker)))
|
||||
if len(seen) > before and (str(owner), str(talker)) not in cust:
|
||||
_add((str(owner), str(talker)), call_ts)
|
||||
if len(seen) > before and (str(owner), str(talker)) not in cust_keys:
|
||||
voice_only += 1
|
||||
|
||||
skipped = len(cust) + voice_only - len(fresh) - len(known)
|
||||
logger.info(
|
||||
f"discovery[{channel}]: {len(cust)} customer pairs + {voice_only} voice-only pairs, "
|
||||
f"{len(fresh)} unscanned (first) / {len(known)} already have messages"
|
||||
f"{len(fresh)} unscanned (first) / {len(known)} active in window"
|
||||
+ (f" / {skipped} stale skipped" if active_since_ms is not None else f" / {len(known)} already have messages")
|
||||
)
|
||||
return fresh + known
|
||||
|
||||
@@ -635,7 +710,7 @@ def _page_min_ts(data: dict) -> int:
|
||||
return min(ts_list) if ts_list else 0
|
||||
|
||||
|
||||
async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_history: bool = False):
|
||||
async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_history: bool = False, active_window_hours: int | None = None):
|
||||
"""Scrape wechat messages via browser fetch POST /wechatweb/msg/chats.
|
||||
|
||||
**增量同步(默认)**:每对话水位 = 本地 max(timestamp);请求带
|
||||
@@ -648,7 +723,15 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_hi
|
||||
from app.db.base import async_session_maker
|
||||
from app.storage.repos import upsert_messages
|
||||
|
||||
conversations = await _discover_conversations("wechat", "voice_call")
|
||||
conversations = await _discover_conversations(
|
||||
"wechat", "voice_call",
|
||||
active_since_ms=(
|
||||
int((__import__("time").time() - active_window_hours * 3600) * 1000)
|
||||
if active_window_hours else None
|
||||
),
|
||||
# full_history(周日对齐)强制全访问:云客可能补出历史消息
|
||||
exclude_scanned_empty=not full_history,
|
||||
)
|
||||
watermarks = await _conversation_watermarks("wechat")
|
||||
cust_activity = await _customer_activity("wechat")
|
||||
|
||||
@@ -737,6 +820,9 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_hi
|
||||
ts = int(str(ts_raw)) if ts_raw else None
|
||||
except (ValueError, TypeError):
|
||||
ts = None
|
||||
# 增量模式过滤边界旧消息(云客 start inclusive,2026-09-10)
|
||||
if watermark and ts is not None and ts <= watermark:
|
||||
continue
|
||||
records.append({
|
||||
"msg_id": str(row.get("msgId") or row.get("msgSvrId") or row.get("id") or f"{owner_id}:{friend_id}:{i}:{len(records)}"),
|
||||
"channel": "wechat",
|
||||
@@ -786,6 +872,8 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_hi
|
||||
continue
|
||||
|
||||
total_fetched += len(records)
|
||||
if not records:
|
||||
await _mark_scan_empty("wechat_conv_empty", f"{owner_id}:{friend_id}")
|
||||
total_pages += pages
|
||||
|
||||
if records:
|
||||
@@ -853,7 +941,7 @@ def _normalize_group_message(row: dict, owner_id: str, room_id: str) -> dict | N
|
||||
}
|
||||
|
||||
|
||||
async def scrape_group_messages(cookie_str: str, max_groups: int = 500, full_history: bool = False):
|
||||
async def scrape_group_messages(cookie_str: str, max_groups: int = 500, full_history: bool = False, active_window_hours: int | None = None):
|
||||
"""Scrape WeChat GROUP chat messages via browser fetch POST /wechatweb/msg/chats.
|
||||
|
||||
Same endpoint as 1:1 chats — pass the room id as ``friendId`` (confirmed
|
||||
@@ -868,12 +956,17 @@ async def scrape_group_messages(cookie_str: str, max_groups: int = 500, full_his
|
||||
from app.storage.repos import upsert_messages
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
active_since_ms = (
|
||||
int((__import__("time").time() - active_window_hours * 3600) * 1000)
|
||||
if active_window_hours else None
|
||||
)
|
||||
async with async_engine.connect() as conn:
|
||||
result = await conn.execute(sa_text(
|
||||
"SELECT owner_account_id, room_id FROM chat_group "
|
||||
"WHERE channel='wechat' AND owner_account_id IS NOT NULL "
|
||||
"AND (:t IS NULL OR last_chat_time IS NULL OR last_chat_time >= :t) "
|
||||
"ORDER BY last_chat_time DESC"
|
||||
))
|
||||
), {"t": active_since_ms})
|
||||
groups = [(row[0], row[1]) for row in result.fetchall()]
|
||||
|
||||
room_wms = await _room_watermarks()
|
||||
@@ -934,6 +1027,9 @@ async def scrape_group_messages(cookie_str: str, max_groups: int = 500, full_his
|
||||
continue
|
||||
rec = _normalize_group_message(row, owner_id, room_id)
|
||||
if rec is not None:
|
||||
# 增量模式过滤边界旧消息(同 1:1,2026-09-10)
|
||||
if watermark and (rec.get("timestamp") or 0) <= watermark:
|
||||
continue
|
||||
records.append(rec)
|
||||
|
||||
except Exception as e:
|
||||
@@ -1631,6 +1727,200 @@ async def scrape_wa_groups(cookie_str: str, max_pages: int = 200):
|
||||
return total_fetched, total_upserted
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 微信群统计 getChatroomList(2026-09-09 真机探明,云客同款数据源)
|
||||
#
|
||||
# 云客「沟通→微信号管理→微信群统计」页(/cms/wechat/wechatgroup)数据源:
|
||||
# POST /wechatweb/chatroom/getChatroomList(form-urlencoded)
|
||||
# departmentId + startTime/endTime('YYYY-MM-DD HH:MM:SS' 时间窗) + 分页;
|
||||
# 信封 data.overviews[] + totalCount(1,854 行,员工×群维度)。
|
||||
# 行含:真 memberCount(群成员数量——friend_list/es 的 friendNum 其实是
|
||||
# 员工好友数,旧映射存反了)、remark(群备注)、员工微信(wechatId=微信号
|
||||
# alias/wechatNickName/头像)、员工信息(userName/userPhone)、以及**窗内**
|
||||
# addMembers/lossMembers(新增/退出成员数,任意窗实时算)。
|
||||
#
|
||||
# 本地无成员进出事件流 → add/loss 只能按天积累:每夜抓「当日窗」落
|
||||
# chat_group_member_stats 日行,页面任意日窗=按天求和(历史无数据)。
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CHATROOM_LIST_PATH = "/wechatweb/chatroom/getChatroomList"
|
||||
|
||||
|
||||
def _normalize_chatroom_stat_row(row: dict, owner_wxid: str) -> dict:
|
||||
"""getChatroomList 行 → ChatGroup 补列记录。
|
||||
|
||||
只放该端点有的字段——未出现的列不参与 upsert 的 UPDATE 子句,
|
||||
friend_list/es 抓的群主信息等旧值得以保留。
|
||||
"""
|
||||
rec = {
|
||||
"room_id": row.get("chatRoomId"),
|
||||
"channel": "wechat",
|
||||
"owner_account_id": owner_wxid,
|
||||
"room_name": row.get("chatRoomName"),
|
||||
# 真群成员数(修正:旧源 friendNum 是「员工好友数」被误存此列)
|
||||
"member_count": row.get("memberCount"),
|
||||
"head_image_url": row.get("headImageUrl"),
|
||||
"owner_account_nickname": row.get("wechatNickName"),
|
||||
"owner_user_id": row.get("userId"),
|
||||
"owner_user_phone": row.get("userPhone"),
|
||||
"message_count": row.get("messageCount"),
|
||||
"own_message_count": row.get("ownMessageCount"),
|
||||
"add_members": row.get("addMembers"), # 本窗快照(日窗=当日进出)
|
||||
"loss_members": row.get("lossMembers"),
|
||||
"last_chat_time": row.get("lastChatTime"),
|
||||
"update_time": row.get("updateTime"),
|
||||
"raw_json": row,
|
||||
}
|
||||
# 群备注:getChatroomList 行通常无 remark 键(云客页面也显示 -);有才写,
|
||||
# 无则不放该键——upsert 不更新未出现的列,friend_list/es 来源的
|
||||
# friendWxRemark 备注得以保留
|
||||
if row.get("remark"):
|
||||
rec["group_remark"] = row["remark"]
|
||||
return rec
|
||||
|
||||
|
||||
async def _chatroom_rows(
|
||||
page, dept: str, start_str: str, end_str: str, max_pages: int = 100
|
||||
) -> tuple[list[dict], int]:
|
||||
"""翻完 getChatroomList 一个窗,返回 (全部行, 云客声明 total)。"""
|
||||
rows_all: list[dict] = []
|
||||
declared = 0
|
||||
page_num = 1
|
||||
while page_num <= max_pages:
|
||||
body = await _browser_fetch(
|
||||
page, CHATROOM_LIST_PATH,
|
||||
form_body={
|
||||
"departmentId": dept, "startTime": start_str, "endTime": end_str,
|
||||
"pageNum": page_num, "pageSize": 50,
|
||||
},
|
||||
)
|
||||
data = body.get("data") or {}
|
||||
rows = data.get("overviews") or []
|
||||
rows_all.extend(rows)
|
||||
declared = data.get("totalCount") or declared
|
||||
logger.info(f" chatroom-list page {page_num}: {len(rows)} rows (total {declared})")
|
||||
if not rows or page_num * 50 >= (declared or 0):
|
||||
break
|
||||
page_num += 1
|
||||
await asyncio.sleep(1.2)
|
||||
return rows_all, declared or 0
|
||||
|
||||
|
||||
async def _owner_alias_map(conn):
|
||||
"""wechat_account 的 alias/nick_name → wx_id 映射。
|
||||
|
||||
getChatroomList 行的 wechatId 是员工**微信号**(alias,如 Nancyjade)
|
||||
而非 wxId,owner 归属需经此解析;miss 时回退存原值(不影响其余列)。
|
||||
"""
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
result = await conn.execute(sa_text(
|
||||
"SELECT alias, nick_name, wx_id FROM wechat_account "
|
||||
"WHERE wx_id IS NOT NULL AND wx_id != ''"
|
||||
))
|
||||
mapping: dict[str, str] = {}
|
||||
for alias, nick, wx in result.fetchall():
|
||||
if alias:
|
||||
mapping[str(alias)] = wx
|
||||
if nick:
|
||||
mapping.setdefault(str(nick), wx)
|
||||
return mapping
|
||||
|
||||
|
||||
async def scrape_chatroom_stats(cookie_str: str, day: str | None = None):
|
||||
"""getChatroomList 抓取(云客微信群统计同款)。
|
||||
|
||||
* ``day=None``:全窗(2020 起)——chat_group 档案补列基线(真成员数/
|
||||
群备注/员工信息列;add/loss 存全窗快照);
|
||||
* ``day='YYYY-MM-DD'``:单日窗——落 chat_group_member_stats 当日行
|
||||
(页面任意日窗的新增/退出=按天求和)。
|
||||
"""
|
||||
import datetime as _dt
|
||||
|
||||
from app.db.base import async_engine
|
||||
from app.storage.repos import upsert_chat_group, upsert_chat_group_member_stats
|
||||
|
||||
dept = await _root_dept_id()
|
||||
async with async_engine.connect() as conn:
|
||||
alias_map = await _owner_alias_map(conn)
|
||||
|
||||
if day:
|
||||
start_str, end_str = f"{day} 00:00:00", f"{day} 23:59:59"
|
||||
day_key = int(day.replace("-", ""))
|
||||
else:
|
||||
start_str = "2020-01-01 00:00:00"
|
||||
end_str = f"{_dt.date.today()} 23:59:59"
|
||||
day_key = None
|
||||
|
||||
group_recs: list[dict] = []
|
||||
stats_recs: list[dict] = []
|
||||
declared = 0
|
||||
async with PageScraper(cookie_str) as scraper:
|
||||
page = await _open_fetch_page(scraper)
|
||||
rows, declared = await _chatroom_rows(page, dept, start_str, end_str)
|
||||
for row in rows:
|
||||
rid = row.get("chatRoomId")
|
||||
if not rid:
|
||||
continue
|
||||
owner_wxid = (
|
||||
alias_map.get(str(row.get("wechatId") or ""))
|
||||
or alias_map.get(str(row.get("wechatNickName") or ""))
|
||||
or str(row.get("wechatId") or "")
|
||||
)
|
||||
group_recs.append(_normalize_chatroom_stat_row(row, owner_wxid))
|
||||
if day_key is not None:
|
||||
stats_recs.append({
|
||||
"channel": "wechat",
|
||||
"owner_account_id": owner_wxid,
|
||||
"room_id": rid,
|
||||
"day": day_key,
|
||||
"added": row.get("addMembers") or 0,
|
||||
"lost": row.get("lossMembers") or 0,
|
||||
})
|
||||
|
||||
if group_recs:
|
||||
await _upsert_all(upsert_chat_group, group_recs)
|
||||
if stats_recs:
|
||||
await _upsert_all(upsert_chat_group_member_stats, stats_recs)
|
||||
|
||||
# 对账(2026-09-10,全窗模式):删掉本地有而云客名单里没有的 chat_group 行
|
||||
# ——friend_list/es 时代的残留行(owner 组合已不在 getChatroomList 返回,
|
||||
# 实测多 148 行致页脚与云客不一致)。名单取满(>=声明 total)才删,防短页
|
||||
# 误删;只动 wechat(WA 群另一来源);GroupMember 按 room 共享不动。
|
||||
pruned = 0
|
||||
if day_key is None and group_recs and declared and len(group_recs) >= declared:
|
||||
import sqlalchemy as sa
|
||||
|
||||
from app.db.base import async_session_maker
|
||||
from app.db.models import ChatGroup
|
||||
|
||||
keep = {(r["owner_account_id"], r["room_id"]) for r in group_recs}
|
||||
async with async_session_maker() as session:
|
||||
local = (await session.execute(
|
||||
sa.select(ChatGroup.owner_account_id, ChatGroup.room_id)
|
||||
.where(ChatGroup.channel == "wechat")
|
||||
)).all()
|
||||
stale = [(o, r) for (o, r) in local if (o, r) not in keep]
|
||||
for owner, room in stale:
|
||||
# 逐行删(差集常态 0~几行;不用 tuple IN——asyncmy 对其参数
|
||||
# 形态挑剔,实测 TypeError)
|
||||
await session.execute(
|
||||
sa.delete(ChatGroup).where(
|
||||
ChatGroup.channel == "wechat",
|
||||
ChatGroup.owner_account_id == owner,
|
||||
ChatGroup.room_id == room,
|
||||
)
|
||||
)
|
||||
pruned = len(stale)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
f"DONE: chatroom-stats day={day or '全窗'} groups={len(group_recs)} "
|
||||
f"stats_rows={len(stats_recs)} pruned={pruned}"
|
||||
)
|
||||
return len(group_recs), len(stats_recs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 员工朋友圈 Moments (browser fetch, per employee)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1986,7 +2276,7 @@ async def scrape_voice_clips(cookie_str: str, max_employees: int = 200, page_siz
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full_history: bool = False):
|
||||
async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full_history: bool = False, active_window_hours: int | None = None):
|
||||
"""Scrape WhatsApp chat records via browser fetch POST
|
||||
/qqweb/WhatsAppUser/chatRecords (JSON body {uid, friendUid, start, end}).
|
||||
|
||||
@@ -2001,7 +2291,14 @@ async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full
|
||||
from app.scraper.whatsapp import _is_wa_divider, _normalize_whatsapp_message
|
||||
from app.storage.repos import upsert_messages
|
||||
|
||||
conversations = await _discover_conversations("whatsapp", "wa_voice_call")
|
||||
conversations = await _discover_conversations(
|
||||
"whatsapp", "wa_voice_call",
|
||||
active_since_ms=(
|
||||
int((__import__("time").time() - active_window_hours * 3600) * 1000)
|
||||
if active_window_hours else None
|
||||
),
|
||||
exclude_scanned_empty=not full_history,
|
||||
)
|
||||
watermarks = await _conversation_watermarks("whatsapp")
|
||||
cust_activity = await _customer_activity("whatsapp")
|
||||
|
||||
@@ -2047,7 +2344,14 @@ async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full
|
||||
msgs = data.get("messages") or []
|
||||
for row in msgs:
|
||||
if isinstance(row, dict) and not _is_wa_divider(row):
|
||||
records.append(_normalize_whatsapp_message(row))
|
||||
rec = _normalize_whatsapp_message(row)
|
||||
# 增量模式过滤边界旧消息:云客 start 参数是 inclusive
|
||||
# (返回 ts>=start 含边界),不过滤则每对话白拉数条已
|
||||
# 入库旧消息(2026-09-10 实测一夜 fetched 53,682 仅
|
||||
# 321 条真新,99% 白写)。
|
||||
if watermark and (rec.get("timestamp") or 0) <= watermark:
|
||||
continue
|
||||
records.append(rec)
|
||||
if not data.get("hasLast"):
|
||||
break
|
||||
# 增量触底停:翻到已同步区
|
||||
@@ -2078,6 +2382,8 @@ async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full
|
||||
continue
|
||||
|
||||
total_fetched += len(records)
|
||||
if not records:
|
||||
await _mark_scan_empty("whatsapp_conv_empty", f"{uid}:{friend_uid}")
|
||||
if records:
|
||||
# upsert_messages 收整列表(逐条版 _upsert_all 会把 dict 迭代成
|
||||
# key 字符串 → TypeError, 2026-08-19 WA 阶段首条即崩的根因)
|
||||
@@ -2124,7 +2430,7 @@ def _normalize_wa_group_message(record: dict) -> dict:
|
||||
return rec
|
||||
|
||||
|
||||
async def scrape_wa_group_messages(cookie_str: str, max_groups: int = 500, full_history: bool = False):
|
||||
async def scrape_wa_group_messages(cookie_str: str, max_groups: int = 500, full_history: bool = False, active_window_hours: int | None = None):
|
||||
"""WA 群消息:浏览器 fetch chatRecords,friendUid 直接传 @g.us 群 id
|
||||
(真机确认 2026-08-20,与微信群的 msg/chats+roomId 同套路)。
|
||||
|
||||
@@ -2138,11 +2444,16 @@ async def scrape_wa_group_messages(cookie_str: str, max_groups: int = 500, full_
|
||||
from app.storage.repos import upsert_messages
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
active_since_ms = (
|
||||
int((__import__("time").time() - active_window_hours * 3600) * 1000)
|
||||
if active_window_hours else None
|
||||
)
|
||||
async with async_engine.connect() as conn:
|
||||
result = await conn.execute(sa_text(
|
||||
"SELECT owner_account_id, room_id FROM chat_group "
|
||||
"WHERE channel='whatsapp' AND owner_account_id IS NOT NULL"
|
||||
))
|
||||
"WHERE channel='whatsapp' AND owner_account_id IS NOT NULL "
|
||||
"AND (:t IS NULL OR last_chat_time IS NULL OR last_chat_time >= :t)"
|
||||
), {"t": active_since_ms})
|
||||
groups = [(row[0], row[1]) for row in result.fetchall()]
|
||||
|
||||
room_wms = await _room_watermarks()
|
||||
@@ -2186,7 +2497,11 @@ async def scrape_wa_group_messages(cookie_str: str, max_groups: int = 500, full_
|
||||
for row in rows:
|
||||
if not isinstance(row, dict) or _is_wa_divider(row):
|
||||
continue
|
||||
records.append(_normalize_wa_group_message(row))
|
||||
rec = _normalize_wa_group_message(row)
|
||||
# 增量模式过滤边界旧消息(同 1:1,2026-09-10)
|
||||
if watermark and (rec.get("timestamp") or 0) <= watermark:
|
||||
continue
|
||||
records.append(rec)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f" [{i+1}] {str(owner_id)[:18]}:{str(room_id)[:18]} failed: {e}")
|
||||
@@ -2301,6 +2616,7 @@ async def main():
|
||||
"wa-accounts", # WA员工账号 (waBusiness/userlist interception)
|
||||
"wa-friends", # WA客户 (waBusiness/friendlist interception)
|
||||
"wa-groups", # WA群 (browser fetch businessFriendList type=1)
|
||||
"chatroom-stats", # 微信群统计 (getChatroomList 带窗,云客同款)
|
||||
"voice-calls", # 语音通话记录 (voice-call-list + wavoicecalllist)
|
||||
"voice-clips", # 长语音 (browser fetch call/list per employee)
|
||||
"wa-messages", # WA消息 (browser fetch chatRecords per conversation)
|
||||
@@ -2315,6 +2631,9 @@ async def main():
|
||||
parser.add_argument("--max-pages", type=int, default=200, help="列表翻页上限 (accounts/wa-accounts/wa-friends/voice-calls/moments)")
|
||||
parser.add_argument("--batch-size", type=int, default=50, help="媒体下载批次大小 (media)")
|
||||
parser.add_argument("--full-history", action="store_true", help="消息类实体强制全量翻历史(默认增量水位)")
|
||||
parser.add_argument("--active-window-hours", type=int, default=None,
|
||||
help="只扫近 N 小时活跃的对话(4h 增量模式传 24;未扫过的对话不受限;默认不过滤=全量发现)")
|
||||
parser.add_argument("--day", type=str, default=None, help="chatroom-stats:单日窗 YYYY-MM-DD(落按天进出表);缺省=全窗档案基线")
|
||||
args = parser.parse_args()
|
||||
|
||||
cookie = _load_cookie()
|
||||
@@ -2337,9 +2656,9 @@ async def _dispatch_entity(args, cookie: str) -> None:
|
||||
elif args.entity == "groups":
|
||||
await scrape_groups(cookie, args.max_employees)
|
||||
elif args.entity == "messages":
|
||||
await scrape_messages(cookie, args.max_conversations, args.full_history)
|
||||
await scrape_messages(cookie, args.max_conversations, args.full_history, args.active_window_hours)
|
||||
elif args.entity == "group-messages":
|
||||
await scrape_group_messages(cookie, args.max_conversations, args.full_history)
|
||||
await scrape_group_messages(cookie, args.max_conversations, args.full_history, args.active_window_hours)
|
||||
elif args.entity == "accounts":
|
||||
await scrape_accounts(cookie, args.max_pages)
|
||||
elif args.entity == "wa-accounts":
|
||||
@@ -2348,14 +2667,16 @@ async def _dispatch_entity(args, cookie: str) -> None:
|
||||
await scrape_wa_friends(cookie, args.max_pages)
|
||||
elif args.entity == "wa-groups":
|
||||
await scrape_wa_groups(cookie, args.max_pages)
|
||||
elif args.entity == "chatroom-stats":
|
||||
await scrape_chatroom_stats(cookie, args.day)
|
||||
elif args.entity == "voice-calls":
|
||||
await scrape_voice_calls(cookie, args.max_pages)
|
||||
elif args.entity == "voice-clips":
|
||||
await scrape_voice_clips(cookie, args.max_employees)
|
||||
elif args.entity == "wa-messages":
|
||||
await scrape_wa_messages(cookie, args.max_conversations, args.full_history)
|
||||
await scrape_wa_messages(cookie, args.max_conversations, args.full_history, args.active_window_hours)
|
||||
elif args.entity == "wa-group-messages":
|
||||
await scrape_wa_group_messages(cookie, args.max_conversations, args.full_history)
|
||||
await scrape_wa_group_messages(cookie, args.max_conversations, args.full_history, args.active_window_hours)
|
||||
elif args.entity == "moments":
|
||||
await scrape_moments(cookie, args.max_pages)
|
||||
elif args.entity == "media":
|
||||
|
||||
@@ -64,17 +64,19 @@ async def _seed(session: AsyncSession) -> None:
|
||||
|
||||
|
||||
async def test_default_list_returns_all_ordered_desc(make_client):
|
||||
"""No filters -> 3 groups, ordered by ``last_chat_time desc`` (nulls last)."""
|
||||
"""No filters -> 3 groups;微信排序=owner 二进制字符串升序分组+room_id
|
||||
升序(云客群统计同款,2026-09-10 终判:区分大小写,大写排小写前)。
|
||||
owner 序:wa_owner_1 < wx_owner_1 < wx_owner_2 → [wa_room_x, room_a, room_b]。
|
||||
"""
|
||||
client = await make_client(_seed)
|
||||
r = await client.get("/api/groups")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["total"] == 3
|
||||
# 2000, 1000, null -> [room_a, room_b, wa_room_x]
|
||||
assert [it["room_id"] for it in body["items"]] == [
|
||||
"wa_room_x@s.whatsapp.net",
|
||||
"room_a@chatroom",
|
||||
"room_b@chatroom",
|
||||
"wa_room_x@s.whatsapp.net",
|
||||
]
|
||||
for key in (
|
||||
"room_id",
|
||||
@@ -344,3 +346,79 @@ async def test_q_room_name_substring_filter(make_client):
|
||||
# 空串 -> 不过滤
|
||||
r3 = await client.get("/api/groups", params={"q": ""})
|
||||
assert r3.json()["total"] == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 微信群统计扩展(2026-09-09):统计时段窗聚合 + 员工列 + 群备注/最新消息筛选
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _seed_stat(session: AsyncSession) -> None:
|
||||
from app.db.models import ChatGroupMemberStats, MsgDailyStats, Staff, WechatAccount
|
||||
|
||||
session.add_all([
|
||||
WechatAccount(wx_id="wx_owner_1", nick_name="玉", alias="Nancyjade",
|
||||
user_id="u_wx_1", raw_json={"headImageUrl": "http://a/h.png"}),
|
||||
Staff(user_id="u_wx_1", name="聂玉婷", phone="18500000000"),
|
||||
ChatGroup(room_id="stat_room@chatroom", channel="wechat", room_name="统计群",
|
||||
owner_account_id="wx_owner_1", owner_user_id="u_wx_1",
|
||||
member_count=46, group_remark="VIP群", last_chat_time=5000),
|
||||
# 窗内两天日行(0907+0908):沟通 3+4=7 条
|
||||
MsgDailyStats(channel="wechat", owner_account_id="wx_owner_1", peer_kind="room",
|
||||
peer_id="stat_room@chatroom", day=20260907, send_cnt=1, recv_cnt=2),
|
||||
MsgDailyStats(channel="wechat", owner_account_id="wx_owner_1", peer_kind="room",
|
||||
peer_id="stat_room@chatroom", day=20260908, send_cnt=2, recv_cnt=2),
|
||||
# 窗外日行(0901)不应计入
|
||||
MsgDailyStats(channel="wechat", owner_account_id="wx_owner_1", peer_kind="room",
|
||||
peer_id="stat_room@chatroom", day=20260901, send_cnt=99, recv_cnt=99),
|
||||
# 窗内进出:0907 +2/-1
|
||||
ChatGroupMemberStats(channel="wechat", owner_account_id="wx_owner_1",
|
||||
room_id="stat_room@chatroom", day=20260907, added=2, lost=1),
|
||||
])
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_group_window_aggregates_and_staff_columns(make_client):
|
||||
"""msg_start/msg_end 窗内三聚合 + 员工微信/员工信息关联列。"""
|
||||
client = await make_client(_seed_stat)
|
||||
# 2026-09-07 ~ 2026-09-09(上海日 20260907..20260909,UTC ms)
|
||||
r = await client.get("/api/groups", params={
|
||||
"channel": "wechat", "room_id": "stat_room@chatroom",
|
||||
"msg_start": 1788758400000, "msg_end": 1789017600000,
|
||||
})
|
||||
assert r.status_code == 200
|
||||
it = r.json()["items"][0]
|
||||
assert it["window_msg_count"] == 7 # 窗内日行求和,0901 排除
|
||||
assert it["window_added"] == 2
|
||||
assert it["window_lost"] == 1
|
||||
assert it["group_remark"] == "VIP群"
|
||||
assert it["staff_nickname"] == "玉"
|
||||
assert it["staff_wx_alias"] == "Nancyjade"
|
||||
assert it["staff_avatar"] == "http://a/h.png"
|
||||
assert it["staff_name"] == "聂玉婷"
|
||||
assert it["staff_phone"] == "18500000000"
|
||||
|
||||
# 无窗请求 → 聚合三列为 None(不误显 0)
|
||||
r2 = await client.get("/api/groups", params={
|
||||
"channel": "wechat", "room_id": "stat_room@chatroom"})
|
||||
it2 = r2.json()["items"][0]
|
||||
assert it2["window_msg_count"] is None
|
||||
assert it2["window_added"] is None
|
||||
|
||||
|
||||
async def test_group_remark_and_last_window_filters(make_client):
|
||||
"""群备注子串 + 最新消息时间段过滤。"""
|
||||
client = await make_client(_seed_stat)
|
||||
r = await client.get("/api/groups", params={
|
||||
"channel": "wechat", "group_remark": "VIP"})
|
||||
assert [i["room_id"] for i in r.json()["items"]] == ["stat_room@chatroom"]
|
||||
r2 = await client.get("/api/groups", params={
|
||||
"channel": "wechat", "group_remark": "不存在"})
|
||||
assert r2.json()["total"] == 0
|
||||
|
||||
# last_chat_time=5000 → last_start=4000 命中,last_start=6000 排除
|
||||
r3 = await client.get("/api/groups", params={
|
||||
"channel": "wechat", "last_start": 4000, "last_end": 9999})
|
||||
assert "stat_room@chatroom" in [i["room_id"] for i in r3.json()["items"]]
|
||||
r4 = await client.get("/api/groups", params={
|
||||
"channel": "wechat", "last_start": 6000, "last_end": 9999})
|
||||
assert r4.json()["total"] == 0
|
||||
|
||||
@@ -123,6 +123,7 @@ async def test_metric_daily_friend_only_caliber(make_session):
|
||||
assert row.msg_sent == 3 and row.msg_recv == 2 # room 的 10 不进
|
||||
assert row.active_rooms == 1
|
||||
assert row.cust_threads == 1 and row.threads_answered == 0 # 未回复串
|
||||
assert row.first_reply_p50_ms is None and row.first_reply_p90_ms is None # 空样本→None(≠0)
|
||||
|
||||
|
||||
async def test_metric_daily_ghost_filtered(make_session):
|
||||
@@ -173,6 +174,35 @@ async def test_metric_daily_same_source_as_fact(make_session):
|
||||
sa.select(EmployeeMetricDaily))).scalars().all()) == 2
|
||||
|
||||
|
||||
async def test_metric_daily_same_source_as_fact_answered(make_session):
|
||||
"""同源判别正向路径(v2 §1.1 #5):被回串——threads_answered ≡ fact 行数,
|
||||
单样本 p50=p90=fr_ms(锁分位非 None 映射)。"""
|
||||
from app.storage.first_reply_facts import recompute_pairs
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
# 客户 10:00 提问,员工 10:25 回复 → fr=25min,串末条归属 09-01
|
||||
fr_ms = 25 * 60 * 1000
|
||||
s.add(Message(msg_id="an0", channel="wechat", owner_account_id="wx1",
|
||||
talker_id="pa", timestamp=_ts(2026, 9, 1, 10, 0),
|
||||
is_send=False, type="text", text="q"))
|
||||
s.add(Message(msg_id="an1", channel="wechat", owner_account_id="wx1",
|
||||
talker_id="pa", timestamp=_ts(2026, 9, 1, 10, 25),
|
||||
is_send=True, type="text", text="a"))
|
||||
await _seed_msg_rows(s, day=20260901, peer="pa", send=1, recv=1)
|
||||
await s.commit()
|
||||
await recompute_pairs(s, [("wechat", "wx1", "pa")])
|
||||
await s.commit()
|
||||
await recompute_metric_days(s, [20260901])
|
||||
await s.commit()
|
||||
n_facts = (await s.execute(
|
||||
sa.select(sa.func.count()).select_from(FirstReplyFact))).scalar_one()
|
||||
row = (await s.execute(sa.select(EmployeeMetricDaily))).scalar_one()
|
||||
assert row.cust_threads == 1
|
||||
assert row.threads_answered == 1 == n_facts
|
||||
assert row.fr_sum_ms == fr_ms
|
||||
assert row.first_reply_p50_ms == fr_ms == row.first_reply_p90_ms
|
||||
|
||||
|
||||
async def test_metric_daily_lifecycle_counts(make_session):
|
||||
"""生命周期列:cooling/dormant/reactivated 判定进宽表。"""
|
||||
s = make_session
|
||||
@@ -204,3 +234,36 @@ async def test_metric_daily_lifecycle_counts(make_session):
|
||||
await s.execute(sa.select(EmployeeMetricDaily))).scalars().all()}
|
||||
assert got[20261010].dormant_customer_count == 1 # 39 天静默+历史 8 条
|
||||
assert got[20261011].reactivated_customer_count == 1 # 当日回流且 gap>30
|
||||
|
||||
|
||||
async def test_metric_daily_lifecycle_hist_point_in_time(make_session):
|
||||
"""hist 时点语义判别:回填历史日 M,沉睡门槛只计 ≤M 的消息。
|
||||
|
||||
客户 pd:X=09-01 共 2 条 + Y=10-02 共 3 条(全历史 5 条):
|
||||
- M=10-01(X+30,<Y):gap≥30 达沉睡判定,但 hist_asof=2 < 5 → None
|
||||
(全历史口径 hist=5 会把 M 误判 dormant——M 之后的消息虚增);
|
||||
- D=11-01(Y+30):hist_asof=5 ≥ 5 → dormant。"""
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
s.add(Customer(friend_id="pd", channel="wechat", owner_account_id="wx1",
|
||||
delete_flag=0))
|
||||
for i, (day, h, is_send) in enumerate([
|
||||
(20260901, 10, False), (20260901, 11, True), # X 2 条
|
||||
(20261002, 10, False), (20261002, 11, True), (20261002, 12, False), # Y 3 条
|
||||
]):
|
||||
s.add(Message(msg_id=f"pd{i}", channel="wechat", owner_account_id="wx1",
|
||||
talker_id="pd",
|
||||
timestamp=_ts(day // 10000, day // 100 % 100, day % 100, h, 0),
|
||||
is_send=is_send, type="text", text="x"))
|
||||
await _seed_msg_rows(s, day=20260901, peer="pd", send=1, recv=1)
|
||||
await _seed_msg_rows(s, day=20261002, peer="pd", send=1, recv=2)
|
||||
# M 是无消息日:state=None 时纯生命周期源不出行,补一条 room 行撑出
|
||||
# 宽表行,让 dormant==0 有断言落点
|
||||
await _seed_msg_rows(s, day=20261001, kind="room", peer="r1", send=1, recv=0)
|
||||
await s.commit()
|
||||
await recompute_metric_days(s, [20261001, 20261101])
|
||||
await s.commit()
|
||||
got = {r.day: r for r in (
|
||||
await s.execute(sa.select(EmployeeMetricDaily))).scalars().all()}
|
||||
assert got[20261001].dormant_customer_count == 0 # hist_asof=2<5 → None
|
||||
assert got[20261101].dormant_customer_count == 1 # hist_asof=5 ≥ 5
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
"""管理域确定性异常规则判别测试(批次B Task B3,v1 §5.4 异常卡片)。"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.management.anomalies import detect_anomalies
|
||||
from app.management.employee_metrics import PeriodMetrics
|
||||
from app.management.metric_definitions import DEFAULT_SCORE_CONFIG
|
||||
|
||||
|
||||
def _pm(**overrides):
|
||||
"""全零/None 默认的 PeriodMetrics 构造器(22 字段,覆盖式改)。
|
||||
|
||||
与 test_management_scoring._pm 同式(测试间不共享 helper,复制保持独立)。
|
||||
"""
|
||||
base = {
|
||||
"user_id": "u1", "days_with_data": 0, "msg_sent": 0, "msg_recv": 0,
|
||||
"cust_threads": 0, "threads_answered": 0, "fr_sum_ms": 0,
|
||||
"fr_p50_ms": None, "fr_p90_ms": None,
|
||||
"reply_2h_count": 0, "reply_24h_count": 0,
|
||||
"active_friends": 0, "active_rooms_avg": 0.0,
|
||||
"new_customer": 0, "cooling_customer": 0, "dormant_customer": 0,
|
||||
"end_active_friends": 0, "reactivated_customer": 0,
|
||||
"call_count": 0, "call_seconds": 0, "answered_call": 0,
|
||||
"call_followup": 0,
|
||||
}
|
||||
base.update(overrides)
|
||||
return PeriodMetrics(**base)
|
||||
|
||||
|
||||
def _detect(cur, base, *, n_days=7, user_id="u1", config=None):
|
||||
return detect_anomalies(
|
||||
user_id, cur, base, n_days=n_days,
|
||||
config=config if config is not None else DEFAULT_SCORE_CONFIG)
|
||||
|
||||
|
||||
# ---- 1. reply_p90_worsened ----
|
||||
|
||||
def test_p90_worsened_warning_with_pct():
|
||||
"""×1.6 → warning;标题百分比=(160-100)/100=60%;affected=未回串数。"""
|
||||
cur = _pm(cust_threads=20, threads_answered=12, fr_p90_ms=9_600_000) # 160min
|
||||
base = _pm(cust_threads=40, threads_answered=40, fr_p90_ms=6_000_000) # 100min
|
||||
cards = _detect(cur, base)
|
||||
assert [c["rule_code"] for c in cards] == ["reply_p90_worsened"]
|
||||
c = cards[0]
|
||||
assert c["severity"] == "warning"
|
||||
assert c["title"] == "回复 P90 较个人基线上升 60%"
|
||||
assert c["current_value"] == 9_600_000
|
||||
assert c["baseline_value"] == 6_000_000
|
||||
assert c["unit"] == "ms"
|
||||
assert c["sample_size"] == 20
|
||||
assert c["affected_count"] == 8 # 20 串 - 12 已回 = 期内未回串数
|
||||
assert c["drilldown"] == {"type": "metric",
|
||||
"query": {"metric_code": "first_reply_p90",
|
||||
"user_id": "u1"}}
|
||||
|
||||
|
||||
def test_p90_worsened_critical_at_3x():
|
||||
cur = _pm(cust_threads=20, threads_answered=20, fr_p90_ms=18_000_000)
|
||||
base = _pm(cust_threads=40, threads_answered=40, fr_p90_ms=6_000_000)
|
||||
cards = _detect(cur, base)
|
||||
assert len(cards) == 1
|
||||
assert cards[0]["severity"] == "critical"
|
||||
assert "上升 200%" in cards[0]["title"]
|
||||
|
||||
|
||||
def test_p90_boundaries_strict_compare():
|
||||
"""×1.5 恰好 → 严格大于不成立不触发;×2.5 恰好 → warning 非 critical;
|
||||
threads 恰好 = soft(10) → ≥soft 触发。"""
|
||||
base = _pm(cust_threads=40, fr_p90_ms=6_000_000)
|
||||
assert _detect(_pm(cust_threads=20, fr_p90_ms=9_000_000), base) == []
|
||||
cards = _detect(_pm(cust_threads=20, fr_p90_ms=15_000_000), base)
|
||||
assert [c["severity"] for c in cards] == ["warning"]
|
||||
cards = _detect(_pm(cust_threads=10, fr_p90_ms=9_600_000), base)
|
||||
assert [c["rule_code"] for c in cards] == ["reply_p90_worsened"]
|
||||
|
||||
|
||||
def test_p90_below_soft_threads_not_fired():
|
||||
cur = _pm(cust_threads=5, fr_p90_ms=9_600_000) # <soft 10
|
||||
base = _pm(cust_threads=40, threads_answered=40, fr_p90_ms=6_000_000)
|
||||
assert _detect(cur, base) == []
|
||||
|
||||
|
||||
# ---- 2. 基线缺失 ----
|
||||
|
||||
def test_baseline_none_all_rules_silent():
|
||||
"""新员工无个人基线:即便 p90 恶化/活动骤降/过载形态俱在也不出卡。"""
|
||||
cur = _pm(cust_threads=20, threads_answered=5, fr_p90_ms=30_000_000,
|
||||
active_friends=85, msg_sent=10, msg_recv=10)
|
||||
assert _detect(cur, None) == []
|
||||
|
||||
|
||||
# ---- 3. reply_rate_dropped ----
|
||||
|
||||
def test_reply_rate_dropped_warning():
|
||||
"""0.8→0.5(0.5 < 0.7×0.8=0.56)→ warning;百分比 37.5%→38%。"""
|
||||
cur = _pm(cust_threads=20, reply_2h_count=10, threads_answered=15) # 0.5
|
||||
base = _pm(cust_threads=40, reply_2h_count=32, threads_answered=40) # 0.8
|
||||
cards = _detect(cur, base)
|
||||
assert [c["rule_code"] for c in cards] == ["reply_rate_dropped"]
|
||||
c = cards[0]
|
||||
assert c["severity"] == "warning"
|
||||
assert c["title"] == "2h 回复率较个人基线下降 38%"
|
||||
assert c["current_value"] == pytest.approx(0.5)
|
||||
assert c["baseline_value"] == pytest.approx(0.8)
|
||||
assert c["unit"] == "0-1"
|
||||
assert c["sample_size"] == 20
|
||||
assert c["affected_count"] == 5
|
||||
assert c["drilldown"] == {"type": "metric",
|
||||
"query": {"metric_code": "unreplied",
|
||||
"user_id": "u1"}}
|
||||
|
||||
|
||||
def test_reply_rate_small_drop_not_fired():
|
||||
"""0.8→0.6(0.6 ≥ 0.56)→ 不触发。"""
|
||||
cur = _pm(cust_threads=20, reply_2h_count=12) # 0.6
|
||||
base = _pm(cust_threads=40, reply_2h_count=32) # 0.8
|
||||
assert _detect(cur, base) == []
|
||||
|
||||
|
||||
def test_rate_baseline_no_threads_not_fired():
|
||||
"""基线无串样本 → 基线率 None → 不判(基线值 None 不触发)。"""
|
||||
cur = _pm(cust_threads=20, reply_2h_count=0)
|
||||
base = _pm(cust_threads=0) # derive → reply_2h_rate None
|
||||
assert _detect(cur, base) == []
|
||||
|
||||
|
||||
# ---- 4. activity_drop ----
|
||||
|
||||
def test_activity_drop_triggered():
|
||||
"""当前 7 天日均 10 < 基线 28 天日均 30×0.5=15 → warning。"""
|
||||
cur = _pm(msg_sent=40, msg_recv=30, active_friends=25) # 70/7=10 条/天
|
||||
base = _pm(msg_sent=600, msg_recv=240, days_with_data=20) # 840/28=30 条/天
|
||||
cards = _detect(cur, base)
|
||||
assert [c["rule_code"] for c in cards] == ["activity_drop"]
|
||||
c = cards[0]
|
||||
assert c["severity"] == "warning"
|
||||
assert c["title"] == "日均消息量较个人基线下降 67%" # 66.7→67
|
||||
assert c["current_value"] == pytest.approx(10.0)
|
||||
assert c["baseline_value"] == pytest.approx(30.0)
|
||||
assert c["unit"] == "条"
|
||||
assert c["sample_size"] == 25
|
||||
assert c["affected_count"] == 25
|
||||
assert c["drilldown"] == {"type": "employee", "query": {"user_id": "u1"}}
|
||||
|
||||
|
||||
def test_activity_drop_sparse_baseline_not_fired():
|
||||
"""基线 days_with_data=3 < min_days(5) → 基线不可比,不触发。"""
|
||||
base = _pm(msg_sent=600, msg_recv=240, days_with_data=3)
|
||||
cur = _pm(msg_sent=40, msg_recv=30)
|
||||
assert _detect(cur, base) == []
|
||||
|
||||
|
||||
# ---- 5. workload_overload ----
|
||||
|
||||
def test_workload_overload_triggered():
|
||||
cards = _detect(_pm(active_friends=85), _pm())
|
||||
assert [c["rule_code"] for c in cards] == ["workload_overload"]
|
||||
c = cards[0]
|
||||
assert c["severity"] == "warning"
|
||||
assert c["title"] == "活跃客户 85 人超过负载上限 80 人"
|
||||
assert c["current_value"] == 85
|
||||
assert c["baseline_value"] is None # 规则与个人基线无关,阈值在 band 配置
|
||||
assert c["unit"] == "人"
|
||||
assert c["sample_size"] == 85
|
||||
assert c["affected_count"] == 85
|
||||
assert c["drilldown"] == {"type": "employee", "query": {"user_id": "u1"}}
|
||||
|
||||
|
||||
def test_workload_overload_at_limit_not_fired():
|
||||
assert _detect(_pm(active_friends=80), _pm()) == [] # 严格大于上限
|
||||
|
||||
|
||||
# ---- 6. 配置驱动与组合 ----
|
||||
|
||||
def test_thresholds_read_from_config():
|
||||
"""soft_threads/band high 改配置即改判——阈值不硬编码。"""
|
||||
cfg = json.loads(json.dumps(DEFAULT_SCORE_CONFIG))
|
||||
cfg["sample_guards"]["soft_threads"] = 25
|
||||
cfg["dimensions"]["workload"]["metrics"]["active_friends_band"]["high"] = 90
|
||||
cur = _pm(cust_threads=20, fr_p90_ms=9_600_000, active_friends=85)
|
||||
base = _pm(cust_threads=40, fr_p90_ms=6_000_000)
|
||||
# threads 20 < soft25 → p90/rate 不评;85 ≤ 90 → 不 overload
|
||||
assert _detect(cur, base, config=cfg) == []
|
||||
|
||||
|
||||
def test_multiple_rules_stack_in_order():
|
||||
"""p90 ×5 critical + activity 骤降 + overload 90>80 同时触发,按规则表序输出;
|
||||
两侧回复率同为 0(0<0 不成立)→ rate 规则不触发。"""
|
||||
cur = _pm(cust_threads=30, threads_answered=10, fr_p90_ms=30_000_000,
|
||||
active_friends=90, msg_sent=10, msg_recv=10)
|
||||
base = _pm(cust_threads=60, threads_answered=60, fr_p90_ms=6_000_000,
|
||||
msg_sent=600, msg_recv=240, days_with_data=20)
|
||||
cards = _detect(cur, base)
|
||||
assert [c["rule_code"] for c in cards] == [
|
||||
"reply_p90_worsened", "activity_drop", "workload_overload"]
|
||||
assert cards[0]["severity"] == "critical"
|
||||
|
||||
|
||||
def test_card_structure_all_fields():
|
||||
"""单卡 12 字段齐全且类型正确(v1 §5.4)。"""
|
||||
cur = _pm(cust_threads=20, reply_2h_count=20, threads_answered=12,
|
||||
fr_p90_ms=9_600_000, active_friends=30,
|
||||
msg_sent=80, msg_recv=30) # 日均 110/7≈15.7 ≥ 15 → activity 不触发
|
||||
base = _pm(cust_threads=40, reply_2h_count=40, threads_answered=40,
|
||||
fr_p90_ms=6_000_000, active_friends=30,
|
||||
msg_sent=600, msg_recv=240, days_with_data=20)
|
||||
cards = _detect(cur, base, user_id="u9")
|
||||
assert len(cards) == 1
|
||||
c = cards[0]
|
||||
assert set(c) == {
|
||||
"rule_code", "severity", "employee_user_id", "title",
|
||||
"current_value", "baseline_value", "unit", "sample_size",
|
||||
"affected_count", "reason", "drilldown", "suggested_action",
|
||||
}
|
||||
assert isinstance(c["rule_code"], str)
|
||||
assert c["severity"] in ("warning", "critical")
|
||||
assert c["employee_user_id"] == "u9"
|
||||
assert isinstance(c["title"], str) and c["title"]
|
||||
assert isinstance(c["current_value"], (int, float))
|
||||
assert c["baseline_value"] is None or isinstance(
|
||||
c["baseline_value"], (int, float))
|
||||
assert isinstance(c["unit"], str)
|
||||
assert isinstance(c["sample_size"], int)
|
||||
assert isinstance(c["affected_count"], int)
|
||||
assert isinstance(c["reason"], str) and c["reason"]
|
||||
assert set(c["drilldown"]) == {"type", "query"}
|
||||
assert isinstance(c["drilldown"]["query"], dict)
|
||||
assert isinstance(c["suggested_action"], str) and c["suggested_action"]
|
||||
@@ -0,0 +1,744 @@
|
||||
"""管理 API 六只读端点判别测试(批次B Task B4)。
|
||||
|
||||
覆盖:
|
||||
- 参数校验:422(缺 start / 跨度>366 / 日历非法 / drilldown 白名单外 /
|
||||
page_size>100)、404(employees/{ghost}、drilldown 员工不存在);
|
||||
- dashboard 各段齐全与关键数值(kpis/communication_trend/service_trend/
|
||||
lifecycle/dimension_summary/anomalies/action_summary/data_as_of/
|
||||
metric_version);批次D 补:prev_kpis 环比块+msg_delta(含前窗零数据
|
||||
→None)与 anomalies employee_name enrich(详情端点不带);
|
||||
- employees:默认分页 20/total/critical 优先默认排序/channel=wechat 时
|
||||
whatsapp-only 员工消失/keyword/status/min_days 不足→insufficient_sample;
|
||||
- detail:账号清单+metrics 全字段+score 完整明细+anomalies+prev/baseline;
|
||||
- sections:五段各有 trend 数组与四对比;
|
||||
- drilldown:p90 分页与 fr_ms desc/unreplied 按 owner 过滤/cooling-dormant
|
||||
生命周期判定(已删客户剔除)/call_followup 列表+期聚合摘要;
|
||||
- metric-definitions:trial 标记/口径说明/config_source 回退。
|
||||
"""
|
||||
import copy
|
||||
import datetime as dt
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.db.models import (
|
||||
Customer,
|
||||
EmployeeCallDaily,
|
||||
EmployeeMetricDaily,
|
||||
EmployeeScoreConfig,
|
||||
FirstReplyFact,
|
||||
Message,
|
||||
MsgDailyStats,
|
||||
PhoneCall,
|
||||
Staff,
|
||||
WechatAccount,
|
||||
WhatsappAccount,
|
||||
)
|
||||
from app.management.metric_definitions import DEFAULT_SCORE_CONFIG
|
||||
|
||||
_SH = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
START, END = 20260901, 20260905 # 当前窗 5 天(min_days=5 恰好够)
|
||||
|
||||
|
||||
def _ts(*args) -> int:
|
||||
return int(dt.datetime(*args, tzinfo=_SH).timestamp() * 1000)
|
||||
|
||||
|
||||
def _mday(day, *, channel="wechat", user_id="u1", metric_version="v1", **kw):
|
||||
cols = {
|
||||
"msg_sent": 0, "msg_recv": 0, "active_friends": 0, "active_rooms": 0,
|
||||
"cust_threads": 0, "threads_answered": 0, "fr_sum_ms": 0,
|
||||
"new_customer_count": 0, "cooling_customer_count": 0,
|
||||
"dormant_customer_count": 0, "reactivated_customer_count": 0,
|
||||
"data_complete": True,
|
||||
}
|
||||
cols.update(kw)
|
||||
return EmployeeMetricDaily(
|
||||
day=day, channel=channel, user_id=user_id,
|
||||
metric_version=metric_version, **cols
|
||||
)
|
||||
|
||||
|
||||
def _fact(fr_ms, *, channel="wechat", owner="wx1", peer="p1", day=20260901, i=0):
|
||||
end = _ts(day // 10000, day // 100 % 100, day % 100, 10, 0) + i * 60_000
|
||||
return FirstReplyFact(
|
||||
channel=channel, owner_account_id=owner, peer_id=peer,
|
||||
thread_end_ts=end, reply_ts=end + fr_ms,
|
||||
reply_msg_id=f"{owner}-{peer}-m{i}", day=day, fr_ms=fr_ms,
|
||||
)
|
||||
|
||||
|
||||
async def _seed_common(s):
|
||||
"""三员工全量种子:u1(微信,2 warning)/u2(双渠道,1 critical+1 warning)/
|
||||
u3(仅 WhatsApp,无异常)——支撑 dashboard/employees/detail/sections/
|
||||
drilldown 全部判据。"""
|
||||
for uid, name, onjob in (("u1", "张三", True), ("u2", "李四", True),
|
||||
("u3", "王五", False)):
|
||||
s.add(Staff(user_id=uid, name=name, is_on_job=onjob))
|
||||
s.add(WechatAccount(wx_id="wx1", user_id="u1", nick_name="张三的微信",
|
||||
user_name="张三"))
|
||||
s.add(WechatAccount(wx_id="wx2", user_id="u2"))
|
||||
s.add(WhatsappAccount(uid="wa2", user_id="u2"))
|
||||
s.add(WhatsappAccount(uid="wa3", user_id="u3"))
|
||||
|
||||
# ---- 当前窗宽表(0901-0905)----
|
||||
for d in range(1, 6):
|
||||
s.add(_mday(20260900 + d, user_id="u1", msg_sent=1, cust_threads=2,
|
||||
threads_answered=2, fr_sum_ms=240_000, active_friends=3,
|
||||
active_rooms=1, cooling_customer_count=2,
|
||||
dormant_customer_count=1,
|
||||
first_reply_p50_ms=110_000, first_reply_p90_ms=220_000))
|
||||
s.add(_mday(20260900 + d, user_id="u2", msg_sent=10, msg_recv=10,
|
||||
cust_threads=2, threads_answered=2, fr_sum_ms=1_200_000,
|
||||
active_friends=4, cooling_customer_count=1,
|
||||
reactivated_customer_count=1))
|
||||
s.add(_mday(20260900 + d, channel="whatsapp", user_id="u3",
|
||||
msg_sent=5, msg_recv=5, cust_threads=1, active_friends=1))
|
||||
# ---- 基线窗宽表(0827-0831,u1 高日均触发 activity_drop)----
|
||||
for d in range(27, 32):
|
||||
s.add(_mday(20260800 + d, user_id="u1", msg_sent=14, msg_recv=14))
|
||||
s.add(_mday(20260800 + d, user_id="u2", msg_sent=5, msg_recv=5))
|
||||
|
||||
# ---- fact:当前窗(u1 120s warning / u2 600s critical)+ 基线 60s ----
|
||||
s.add(_fact(120_000, peer="pA", day=20260901, i=0))
|
||||
s.add(_fact(120_000, peer="pB", day=20260902, i=1))
|
||||
s.add(_fact(600_000, owner="wx2", peer="pC", day=20260901, i=2))
|
||||
s.add(_fact(600_000, owner="wx2", peer="pD", day=20260902, i=3))
|
||||
for owner, peer, day, i in (("wx1", "pA", 20260827, 10),
|
||||
("wx1", "pB", 20260828, 11),
|
||||
("wx2", "pC", 20260827, 12),
|
||||
("wx2", "pD", 20260828, 13)):
|
||||
s.add(_fact(60_000, owner=owner, peer=peer, day=day, i=i))
|
||||
|
||||
# ---- msg_daily_stats:活跃客户 distinct + data_as_of + 未回候选 ----
|
||||
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||
peer_kind="friend", peer_id="p1", day=20260901,
|
||||
send_cnt=0, recv_cnt=1, last_ts=_ts(2026, 9, 1, 10, 0),
|
||||
last_is_send=False))
|
||||
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||
peer_kind="friend", peer_id="p2", day=20260902,
|
||||
send_cnt=1, recv_cnt=0, last_ts=_ts(2026, 9, 2, 10, 0),
|
||||
last_is_send=True))
|
||||
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx2",
|
||||
peer_kind="friend", peer_id="p3", day=20260901,
|
||||
send_cnt=2, recv_cnt=0, last_ts=_ts(2026, 9, 1, 11, 0),
|
||||
last_is_send=True))
|
||||
s.add(MsgDailyStats(channel="whatsapp", owner_account_id="wa3",
|
||||
peer_kind="friend", peer_id="p4", day=20260903,
|
||||
send_cnt=1, recv_cnt=0, last_ts=_ts(2026, 9, 3, 9, 0),
|
||||
last_is_send=True))
|
||||
# drilldown cooling/dormant 客户互动史(全历史,窗外):
|
||||
# f1 末次 0801(gap 35, hist 12)→ dormant;f2 末次 0825(gap 11)→ cooling
|
||||
for day, n in ((20260701, 6), (20260801, 6)):
|
||||
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||
peer_kind="friend", peer_id="f1", day=day,
|
||||
send_cnt=n, recv_cnt=0,
|
||||
last_ts=_ts(day // 10000, day // 100 % 100,
|
||||
day % 100, 10, 0),
|
||||
last_is_send=True))
|
||||
for day in (20260801, 20260825):
|
||||
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||
peer_kind="friend", peer_id="f2", day=day,
|
||||
send_cnt=2, recv_cnt=2,
|
||||
last_ts=_ts(2026, 8, day % 100, 11, 0),
|
||||
last_is_send=True))
|
||||
|
||||
# ---- 客户档案(f9 已删,须被 drilldown 剔除)----
|
||||
s.add(Customer(friend_id="f1", channel="wechat", owner_account_id="wx1",
|
||||
friend_nickname="客户一", user_id="u1", delete_flag=0))
|
||||
s.add(Customer(friend_id="f2", channel="wechat", owner_account_id="wx1",
|
||||
friend_nickname="客户二", user_id="u1", delete_flag=0))
|
||||
s.add(Customer(friend_id="f9", channel="wechat", owner_account_id="wx1",
|
||||
friend_nickname="已删客户", user_id="u1", delete_flag=1))
|
||||
|
||||
# ---- 未回候选 p1 的尾部消息(unreplied 决赛圈点查)----
|
||||
s.add(Message(msg_id="m-p1", channel="wechat", owner_account_id="wx1",
|
||||
talker_id="p1", is_send=False, type="text", text="在吗",
|
||||
timestamp=_ts(2026, 9, 1, 10, 0)))
|
||||
|
||||
# ---- 通话 ----
|
||||
s.add(EmployeeCallDaily(day=20260901, user_id="u1", call_count=2,
|
||||
call_seconds=100, answered_call_count=1,
|
||||
call_followup_count=1))
|
||||
s.add(EmployeeCallDaily(day=20260902, user_id="u2", call_count=1,
|
||||
call_seconds=60, answered_call_count=1,
|
||||
call_followup_count=0))
|
||||
s.add(PhoneCall(call_id="c1", channel="voice_call", user_id="u1",
|
||||
start_call_time=_ts(2026, 9, 1, 15, 0), call_duration=120,
|
||||
call_number="13800000001"))
|
||||
s.add(PhoneCall(call_id="c2", channel="voice_call", user_id="u1",
|
||||
start_call_time=_ts(2026, 9, 2, 15, 0), call_duration=0,
|
||||
call_number="13800000002"))
|
||||
await s.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 参数与错误
|
||||
|
||||
|
||||
async def test_dashboard_missing_start_422(make_client):
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/dashboard", params={"end": END})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
async def test_dashboard_span_and_calendar_422(make_client):
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/dashboard",
|
||||
params={"start": 20250101, "end": 20261231})
|
||||
assert r.status_code == 422
|
||||
r = await client.get("/api/management/dashboard",
|
||||
params={"start": 20260230, "end": 20260905})
|
||||
assert r.status_code == 422 # 日历非法(2 月 30 日)
|
||||
r = await client.get("/api/management/dashboard",
|
||||
params={"start": 20260906, "end": 20260901})
|
||||
assert r.status_code == 422 # start > end
|
||||
|
||||
|
||||
async def test_drilldown_unknown_metric_422(make_client):
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/employees/u1/drilldown",
|
||||
params={"metric_code": "bogus",
|
||||
"start": START, "end": END})
|
||||
assert r.status_code == 422
|
||||
assert "unknown metric_code" in r.json()["detail"]
|
||||
|
||||
|
||||
async def test_employees_page_size_over_100_422(make_client):
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/employees",
|
||||
params={"start": START, "end": END,
|
||||
"page_size": 101})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
async def test_employee_detail_404(make_client):
|
||||
async def seed(s):
|
||||
await s.commit()
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/employees/ghost",
|
||||
params={"start": START, "end": END})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
async def test_drilldown_employee_404(make_client):
|
||||
async def seed(s):
|
||||
await s.commit()
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/employees/ghost/drilldown",
|
||||
params={"metric_code": "unreplied",
|
||||
"start": START, "end": END})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- dashboard
|
||||
|
||||
|
||||
async def test_dashboard_structure_and_kpis(make_client):
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/dashboard",
|
||||
params={"channel": "all", "start": START, "end": END})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
for key in ("scope", "data_as_of", "metric_version", "kpis",
|
||||
"communication_trend", "service_trend", "lifecycle",
|
||||
"dimension_summary", "anomalies", "action_summary",
|
||||
"config_source", "caliber_notes"):
|
||||
assert key in body, key
|
||||
assert body["metric_version"] == "v1"
|
||||
assert body["config_source"] == "default"
|
||||
assert body["scope"] == {"channel": "all", "start": START, "end": END,
|
||||
"compare": "previous"}
|
||||
assert body["data_as_of"] == _ts(2026, 9, 3, 9, 0)
|
||||
|
||||
k = body["kpis"]
|
||||
# 活跃客户=PM distinct 求和(u1:2 + u2:1 + u3:1)
|
||||
assert k["active_customers"] == 4
|
||||
assert k["active_employees"] == 3
|
||||
assert k["msg_total"] == 155 # u1 5 + u2 100 + u3 50
|
||||
assert k["cust_threads"] == 25 # 10 + 10 + 5
|
||||
assert k["threads_answered"] == 20 # 10 + 10 + 0
|
||||
assert k["call_count"] == 3
|
||||
assert k["call_seconds"] == 160
|
||||
assert k["answered_call"] == 2
|
||||
# 期末存量:u1 cooling2/dormant1 + u2 cooling1;reactivated 期内求和 u2 5
|
||||
assert body["lifecycle"] == {"new": 0, "cooling": 3, "dormant": 1,
|
||||
"reactivated": 5}
|
||||
assert k["lifecycle"] == body["lifecycle"]
|
||||
|
||||
# 前窗环比(批次D):prev 窗 0827-0831 与基线窗种子重叠——
|
||||
# u1(14+14)×5 + u2(5+5)×5 = 190;宽表无 active_friends/串列 → 0
|
||||
pk = body["prev_kpis"]
|
||||
assert set(pk) == {"active_customers", "active_employees",
|
||||
"msg_total", "cust_threads"}
|
||||
assert pk == {"active_customers": 0, "active_employees": 2,
|
||||
"msg_total": 190, "cust_threads": 0}
|
||||
assert body["msg_delta"] == round((155 - 190) / 190, 3) # -0.184
|
||||
|
||||
# 趋势逐日 5 点;0901 = u1(1)+u2(20)+u3(10) 消息,宽表 active 合计 8,通话 2
|
||||
trend = body["communication_trend"]
|
||||
assert [t["day"] for t in trend] == [20260901 + i for i in range(5)]
|
||||
assert trend[0]["msg_total"] == 31
|
||||
assert trend[0]["active_customers"] == 8
|
||||
assert trend[0]["call_cnt"] == 2
|
||||
assert trend[1]["call_cnt"] == 1
|
||||
assert trend[4] == {"day": 20260905, "msg_total": 31,
|
||||
"active_customers": 8, "call_cnt": 0}
|
||||
|
||||
# 服务趋势:0901 fact [120k, 600k] → p50=120k/p90=600k;
|
||||
# reply_2h_rate = 2/5(两串均 ≤2h——600s=10min / 宽表当日串 5)
|
||||
st = {t["day"]: t for t in body["service_trend"]}
|
||||
assert st[20260901]["fr_p50_ms"] == 120_000
|
||||
assert st[20260901]["fr_p90_ms"] == 600_000
|
||||
assert st[20260901]["reply_2h_rate"] == 0.4
|
||||
assert st[20260903]["fr_p50_ms"] is None # 无 fact 日
|
||||
assert st[20260903]["reply_2h_rate"] == 0.0 # 分母有串、分子 0
|
||||
|
||||
# 维度摘要:5 维全员计数,总数=员工数 3
|
||||
dims = body["dimension_summary"]
|
||||
assert set(dims) == {"customer", "service", "action", "workload", "risk"}
|
||||
for d in dims.values():
|
||||
assert d["scored_count"] + d["insufficient_count"] + d["pending_count"] == 3
|
||||
assert "avg_score" in d
|
||||
|
||||
# 异常:critical 在前(u2),warning 其后按 affected_count desc;
|
||||
# dashboard 卡片 enrich employee_name(批次D,前端跳员工详情用)
|
||||
anoms = body["anomalies"]
|
||||
assert anoms[0]["severity"] == "critical"
|
||||
assert anoms[0]["employee_user_id"] == "u2"
|
||||
assert anoms[0]["employee_name"] == "李四"
|
||||
assert all(a["severity"] == "critical" for a in anoms[:1])
|
||||
assert all(a["severity"] == "warning" for a in anoms[1:])
|
||||
assert all(a["employee_name"] == "张三" for a in anoms[1:])
|
||||
|
||||
assert body["action_summary"] == {"unreplied_2h": 1, "churn_real_30d": 0,
|
||||
"sensitive_pending": 0}
|
||||
|
||||
|
||||
async def test_dashboard_prev_zero_no_delta(make_client):
|
||||
"""前窗无数据(whatsapp 视图前窗零宽表)→ prev_kpis 四键全 0、
|
||||
msg_delta None(prev=0 不算环比,None 而非 0/负∞)。"""
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/dashboard",
|
||||
params={"channel": "whatsapp", "start": START,
|
||||
"end": END})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["kpis"]["msg_total"] == 50 # 仅 u3 的 whatsapp 宽表
|
||||
assert body["prev_kpis"] == {"active_customers": 0,
|
||||
"active_employees": 0, "msg_total": 0,
|
||||
"cust_threads": 0}
|
||||
assert body["msg_delta"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- employees
|
||||
|
||||
|
||||
async def test_employees_default_sort_and_paging(make_client):
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/employees",
|
||||
params={"channel": "all", "start": START, "end": END})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["total"] == 3
|
||||
assert body["page"] == 1
|
||||
assert body["page_size"] == 20
|
||||
assert body["metric_version"] == "v1"
|
||||
assert body["config_source"] == "default"
|
||||
assert len(body["rows"]) == 3
|
||||
|
||||
# 默认 anomaly_severity 排序:含 critical 的 u2 在最前
|
||||
# (u2=1 critical——基线无串样本率不可算,rate 规则不触发;u1=2 warning)
|
||||
ids = [row["user_id"] for row in body["rows"]]
|
||||
assert ids == ["u2", "u1", "u3"]
|
||||
u2, u1, u3 = body["rows"]
|
||||
assert u2["anomaly_count"] == 1 and u2["anomaly_weight"] == 2
|
||||
assert u1["anomaly_count"] == 2 and u1["anomaly_weight"] == 2
|
||||
assert u3["anomaly_count"] == 0 and u3["anomaly_weight"] == 0
|
||||
|
||||
# 行结构:metrics 全字段 / comparisons 四值 / score 摘要
|
||||
assert u1["name"] == "张三" and u1["is_on_job"] is True
|
||||
assert u1["metrics"]["cust_threads"] == 10
|
||||
assert u1["metrics"]["end_active_friends"] == 3
|
||||
# M3:comparisons 键域=源指标名(与 sections 端点一致;band 配置 code
|
||||
# 映射为其 spec.metric——active_friends/call_count,不出现 *_band)
|
||||
assert set(u1["comparisons"]) >= {
|
||||
"active_share", "reply_2h_rate", "reply_24h_rate", "fr_p90_ms",
|
||||
"active_friends", "call_count"}
|
||||
assert "active_friends_band" not in u1["comparisons"]
|
||||
assert "call_band" not in u1["comparisons"]
|
||||
for c in u1["comparisons"].values():
|
||||
assert set(c) == {"current", "prev", "baseline", "median"}
|
||||
assert u1["comparisons"]["fr_p90_ms"]["current"] == 120_000
|
||||
assert u1["comparisons"]["fr_p90_ms"]["baseline"] == 60_000
|
||||
assert set(u1["score"]["dimensions"]) == {
|
||||
"customer", "service", "action", "workload", "risk"}
|
||||
for d in u1["score"]["dimensions"].values():
|
||||
assert set(d) == {"score", "status"}
|
||||
assert u1["score"]["trial"] is True
|
||||
assert u1["score"]["score_status"] == "scored" # 5 天数据 ≥ min_days
|
||||
|
||||
|
||||
async def test_employees_channel_filter(make_client):
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/employees",
|
||||
params={"channel": "wechat", "start": START,
|
||||
"end": END})
|
||||
ids = [row["user_id"] for row in r.json()["rows"]]
|
||||
assert ids == ["u2", "u1"] # u3 仅 WhatsApp,wechat 视图消失
|
||||
r = await client.get("/api/management/employees",
|
||||
params={"channel": "whatsapp", "start": START,
|
||||
"end": END})
|
||||
ids = [row["user_id"] for row in r.json()["rows"]]
|
||||
assert ids == ["u2", "u3"] # u1 无 WA 账号
|
||||
|
||||
|
||||
async def test_employees_keyword(make_client):
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/employees",
|
||||
params={"start": START, "end": END, "keyword": "张"})
|
||||
assert [row["user_id"] for row in r.json()["rows"]] == ["u1"]
|
||||
r = await client.get("/api/management/employees",
|
||||
params={"start": START, "end": END, "keyword": "U3"})
|
||||
assert [row["user_id"] for row in r.json()["rows"]] == ["u3"]
|
||||
|
||||
|
||||
async def test_employees_status_filter(make_client):
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/employees",
|
||||
params={"start": START, "end": END,
|
||||
"status": "on_job"})
|
||||
assert [row["user_id"] for row in r.json()["rows"]] == ["u2", "u1"]
|
||||
r = await client.get("/api/management/employees",
|
||||
params={"start": START, "end": END,
|
||||
"status": "offline"})
|
||||
assert [row["user_id"] for row in r.json()["rows"]] == ["u3"]
|
||||
|
||||
|
||||
async def test_employees_min_days_insufficient(make_client):
|
||||
"""B2 评审遗留接线:days_with_data < min_days → score_status 覆盖
|
||||
insufficient_sample,但可评明细(total_score)保留展示。"""
|
||||
|
||||
async def seed(s):
|
||||
s.add(Staff(user_id="u9", name="赵九", is_on_job=True))
|
||||
s.add(WechatAccount(wx_id="wx9", user_id="u9"))
|
||||
# 仅 2 天宽表数据(< min_days=5),但串样本 30 ≥ min_threads 可评分
|
||||
for d in (1, 2):
|
||||
s.add(_mday(20260900 + d, user_id="u9", cust_threads=15,
|
||||
threads_answered=15, fr_sum_ms=900_000,
|
||||
active_friends=10))
|
||||
s.add(_fact(60_000, owner="wx9", peer="q1", day=20260901, i=0))
|
||||
s.add(_fact(60_000, owner="wx9", peer="q2", day=20260902, i=1))
|
||||
for i in range(6): # 6 个 distinct peer → workload band 可评
|
||||
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx9",
|
||||
peer_kind="friend", peer_id=f"q{i}",
|
||||
day=20260901, send_cnt=1, recv_cnt=0,
|
||||
last_ts=_ts(2026, 9, 1, 12, 0),
|
||||
last_is_send=True))
|
||||
await s.commit()
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/employees",
|
||||
params={"start": START, "end": END})
|
||||
assert r.status_code == 200
|
||||
row = r.json()["rows"][0]
|
||||
assert row["user_id"] == "u9"
|
||||
assert row["metrics"]["days_with_data"] == 2
|
||||
assert row["score"]["score_status"] == "insufficient_sample"
|
||||
assert row["score"]["total_score"] is not None # 明细保留展示
|
||||
|
||||
|
||||
async def test_employees_db_score_config_guard(make_client):
|
||||
"""config_source=db 路径:激活配置的 min_days=9 生效——u1 五天数据
|
||||
在默认配置(min_days=5)下 scored,在 DB 配置下翻为 insufficient_sample。"""
|
||||
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
cfg = copy.deepcopy(DEFAULT_SCORE_CONFIG)
|
||||
cfg["version"] = "v1-test"
|
||||
cfg["name"] = "评审测试配置"
|
||||
cfg["sample_guards"]["min_days"] = 9
|
||||
s.add(EmployeeScoreConfig(version="v1-test", name="评审测试配置",
|
||||
status="active", config_json=cfg,
|
||||
created_by="test"))
|
||||
await s.commit()
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/employees",
|
||||
params={"channel": "all", "start": START,
|
||||
"end": END})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["config_source"] == "db"
|
||||
u1 = next(row for row in body["rows"] if row["user_id"] == "u1")
|
||||
assert u1["metrics"]["days_with_data"] == 5
|
||||
assert u1["score"]["score_status"] == "insufficient_sample" # 5 < 9
|
||||
assert u1["score"]["total_score"] is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- detail
|
||||
|
||||
|
||||
async def test_employee_detail_full(make_client):
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/employees/u1",
|
||||
params={"channel": "all", "start": START, "end": END})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["user_id"] == "u1"
|
||||
assert body["name"] == "张三"
|
||||
assert body["accounts"] == [
|
||||
{"channel": "wechat", "account_id": "wx1",
|
||||
"nick_name": "张三的微信", "user_name": "张三"}]
|
||||
# metrics 全字段(22 列 namedtuple)
|
||||
assert len(body["metrics"]) == 22
|
||||
assert body["metrics"]["cust_threads"] == 10
|
||||
# score 完整明细:dimensions 含逐指标 metrics;metrics_detail 扁平
|
||||
assert body["score"]["dimensions"]["service"]["metrics"]["fr_p90_ms"][
|
||||
"score"] == 100.0
|
||||
assert body["score"]["metrics_detail"]["fr_p90_ms"]["value"] == 120_000
|
||||
assert body["score"]["metrics_detail"]["reply_2h_rate"]["value"] == 0.2
|
||||
assert body["score"]["metrics_detail"]["fr_p90_ms"]["baseline"] == 60_000
|
||||
# anomalies + prev/baseline 原始值(prev 窗 0827-0831 与基线窗重叠:
|
||||
# 5 天 × msg_sent 14 = 70);详情端点 anomalies 不带 employee_name
|
||||
# (自指无意义,仅 dashboard enrich)
|
||||
assert len(body["anomalies"]) == 2
|
||||
assert all(a["severity"] == "warning" for a in body["anomalies"])
|
||||
assert all("employee_name" not in a for a in body["anomalies"])
|
||||
assert body["prev"]["msg_sent"] == 70
|
||||
assert body["baseline"]["msg_sent"] == 70
|
||||
assert body["data_as_of"] == _ts(2026, 9, 3, 9, 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- sections
|
||||
|
||||
|
||||
async def test_employee_sections(make_client):
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
|
||||
client = await make_client(seed)
|
||||
base = "/api/management/employees/u1/metrics"
|
||||
for section in ("overview", "service", "customer", "workload", "calls"):
|
||||
r = await client.get(base, params={
|
||||
"section": section, "channel": "wechat",
|
||||
"start": START, "end": END})
|
||||
assert r.status_code == 200, section
|
||||
body = r.json()
|
||||
assert body["section"] == section
|
||||
assert len(body["trend"]) == 5 # 全窗口逐日(含零填充)
|
||||
assert all("day" in t for t in body["trend"])
|
||||
assert body["comparisons"]["reply_2h_rate"]["current"] == 0.2
|
||||
assert set(body["comparisons"]["reply_2h_rate"]) == {
|
||||
"current", "prev", "baseline", "median"}
|
||||
|
||||
r = await client.get(base, params={"section": "service",
|
||||
"channel": "wechat",
|
||||
"start": START, "end": END})
|
||||
body = r.json()
|
||||
assert body["metrics"]["cust_threads"] == 10
|
||||
assert body["metrics"]["reply_2h_count"] == 2
|
||||
t0 = body["trend"][0]
|
||||
assert t0 == {"day": 20260901, "cust_threads": 2, "threads_answered": 2,
|
||||
"fr_sum_ms": 240_000, "first_reply_p50_ms": 110_000,
|
||||
"first_reply_p90_ms": 220_000}
|
||||
|
||||
r = await client.get(base, params={"section": "calls", "channel": "all",
|
||||
"start": START, "end": END})
|
||||
calls = {t["day"]: t for t in r.json()["trend"]}
|
||||
assert calls[20260901]["call_count"] == 2
|
||||
assert calls[20260901]["call_followup_count"] == 1
|
||||
assert calls[20260905]["call_count"] == 0 # 零填充
|
||||
assert r.json()["metrics"]["call_count"] == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- drilldown
|
||||
|
||||
|
||||
async def test_drilldown_fr_p90(make_client):
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
|
||||
client = await make_client(seed)
|
||||
url = "/api/management/employees/u2/drilldown"
|
||||
r = await client.get(url, params={
|
||||
"metric_code": "first_reply_p90", "channel": "all",
|
||||
"start": START, "end": END, "page": 1, "page_size": 1})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["total"] == 2
|
||||
assert len(body["items"]) == 1
|
||||
item = body["items"][0]
|
||||
# fr_ms desc + 同值按日 desc 决胜:0902 的 pD 在前
|
||||
assert item["fr_ms"] == 600_000
|
||||
assert item["peer_id"] == "pD"
|
||||
assert item["channel"] == "wechat"
|
||||
assert item["owner_account_id"] == "wx2"
|
||||
assert item["chat_hint"] == {"owner_account_id": "wx2", "talker_id": "pD"}
|
||||
r = await client.get(url, params={
|
||||
"metric_code": "first_reply_p90", "channel": "all",
|
||||
"start": START, "end": END, "page": 2, "page_size": 1})
|
||||
assert r.json()["items"][0]["peer_id"] == "pC"
|
||||
# u2 的 WA 账号无 fact 行
|
||||
r = await client.get(url, params={
|
||||
"metric_code": "first_reply_p50", "channel": "whatsapp",
|
||||
"start": START, "end": END})
|
||||
assert r.json()["total"] == 0
|
||||
|
||||
|
||||
async def test_drilldown_unreplied(make_client):
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/employees/u1/drilldown",
|
||||
params={"metric_code": "unreplied",
|
||||
"channel": "wechat",
|
||||
"start": START, "end": END})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["total"] == 1 # p1(wx1)进入,u1 过滤后
|
||||
assert body["items"][0]["peer_id"] == "p1"
|
||||
assert body["items"][0]["owner_account_id"] == "wx1"
|
||||
# u2 名下无未回候选(p3 尾部为员工消息)
|
||||
r = await client.get("/api/management/employees/u2/drilldown",
|
||||
params={"metric_code": "unreplied",
|
||||
"channel": "wechat",
|
||||
"start": START, "end": END})
|
||||
assert r.json()["total"] == 0
|
||||
|
||||
|
||||
async def test_drilldown_lifecycle_customers(make_client):
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/employees/u1/drilldown",
|
||||
params={"metric_code": "cooling_customers",
|
||||
"channel": "all",
|
||||
"start": START, "end": END})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["total"] == 1 # f2 降温;f1 沉睡、f9 已删
|
||||
assert body["items"][0] == {"friend_id": "f2", "channel": "wechat",
|
||||
"nickname": "客户二",
|
||||
"last_day": 20260825, "state": "cooling"}
|
||||
|
||||
r = await client.get("/api/management/employees/u1/drilldown",
|
||||
params={"metric_code": "dormant_customers",
|
||||
"channel": "all",
|
||||
"start": START, "end": END})
|
||||
body = r.json()
|
||||
assert body["total"] == 1
|
||||
assert body["items"][0]["friend_id"] == "f1"
|
||||
assert body["items"][0]["state"] == "dormant"
|
||||
|
||||
|
||||
async def test_drilldown_shared_customer_history(make_client):
|
||||
"""I1:共享/换归属客户——互动史挂在他人账号(wx2)名下而 Customer.owner
|
||||
=wx1:历史按 (channel, peer) 键控跨 owner 合并(对齐批次A _lifecycle_daily
|
||||
的 peer 键控口径),客户不丢、历史完整(hist 12 ≥ 5 判沉睡)。"""
|
||||
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
for day, month in ((20260701, 7), (20260801, 8)):
|
||||
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx2",
|
||||
peer_kind="friend", peer_id="f3", day=day,
|
||||
send_cnt=3, recv_cnt=3,
|
||||
last_ts=_ts(2026, month, day % 100, 12, 0),
|
||||
last_is_send=True))
|
||||
s.add(Customer(friend_id="f3", channel="wechat",
|
||||
owner_account_id="wx1", friend_nickname="共享客户",
|
||||
user_id="u1", delete_flag=0))
|
||||
await s.commit()
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/employees/u1/drilldown",
|
||||
params={"metric_code": "dormant_customers",
|
||||
"channel": "all",
|
||||
"start": START, "end": END})
|
||||
body = r.json()
|
||||
assert body["total"] == 2 # f1 + f3(历史全在 wx2 名下也不丢)
|
||||
f3 = next(i for i in body["items"] if i["friend_id"] == "f3")
|
||||
assert f3 == {"friend_id": "f3", "channel": "wechat",
|
||||
"nickname": "共享客户",
|
||||
"last_day": 20260801, "state": "dormant"}
|
||||
# 归属口径仍是 Customer.owner(wx1):u2(wx2)名下看不到
|
||||
r = await client.get("/api/management/employees/u2/drilldown",
|
||||
params={"metric_code": "dormant_customers",
|
||||
"channel": "all",
|
||||
"start": START, "end": END})
|
||||
assert r.json()["total"] == 0
|
||||
|
||||
|
||||
async def test_drilldown_call_followup(make_client):
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/employees/u1/drilldown",
|
||||
params={"metric_code": "call_followup",
|
||||
"channel": "all",
|
||||
"start": START, "end": END})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["total"] == 2
|
||||
# start_call_time desc:c2(0902)在前
|
||||
assert [i["call_id"] for i in body["items"]] == ["c2", "c1"]
|
||||
assert set(body["items"][0]) == {
|
||||
"call_id", "channel", "call_number", "start_call_time",
|
||||
"call_duration"}
|
||||
assert body["summary"] == {"call_count": 2, "call_seconds": 100,
|
||||
"answered_call_count": 1,
|
||||
"call_followup_count": 1}
|
||||
|
||||
|
||||
# ------------------------------------------------------- metric-definitions
|
||||
|
||||
|
||||
async def test_metric_definitions(make_client):
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/metric-definitions")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["definitions"]["cust_threads"]["unit"] == "串"
|
||||
assert body["score_config"]["trial"] is True
|
||||
assert body["score_config"]["dimensions"]["risk"]["status"] == "pending_data"
|
||||
assert body["config_source"] == "default" # 未种子配置→回退默认
|
||||
assert "1:1" in body["caliber_notes"]
|
||||
@@ -0,0 +1,300 @@
|
||||
"""管理域期聚合查询判别测试(批次B Task B1)。
|
||||
|
||||
判据(v2 §3 / B1 规格):
|
||||
- 分位=fact 表最近邻秩 ceil(q·n) 精确值(整除算术,无 ceil 方言坑),
|
||||
多账号/跨渠道样本按员工合并;
|
||||
- 宽表 SUM 按 (day∈窗, metric_version='v1', channel) 过滤;
|
||||
active_rooms_avg 摊到全窗天数(含无数据日);
|
||||
- 期末生命周期存量=每 (user, channel) 最新 ≤end 行,跨渠道相加;
|
||||
- distinct 活跃客户=窗内 (uid, peer) 去重(peer_kind='friend')——
|
||||
同一员工多账号的共享客户只计 1(与宽表日值口径一致);
|
||||
- 窗口工具:闭区间 day_range/等长前窗/28 天个人基线窗;
|
||||
- 幽灵员工(不在 staff)过滤;user_ids 交集;通话恒全渠道。
|
||||
"""
|
||||
import datetime as dt
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.db.models import (
|
||||
EmployeeCallDaily,
|
||||
EmployeeMetricDaily,
|
||||
FirstReplyFact,
|
||||
MsgDailyStats,
|
||||
Staff,
|
||||
WechatAccount,
|
||||
WhatsappAccount,
|
||||
)
|
||||
from app.management.employee_metrics import (
|
||||
PeriodMetrics,
|
||||
baseline_window,
|
||||
day_range,
|
||||
period_metrics,
|
||||
prev_window,
|
||||
team_medians,
|
||||
)
|
||||
|
||||
_SH = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def _ts(*args) -> int:
|
||||
return int(dt.datetime(*args, tzinfo=_SH).timestamp() * 1000)
|
||||
|
||||
|
||||
async def _seed_org(s, users=("u1", "u2")):
|
||||
for u in users:
|
||||
s.add(Staff(user_id=u, name=u, is_on_job=True))
|
||||
s.add(WechatAccount(wx_id="wx1", user_id="u1"))
|
||||
s.add(WechatAccount(wx_id="wx2", user_id="u2"))
|
||||
await s.commit()
|
||||
|
||||
|
||||
def _mday(day, *, channel="wechat", user_id="u1", metric_version="v1", **kw):
|
||||
cols = {
|
||||
"msg_sent": 0, "msg_recv": 0, "active_friends": 0, "active_rooms": 0,
|
||||
"cust_threads": 0, "threads_answered": 0, "fr_sum_ms": 0,
|
||||
"new_customer_count": 0, "cooling_customer_count": 0,
|
||||
"dormant_customer_count": 0, "reactivated_customer_count": 0,
|
||||
"data_complete": True,
|
||||
}
|
||||
cols.update(kw)
|
||||
return EmployeeMetricDaily(
|
||||
day=day, channel=channel, user_id=user_id,
|
||||
metric_version=metric_version, **cols
|
||||
)
|
||||
|
||||
|
||||
def _fact(fr_ms, *, channel="wechat", owner="wx1", peer="p1", day=20260901, i=0):
|
||||
end = _ts(2026, 9, 1, 10, 0) + i * 60_000
|
||||
return FirstReplyFact(
|
||||
channel=channel, owner_account_id=owner, peer_id=peer,
|
||||
thread_end_ts=end, reply_ts=end + fr_ms,
|
||||
reply_msg_id=f"{owner}-{peer}-m{i}", day=day, fr_ms=fr_ms,
|
||||
)
|
||||
|
||||
|
||||
async def test_percentile_exact(make_session):
|
||||
"""5 样本 p50=180s/p90=300s(ceil(2.5)=3、ceil(4.5)=5,1-based);
|
||||
2 样本 p50=10s/p90=20s;窗外行不参与。"""
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
for i, sec in enumerate([60, 120, 180, 240, 300]):
|
||||
s.add(_fact(sec * 1000, i=i))
|
||||
for i, sec in enumerate([10, 20]):
|
||||
s.add(_fact(sec * 1000, owner="wx2", peer="q1", i=i))
|
||||
# 窗外(0906>end)长尾:混入则 p90=999s
|
||||
s.add(_fact(999_000, i=9, day=20260906))
|
||||
await s.commit()
|
||||
m = await period_metrics(s, 20260901, 20260905, "wechat")
|
||||
assert m["u1"].fr_p50_ms == 180_000
|
||||
assert m["u1"].fr_p90_ms == 300_000
|
||||
assert m["u2"].fr_p50_ms == 10_000
|
||||
assert m["u2"].fr_p90_ms == 20_000
|
||||
|
||||
|
||||
async def test_period_sums_vs_daily_rows(make_session):
|
||||
"""宽表两天行 SUM 正确;days_with_data=2;active_rooms_avg 摊到全窗
|
||||
5 天(含无数据日);channel/metric_version/窗外行都不计。"""
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
s.add(_mday(20260901, msg_sent=3, msg_recv=5, cust_threads=2,
|
||||
threads_answered=1, fr_sum_ms=7_000, active_rooms=4,
|
||||
reactivated_customer_count=1))
|
||||
s.add(_mday(20260903, msg_sent=4, msg_recv=6, cust_threads=3,
|
||||
threads_answered=2, fr_sum_ms=11_000, active_rooms=6,
|
||||
reactivated_customer_count=2))
|
||||
# 反例三连:错渠道 / 错版本 / 窗外
|
||||
s.add(_mday(20260901, channel="whatsapp", msg_sent=100))
|
||||
s.add(_mday(20260901, metric_version="v2", msg_sent=200))
|
||||
s.add(_mday(20260906, msg_sent=400))
|
||||
await s.commit()
|
||||
m = await period_metrics(s, 20260901, 20260905, "wechat")
|
||||
u1 = m["u1"]
|
||||
assert u1.msg_sent == 7
|
||||
assert u1.msg_recv == 11
|
||||
assert u1.cust_threads == 5
|
||||
assert u1.threads_answered == 3
|
||||
assert u1.fr_sum_ms == 18_000
|
||||
assert u1.days_with_data == 2
|
||||
assert u1.active_rooms_avg == 2.0 # (4+6)/5 全窗天数,非 2 有数据日
|
||||
assert u1.reactivated_customer == 3
|
||||
|
||||
|
||||
async def test_end_of_period_stock(make_session):
|
||||
"""期末存量=每渠道最新 ≤end 行:窗 [0901,0903] 取 0901 行、
|
||||
[0901,0905] 取 0905 行;channel=None 跨渠道相加(4+2=6)。
|
||||
end_active_friends 同源同行:wechat 7 + whatsapp 3 → 10(评分
|
||||
active_share 分子,与周期 distinct 的 active_friends 字段不同口径)。"""
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
s.add(_mday(20260901, new_customer_count=1, cooling_customer_count=2,
|
||||
dormant_customer_count=3, active_friends=7))
|
||||
s.add(_mday(20260905, dormant_customer_count=4, active_friends=7))
|
||||
s.add(_mday(20260905, channel="whatsapp", dormant_customer_count=2,
|
||||
active_friends=3))
|
||||
await s.commit()
|
||||
|
||||
m1 = await period_metrics(s, 20260901, 20260903, "wechat")
|
||||
assert m1["u1"].new_customer == 1
|
||||
assert m1["u1"].cooling_customer == 2
|
||||
assert m1["u1"].dormant_customer == 3
|
||||
assert m1["u1"].end_active_friends == 7 # 渠道内最新行(0901)的值
|
||||
|
||||
m2 = await period_metrics(s, 20260901, 20260905, "wechat")
|
||||
assert m2["u1"].dormant_customer == 4 # wechat 渠道内最新行
|
||||
assert m2["u1"].new_customer == 0
|
||||
assert m2["u1"].end_active_friends == 7
|
||||
|
||||
m3 = await period_metrics(s, 20260901, 20260905, None)
|
||||
assert m3["u1"].dormant_customer == 6 # wechat 4 + whatsapp 2
|
||||
assert m3["u1"].end_active_friends == 10 # wechat 7 + whatsapp 3
|
||||
|
||||
|
||||
async def test_distinct_active_cross_day(make_session):
|
||||
"""同 peer 两天两行只计 1;room 行与幽灵 owner 不计。"""
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
for day, peer in [(20260901, "p1"), (20260902, "p1"), (20260902, "p2")]:
|
||||
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||
peer_kind="friend", peer_id=peer, day=day))
|
||||
# room 行不计入活跃客户
|
||||
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||
peer_kind="room", peer_id="r1", day=20260902))
|
||||
# 幽灵 owner(account_map 无映射)不计
|
||||
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx9",
|
||||
peer_kind="friend", peer_id="p9", day=20260902))
|
||||
await s.commit()
|
||||
m = await period_metrics(s, 20260901, 20260902, "wechat")
|
||||
assert m["u1"].active_friends == 2
|
||||
|
||||
|
||||
async def test_active_friends_shared_peer_across_accounts(make_session):
|
||||
"""多账号共享客户去重:u1 的 wx1/wx3 都会话 p1 → active_friends=1
|
||||
(账号归并到员工后按 peer 去重,非 (owner, peer) 双计)。"""
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
s.add(WechatAccount(wx_id="wx3", user_id="u1"))
|
||||
await s.commit()
|
||||
for owner in ("wx1", "wx3"):
|
||||
s.add(MsgDailyStats(channel="wechat", owner_account_id=owner,
|
||||
peer_kind="friend", peer_id="p1", day=20260901))
|
||||
await s.commit()
|
||||
m = await period_metrics(s, 20260901, 20260905, "wechat")
|
||||
assert m["u1"].active_friends == 1
|
||||
assert m["u2"].active_friends == 0
|
||||
|
||||
|
||||
def test_window_helpers():
|
||||
# day_range 闭区间 + 月末边界
|
||||
assert day_range(20260901, 20260903) == [20260901, 20260902, 20260903]
|
||||
assert day_range(20260831, 20260901) == [20260831, 20260901]
|
||||
# prev_window 等长紧邻前窗(7 天窗 → 7 天窗;单日 → 单日)
|
||||
assert prev_window(20260908, 20260914) == (20260901, 20260907)
|
||||
assert prev_window(20260901, 20260901) == (20260831, 20260831)
|
||||
# baseline_window:紧邻窗前 28 天([0915] → [0818, 0914])
|
||||
assert baseline_window(20260915) == (20260818, 20260914)
|
||||
|
||||
|
||||
async def test_ghost_and_channel_none(make_session):
|
||||
"""幽灵(不在 staff)宽表行/fact 行不计;channel=None 合并两渠道
|
||||
amap——u1(wx1+wa1)的宽表 SUM 与 fact 分位都跨渠道归并;
|
||||
无数据员工(u2)也以零值行出现。"""
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
s.add(WhatsappAccount(uid="wa1", user_id="u1"))
|
||||
# 账号存在但映射到非 staff 用户 → 幽灵
|
||||
s.add(WechatAccount(wx_id="wxg", user_id="ghost"))
|
||||
await s.commit()
|
||||
|
||||
s.add(_mday(20260901, msg_sent=3))
|
||||
s.add(_mday(20260901, channel="whatsapp", msg_sent=4))
|
||||
s.add(_mday(20260901, user_id="ghost", msg_sent=50))
|
||||
s.add(_fact(100_000, owner="wx1", i=0))
|
||||
s.add(_fact(300_000, channel="whatsapp", owner="wa1", peer="w1", i=1))
|
||||
s.add(_fact(50_000, owner="wxg", i=2))
|
||||
await s.commit()
|
||||
|
||||
m = await period_metrics(s, 20260901, 20260905, None)
|
||||
assert set(m) == {"u1", "u2"} # ghost 不在;零数据 u2 在
|
||||
u1 = m["u1"]
|
||||
assert u1.msg_sent == 7 # 两渠道行都归 u1
|
||||
assert u1.days_with_data == 1 # 双渠道同日只计 1 天(DISTINCT day)
|
||||
assert u1.fr_p50_ms == 100_000 # [100s,300s] 合并后 ceil(0.5*2)=1
|
||||
assert u1.fr_p90_ms == 300_000 # ceil(0.9*2)=2
|
||||
assert u1.reply_2h_count == 2
|
||||
assert u1.reply_24h_count == 2
|
||||
assert m["u2"].msg_sent == 0
|
||||
assert m["u2"].fr_p50_ms is None
|
||||
|
||||
|
||||
async def test_reply_counts(make_session):
|
||||
"""fr=90min/3h/25h → 2h_count=1、24h_count=2(2h 是 24h 的子集)。"""
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
s.add(_fact(5_400_000, i=0)) # 90min ≤2h
|
||||
s.add(_fact(10_800_000, i=1)) # 3h ∈(2h,24h]
|
||||
s.add(_fact(90_000_000, i=2)) # 25h >24h
|
||||
await s.commit()
|
||||
m = await period_metrics(s, 20260901, 20260905, "wechat")
|
||||
assert m["u1"].reply_2h_count == 1
|
||||
assert m["u1"].reply_24h_count == 2
|
||||
assert m["u2"].reply_2h_count == 0
|
||||
|
||||
|
||||
async def test_user_ids_filter_and_calls(make_session):
|
||||
"""user_ids 与 staff 求交(未知 id 剔除/空集→{});通话四列按窗 SUM
|
||||
且不受 channel 参数影响(无渠道维,恒全渠道)。"""
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
s.add(_mday(20260901, user_id="u1", msg_sent=1))
|
||||
s.add(_mday(20260901, user_id="u2", msg_sent=2))
|
||||
s.add(EmployeeCallDaily(day=20260901, user_id="u1", call_count=3,
|
||||
call_seconds=600, answered_call_count=2,
|
||||
call_followup_count=1))
|
||||
s.add(EmployeeCallDaily(day=20260902, user_id="u1", call_count=1,
|
||||
call_seconds=60, answered_call_count=1,
|
||||
call_followup_count=0))
|
||||
s.add(EmployeeCallDaily(day=20260901, user_id="u2", call_count=9))
|
||||
await s.commit()
|
||||
|
||||
m = await period_metrics(s, 20260901, 20260905, "wechat", user_ids=["u1", "nobody"])
|
||||
assert set(m) == {"u1"}
|
||||
assert m["u1"].msg_sent == 1
|
||||
u1 = m["u1"]
|
||||
assert u1.call_count == 4
|
||||
assert u1.call_seconds == 660
|
||||
assert u1.answered_call == 3
|
||||
assert u1.call_followup == 1
|
||||
|
||||
empty = await period_metrics(s, 20260901, 20260905, "wechat", user_ids=[])
|
||||
assert empty == {}
|
||||
|
||||
# channel="whatsapp" 时通话仍计入(恒全渠道)
|
||||
mwa = await period_metrics(s, 20260901, 20260905, "whatsapp")
|
||||
assert mwa["u1"].call_count == 4
|
||||
|
||||
|
||||
def test_team_medians():
|
||||
"""奇数样本取中位、偶数取均值;None 剔除;全 None → None;user_id 不参与。"""
|
||||
|
||||
def _pm(**kw):
|
||||
base = dict(zip(PeriodMetrics._fields, [None] * len(PeriodMetrics._fields)))
|
||||
base.update(user_id="x", days_with_data=0, msg_sent=0, msg_recv=0,
|
||||
cust_threads=0, threads_answered=0, fr_sum_ms=0,
|
||||
reply_2h_count=0, reply_24h_count=0, active_friends=0,
|
||||
active_rooms_avg=0.0, new_customer=0, cooling_customer=0,
|
||||
dormant_customer=0, reactivated_customer=0,
|
||||
call_count=0, call_seconds=0, answered_call=0,
|
||||
call_followup=0)
|
||||
base.update(kw)
|
||||
return PeriodMetrics(**base)
|
||||
|
||||
metrics = {
|
||||
"u1": _pm(user_id="u1", msg_sent=10, fr_p50_ms=None),
|
||||
"u2": _pm(user_id="u2", msg_sent=20, fr_p50_ms=100),
|
||||
"u3": _pm(user_id="u3", msg_sent=30, fr_p50_ms=200),
|
||||
}
|
||||
med = team_medians(metrics)
|
||||
assert med["msg_sent"] == 20
|
||||
assert med["fr_p50_ms"] == 150.0 # None 剔除后 [100,200] 均值
|
||||
assert "user_id" not in med
|
||||
assert team_medians({})["msg_sent"] is None
|
||||
assert team_medians({"u1": _pm()})["fr_p50_ms"] is None
|
||||
@@ -0,0 +1,315 @@
|
||||
"""管理域评分配置/引擎判别测试(批次B Task B2)。"""
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from app.db.models import EmployeeScoreConfig
|
||||
from app.management.employee_metrics import PeriodMetrics
|
||||
from app.management.metric_definitions import (
|
||||
DEFAULT_SCORE_CONFIG,
|
||||
METRIC_DEFINITIONS,
|
||||
validate_config,
|
||||
)
|
||||
from app.management.scoring import normalize, score_employee
|
||||
|
||||
|
||||
def test_config_weights_sum_and_structure():
|
||||
dims = DEFAULT_SCORE_CONFIG["dimensions"]
|
||||
assert sum(d["weight"] for d in dims.values()) == 100
|
||||
# 一期 pending_data 的维度不参与试算
|
||||
available = [d for d in dims.values() if d.get("status") != "pending_data"]
|
||||
assert sum(d["weight"] for d in available) == 70 # 30+25+15
|
||||
assert dims["action"]["status"] == "pending_data"
|
||||
assert dims["risk"]["status"] == "pending_data"
|
||||
|
||||
|
||||
def test_config_normalization_declared():
|
||||
assert DEFAULT_SCORE_CONFIG["normalization"] == "available_only_rescale"
|
||||
|
||||
|
||||
def test_validate_config_rejects_bad_weight_sum():
|
||||
bad = json.loads(json.dumps(DEFAULT_SCORE_CONFIG)) # deep copy
|
||||
bad["dimensions"]["service"]["weight"] = 26 # 总和 101
|
||||
problems = validate_config(bad)
|
||||
assert any("权重" in p for p in problems)
|
||||
|
||||
|
||||
def test_metric_definitions_cover_scored_metrics():
|
||||
# 每个参与评分的 metric_code 都有元数据(名称/单位/方向)
|
||||
for dim in DEFAULT_SCORE_CONFIG["dimensions"].values():
|
||||
for code in dim.get("metrics", {}):
|
||||
# band 型指标的 key 带 _band 后缀,spec 里有 metric 字段:
|
||||
spec = dim["metrics"][code]
|
||||
src = spec.get("metric", code)
|
||||
assert src in METRIC_DEFINITIONS
|
||||
m = METRIC_DEFINITIONS[src]
|
||||
assert m["name"] and m["unit"] and m["direction"] in (1, -1)
|
||||
|
||||
|
||||
async def test_seed_and_idempotent(make_session):
|
||||
from app.management.metric_definitions import _seed_score_config # 纯会话函数
|
||||
s = make_session
|
||||
v1 = await _seed_score_config(s)
|
||||
await s.commit()
|
||||
assert v1 == "v1.0"
|
||||
rows = (await s.execute(sa.select(EmployeeScoreConfig))).scalars().all()
|
||||
assert len(rows) == 1 and rows[0].status == "active"
|
||||
# 幂等:已存在同版本返回 None 不新建
|
||||
assert await _seed_score_config(s) is None
|
||||
await s.commit()
|
||||
assert len((await s.execute(sa.select(EmployeeScoreConfig))).scalars().all()) == 1
|
||||
|
||||
|
||||
# ---- 评分引擎(Task B2) ----
|
||||
|
||||
def _pm(**overrides):
|
||||
"""全零/None 默认的 PeriodMetrics 构造器(22 字段,覆盖式改)。"""
|
||||
base = {
|
||||
"user_id": "u1", "days_with_data": 0, "msg_sent": 0, "msg_recv": 0,
|
||||
"cust_threads": 0, "threads_answered": 0, "fr_sum_ms": 0,
|
||||
"fr_p50_ms": None, "fr_p90_ms": None,
|
||||
"reply_2h_count": 0, "reply_24h_count": 0,
|
||||
"active_friends": 0, "active_rooms_avg": 0.0,
|
||||
"new_customer": 0, "cooling_customer": 0, "dormant_customer": 0,
|
||||
"end_active_friends": 0, "reactivated_customer": 0,
|
||||
"call_count": 0, "call_seconds": 0, "answered_call": 0,
|
||||
"call_followup": 0,
|
||||
}
|
||||
base.update(overrides)
|
||||
return PeriodMetrics(**base)
|
||||
|
||||
|
||||
def _full_marks(**overrides):
|
||||
"""三可用维度全满分:率 1.0/fr 10min/占比 0.6/两 band 区间内,样本全 ok。"""
|
||||
full = dict(
|
||||
cust_threads=50, reply_2h_count=50, reply_24h_count=50,
|
||||
fr_p90_ms=600_000, # 10min ≤ 30min 满分线
|
||||
end_active_friends=9, cooling_customer=3, dormant_customer=3, # 9/15=0.6
|
||||
active_friends=50, # band [5,80] 内且样本 ok
|
||||
call_count=20, # band [1,200] 内
|
||||
)
|
||||
full.update(overrides)
|
||||
return _pm(**full)
|
||||
|
||||
|
||||
def test_normalize_target_three_segments():
|
||||
spec = {"type": "target", "floor": 0.40, "target": 0.85}
|
||||
assert normalize(spec, 0.20) == 0.0 # ≤floor
|
||||
assert normalize(spec, 0.40) == 0.0 # floor 处 0
|
||||
assert normalize(spec, 0.625) == 50.0 # 中点 50
|
||||
assert normalize(spec, 0.85) == 100.0 # target 处 100
|
||||
assert normalize(spec, 0.99) == 100.0 # ≥target 截断
|
||||
assert normalize(spec, None) is None # 无值≠0 分
|
||||
|
||||
|
||||
def test_normalize_reverse_ends_and_midpoint():
|
||||
spec = {"type": "reverse", "target_ms": 1_800_000, "floor_ms": 14_400_000}
|
||||
assert normalize(spec, 1_800_000) == 100.0 # 30min 满分
|
||||
assert normalize(spec, 600_000) == 100.0 # 更快也封顶 100
|
||||
assert normalize(spec, 8_100_000) == 50.0 # (30min+4h) 中点 50
|
||||
assert normalize(spec, 14_400_000) == 0.0 # 4h 0 分
|
||||
assert normalize(spec, 28_800_000) == 0.0 # 更慢截断 0
|
||||
assert normalize(spec, None) is None
|
||||
|
||||
|
||||
def test_normalize_band_three_segments():
|
||||
spec = {"type": "band", "metric": "active_friends", "low": 5, "high": 80,
|
||||
"basis": "period_distinct"}
|
||||
assert normalize(spec, 3) is None # <low 负载不足→不评分
|
||||
assert normalize(spec, 4.9) is None
|
||||
assert normalize(spec, 5) == 100.0 # 含下限
|
||||
assert normalize(spec, 80) == 100.0 # 含上限
|
||||
assert normalize(spec, 81) == 80.0 # 超上限 80 不再加
|
||||
assert normalize(spec, 500) == 80.0
|
||||
assert normalize(spec, None) is None
|
||||
|
||||
|
||||
def test_zero_threads_rates_none_not_zero():
|
||||
"""cust_threads=0 → 回复率无值(None),不是 0%;service 维度 pending。"""
|
||||
res = score_employee(DEFAULT_SCORE_CONFIG, _pm(cust_threads=0))
|
||||
for code in ("reply_2h_rate", "reply_24h_rate"):
|
||||
md = res["metrics_detail"][code]
|
||||
assert md["value"] is None
|
||||
assert md["score"] is None
|
||||
assert res["dimensions"]["service"]["status"] == "pending"
|
||||
|
||||
|
||||
def test_insufficient_sample_employee():
|
||||
"""cust_threads=5(<soft 10):service 维度 insufficient(率有值 0.0 但
|
||||
不评分);customer 期末无存量 pending;workload 样本 af=0 也不足——
|
||||
可用维度=0 → 整员工不评分。"""
|
||||
res = score_employee(DEFAULT_SCORE_CONFIG, _pm(cust_threads=5))
|
||||
assert res["dimensions"]["service"]["status"] == "insufficient"
|
||||
assert res["dimensions"]["service"]["score"] is None
|
||||
assert res["dimensions"]["customer"]["status"] == "pending"
|
||||
assert res["dimensions"]["workload"]["status"] == "insufficient"
|
||||
assert res["total_score"] is None
|
||||
assert res["grade"] is None
|
||||
assert res["score_status"] == "insufficient_sample"
|
||||
md = res["metrics_detail"]["reply_2h_rate"]
|
||||
assert md["value"] == 0.0 and md["score"] is None
|
||||
assert md["sample_size"] == 5 and md["sample_status"] == "insufficient"
|
||||
|
||||
|
||||
def test_low_confidence_still_scored():
|
||||
"""cust_threads=15 ∈ [soft10, min30):照评但逐指标 low_confidence。"""
|
||||
cur = _pm(cust_threads=15, reply_2h_count=15, reply_24h_count=15,
|
||||
fr_p90_ms=600_000)
|
||||
res = score_employee(DEFAULT_SCORE_CONFIG, cur)
|
||||
svc = res["dimensions"]["service"]
|
||||
assert svc["status"] == "scored" and svc["score"] == 100.0
|
||||
for code in ("reply_2h_rate", "reply_24h_rate", "fr_p90_ms"):
|
||||
assert svc["metrics"][code]["sample_status"] == "low_confidence"
|
||||
assert svc["metrics"][code]["score"] == 100.0
|
||||
# 仅 service 可用(25/25)→ 重归一后仍满分
|
||||
assert res["score_status"] == "scored"
|
||||
assert res["total_score"] == 100
|
||||
|
||||
|
||||
def test_full_marks_rescale_100_of_70():
|
||||
"""三可用维度(权重 30+25+15=70)全满分 → 100/70 重归一 = 100,A。"""
|
||||
res = score_employee(DEFAULT_SCORE_CONFIG, _full_marks())
|
||||
assert res["dimensions"]["customer"]["score"] == 100.0
|
||||
assert res["dimensions"]["service"]["score"] == 100.0
|
||||
assert res["dimensions"]["workload"]["score"] == 100.0
|
||||
assert res["total_score"] == 100
|
||||
assert res["grade"] == "A"
|
||||
assert res["score_status"] == "scored"
|
||||
# 部分:service 满分但 active_share=0(floor 下)→ (0+25+15)/70=57 → E
|
||||
res2 = score_employee(
|
||||
DEFAULT_SCORE_CONFIG,
|
||||
_full_marks(end_active_friends=1, cooling_customer=10,
|
||||
dormant_customer=10), # 1/21≈0.05 ≤ floor 0.20 → 0 分
|
||||
)
|
||||
assert res2["total_score"] == 57
|
||||
assert res2["grade"] == "E"
|
||||
|
||||
|
||||
def test_pending_dimensions_not_scored():
|
||||
"""action/risk 一期 pending_data:status=pending、无 metrics、不参与归一。"""
|
||||
res = score_employee(DEFAULT_SCORE_CONFIG, _full_marks())
|
||||
for key in ("action", "risk"):
|
||||
d = res["dimensions"][key]
|
||||
assert d["status"] == "pending"
|
||||
assert d["score"] is None
|
||||
assert "metrics" not in d
|
||||
scored_w = sum(d["weight"] for d in res["dimensions"].values()
|
||||
if d["status"] == "scored")
|
||||
assert scored_w == 70 # pending 的 30 权重不进分母
|
||||
|
||||
|
||||
def test_active_share_derivation():
|
||||
"""end_active 6/(6+cooling3+dormant1)=0.6 → 达 target 线 100 分。"""
|
||||
cur = _pm(cust_threads=50, end_active_friends=6,
|
||||
cooling_customer=3, dormant_customer=1)
|
||||
res = score_employee(DEFAULT_SCORE_CONFIG, cur)
|
||||
md = res["metrics_detail"]["active_share"]
|
||||
assert md["value"] == 0.6
|
||||
assert md["score"] == 100.0
|
||||
assert res["dimensions"]["customer"]["score"] == 100.0
|
||||
assert md["sample_status"] == "ok"
|
||||
|
||||
|
||||
def test_metrics_detail_four_compare_slots():
|
||||
"""value/prev/baseline/median 四对比位透出;band 明细的 median 按
|
||||
源指标名(active_friends)取 team;单位/名称来自元数据。"""
|
||||
cur = _pm(cust_threads=40, reply_2h_count=30, reply_24h_count=38,
|
||||
fr_p90_ms=600_000, end_active_friends=6, cooling_customer=3,
|
||||
dormant_customer=1, active_friends=40, call_count=10)
|
||||
prev = _pm(cust_threads=40, reply_2h_count=20, reply_24h_count=30,
|
||||
fr_p90_ms=3_600_000, end_active_friends=3, cooling_customer=6,
|
||||
dormant_customer=1, active_friends=20, call_count=5)
|
||||
base = _pm(cust_threads=40, reply_2h_count=10)
|
||||
team = {"reply_2h_rate": 0.7, "active_friends": 33.0}
|
||||
res = score_employee(DEFAULT_SCORE_CONFIG, cur, baseline=base,
|
||||
team=team, prev=prev)
|
||||
md = res["metrics_detail"]["reply_2h_rate"]
|
||||
assert md["value"] == 0.75
|
||||
assert md["prev"] == 0.5
|
||||
assert md["baseline"] == 0.25
|
||||
assert md["median"] == 0.7
|
||||
assert md["unit"] == "0-1"
|
||||
assert md["name"] == "2h回复率"
|
||||
# band 型明细键=配置 code,median 按源指标名查 team
|
||||
band = res["metrics_detail"]["active_friends_band"]
|
||||
assert band["median"] == 33.0
|
||||
assert band["unit"] == "人"
|
||||
assert res["metrics_detail"]["fr_p90_ms"]["prev"] == 3_600_000
|
||||
assert res["metrics_detail"]["active_share"]["prev"] == 0.3 # 3/(3+6+1)
|
||||
|
||||
|
||||
def test_trial_flag_from_config():
|
||||
"""试算标记透传:默认配置恒 True(v2 §0 全默认+试算)。"""
|
||||
res = score_employee(DEFAULT_SCORE_CONFIG, _pm())
|
||||
assert res["trial"] is True
|
||||
# 标记来自 config 而非写死——正式版翻 False 只需改配置
|
||||
no_trial = json.loads(json.dumps(DEFAULT_SCORE_CONFIG))
|
||||
no_trial["trial"] = False
|
||||
assert score_employee(no_trial, _pm())["trial"] is False
|
||||
|
||||
|
||||
def test_validate_config_threshold_shapes():
|
||||
"""阈值形状四查:type 合法/target floor<target/reverse 先后/band 区间。
|
||||
默认配置自身必须通过。"""
|
||||
assert validate_config(DEFAULT_SCORE_CONFIG) == []
|
||||
|
||||
def _bad(mutate):
|
||||
cfg = json.loads(json.dumps(DEFAULT_SCORE_CONFIG))
|
||||
mutate(cfg["dimensions"])
|
||||
return cfg
|
||||
|
||||
bad_type = _bad(lambda d: d["workload"]["metrics"]["call_band"]
|
||||
.update(type="linear"))
|
||||
assert any("call_band" in p and "类型非法" in p
|
||||
for p in validate_config(bad_type))
|
||||
|
||||
bad_target = _bad(lambda d: d["customer"]["metrics"]["active_share"]
|
||||
.update(floor=0.70)) # floor>target 0.60
|
||||
assert any("active_share" in p and "floor<target" in p
|
||||
for p in validate_config(bad_target))
|
||||
|
||||
bad_reverse = _bad(lambda d: d["service"]["metrics"]["fr_p90_ms"]
|
||||
.update(target_ms=20_000_000)) # > floor_ms 14.4M
|
||||
assert any("fr_p90_ms" in p and "target_ms<floor_ms" in p
|
||||
for p in validate_config(bad_reverse))
|
||||
|
||||
bad_band = _bad(lambda d: d["workload"]["metrics"]["call_band"]
|
||||
.update(low=300)) # low>high 200
|
||||
assert any("call_band" in p and "low<high" in p
|
||||
for p in validate_config(bad_band))
|
||||
|
||||
|
||||
def test_validate_config_grade_bounds_shape():
|
||||
"""grade_bounds 须含 A/B/C/D 且严格降序(B4 DB 配置加载预埋)。"""
|
||||
missing = json.loads(json.dumps(DEFAULT_SCORE_CONFIG)) # deep copy
|
||||
del missing["grade_bounds"]["A"]
|
||||
assert any("grade_bounds" in p for p in validate_config(missing))
|
||||
|
||||
non_desc = json.loads(json.dumps(DEFAULT_SCORE_CONFIG))
|
||||
non_desc["grade_bounds"].update(A=80, B=90) # A<B 非降序
|
||||
assert any("grade_bounds" in p and "严格降序" in p
|
||||
for p in validate_config(non_desc))
|
||||
|
||||
|
||||
def test_validate_config_cross_dimension_code_unique():
|
||||
"""同指标 code 出现于两维度:metrics_detail 扁平 dict 会碰撞,须报问题。"""
|
||||
dup = json.loads(json.dumps(DEFAULT_SCORE_CONFIG)) # deep copy
|
||||
# 把 customer 维的 active_share 整个抄进 service 维 → code 跨维度重复
|
||||
dup["dimensions"]["service"]["metrics"]["active_share"] = \
|
||||
dup["dimensions"]["customer"]["metrics"]["active_share"]
|
||||
assert any("active_share" in p and "重复" in p for p in validate_config(dup))
|
||||
|
||||
|
||||
def test_validate_config_source_name_unique():
|
||||
"""两个 code 指向同一源指标:comparisons 按源名建键会静默覆盖,须报问题。"""
|
||||
dup = json.loads(json.dumps(DEFAULT_SCORE_CONFIG))
|
||||
# service 里造一个不同 code 但同指向 active_friends 的 band(源名冲突)
|
||||
dup["dimensions"]["service"]["metrics"]["load_band"] = {
|
||||
"type": "band", "metric": "active_friends", "low": 1, "high": 50,
|
||||
}
|
||||
assert any("同一源指标 active_friends" in p for p in validate_config(dup))
|
||||
|
||||
|
||||
def test_validate_config_default_config_clean():
|
||||
"""默认配置自身必须全绿(空问题列表)。"""
|
||||
assert validate_config(DEFAULT_SCORE_CONFIG) == []
|
||||
@@ -1,4 +1,4 @@
|
||||
"""_analytics_tick:滚动刷新 + 增量扫描 + 异常吞掉(数据分析板块 v1, T11)。
|
||||
"""_analytics_tick:滚动刷新 + 增量扫描 + 管理域滚动段 + 异常吞掉(v1 T11;批次F F1)。
|
||||
|
||||
行为测试是底线——start() 的 job 注册断言在 test_sync_scheduler.py 的
|
||||
注册测试里同步更新(那里 spy 断言 add_job 全集)。
|
||||
@@ -15,6 +15,7 @@ from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db.models import MsgDailyStats, SensitiveWord, StatDailyStaff
|
||||
from app.storage.staff_summary import rolling_days
|
||||
from app.storage.summary import day_key
|
||||
from app.sync.scheduler import SyncScheduler
|
||||
|
||||
@@ -74,3 +75,110 @@ async def test_analytics_tick_swallows_errors(sched_engine, monkeypatch, caplog)
|
||||
with caplog.at_level(logging.ERROR, logger="app.sync.scheduler"):
|
||||
await sch._analytics_tick() # 不抛
|
||||
assert any("analytics tick failed" in r.getMessage() for r in caplog.records)
|
||||
|
||||
|
||||
async def _seed_today(sched_engine) -> int:
|
||||
"""seed 当日一条 wechat friend 会话 + 敏感词,返回 seed 用的 day 键。"""
|
||||
_, maker = sched_engine
|
||||
today = day_key(int(time.time() * 1000))
|
||||
async with maker() as s:
|
||||
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||
peer_kind="friend", peer_id="t1", day=today,
|
||||
send_cnt=1, recv_cnt=0, last_ts=1756950000000,
|
||||
last_is_send=True))
|
||||
s.add(SensitiveWord(word="私单"))
|
||||
await s.commit()
|
||||
return today
|
||||
|
||||
|
||||
async def test_tick_calls_management_chain_in_order(sched_engine, monkeypatch):
|
||||
"""两段链完整顺序:recompute_days → scan → pairs_active_in_days →
|
||||
recompute_pairs → recompute_metric_days → recompute_call_days。
|
||||
|
||||
记录桩替换全部六个上游——_analytics_tick 段内函数延迟 import,
|
||||
monkeypatch 模块属性即命中(惯例见 test_analytics_tick_swallows_errors)。
|
||||
"""
|
||||
engine, maker = sched_engine
|
||||
calls: list[str] = []
|
||||
|
||||
def rec(name: str, ret):
|
||||
async def _stub(*args, **kwargs):
|
||||
calls.append(name)
|
||||
return ret
|
||||
return _stub
|
||||
|
||||
monkeypatch.setattr("app.storage.staff_summary.recompute_days",
|
||||
rec("recompute_days", 0))
|
||||
monkeypatch.setattr("app.analytics.sensitive.scanner.run_incremental",
|
||||
rec("scan", {"scanned": 0, "hits": 0}))
|
||||
monkeypatch.setattr("app.storage.first_reply_facts.pairs_active_in_days",
|
||||
rec("pairs_active_in_days", []))
|
||||
monkeypatch.setattr("app.storage.first_reply_facts.recompute_pairs",
|
||||
rec("recompute_pairs", 0))
|
||||
monkeypatch.setattr("app.storage.employee_metrics.recompute_metric_days",
|
||||
rec("recompute_metric_days", 0))
|
||||
monkeypatch.setattr("app.storage.employee_metrics.recompute_call_days",
|
||||
rec("recompute_call_days", 0))
|
||||
|
||||
sch = SyncScheduler(engine=engine, session_maker=maker)
|
||||
await sch._analytics_tick()
|
||||
assert calls == [
|
||||
"recompute_days", "scan", # 段1
|
||||
"pairs_active_in_days", "recompute_pairs", # 段2
|
||||
"recompute_metric_days", "recompute_call_days",
|
||||
]
|
||||
|
||||
|
||||
async def test_tick_management_failure_isolated(sched_engine, monkeypatch, caplog):
|
||||
"""段2 失败隔离:recompute_metric_days 抛 RuntimeError → ①tick 不抛;
|
||||
②记 "management metrics tick failed";③段1 已提交的 StatDailyStaff
|
||||
不被段2 回滚(两段独立事务)。
|
||||
"""
|
||||
engine, maker = sched_engine
|
||||
await _seed_today(sched_engine)
|
||||
|
||||
async def _boom(*args, **kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr("app.storage.employee_metrics.recompute_metric_days", _boom)
|
||||
sch = SyncScheduler(engine=engine, session_maker=maker)
|
||||
with caplog.at_level(logging.ERROR, logger="app.sync.scheduler"):
|
||||
await sch._analytics_tick() # ①不抛
|
||||
assert any( # ②段2 独立记日志
|
||||
"management metrics tick failed" in r.getMessage() for r in caplog.records
|
||||
)
|
||||
async with maker() as s: # ③段1 结果已提交
|
||||
n = (await s.execute(
|
||||
select(func.count()).select_from(StatDailyStaff)
|
||||
)).scalar_one()
|
||||
assert n >= 1
|
||||
|
||||
|
||||
async def test_tick_management_rolling_window(sched_engine, monkeypatch):
|
||||
"""管理域三函数收到的 days == rolling_days(today, 3)(长度 3,当日+前
|
||||
两天),且与 staff 段捕获的窗口一致(两段共用函数顶提取的同一 today)。
|
||||
"""
|
||||
engine, maker = sched_engine
|
||||
got: dict[str, list[int]] = {}
|
||||
|
||||
def cap(name: str, ret):
|
||||
async def _stub(session, days, *args, **kwargs):
|
||||
got[name] = list(days)
|
||||
return ret
|
||||
return _stub
|
||||
|
||||
monkeypatch.setattr("app.storage.staff_summary.recompute_days",
|
||||
cap("staff", 0))
|
||||
monkeypatch.setattr("app.storage.first_reply_facts.pairs_active_in_days",
|
||||
cap("pairs", []))
|
||||
monkeypatch.setattr("app.storage.employee_metrics.recompute_metric_days",
|
||||
cap("wide", 0))
|
||||
monkeypatch.setattr("app.storage.employee_metrics.recompute_call_days",
|
||||
cap("call", 0))
|
||||
|
||||
sch = SyncScheduler(engine=engine, session_maker=maker)
|
||||
await sch._analytics_tick()
|
||||
|
||||
rd = rolling_days(day_key(int(time.time() * 1000)), 3)
|
||||
assert len(rd) == 3 # 当日 + 前两天
|
||||
assert got["staff"] == got["pairs"] == got["wide"] == got["call"] == rd
|
||||
|
||||
@@ -572,7 +572,12 @@ async def test_safe_page_run_full_invokes_bash_full_sync(engine, monkeypatch):
|
||||
|
||||
|
||||
async def test_safe_page_run_messages_builds_scrape_command(engine, monkeypatch):
|
||||
"""messages job = friends→wa-friends→四类消息→moments 浅扫的命令序列。"""
|
||||
"""messages job = accounts→friends→四类消息(活跃窗)→moments 浅扫的命令序列。
|
||||
|
||||
2026-09-10 调度根治:头部刷员工账号表(员工列表时间最多滞后 4h);
|
||||
消息实体只扫近 24h 活跃对话(--active-window-hours 24,防序列 15h+
|
||||
挤死 full);陈旧对话每日 full 兜底。
|
||||
"""
|
||||
sched = SyncScheduler(engine=engine, max_conversations_per_run=123)
|
||||
commands: list[list[str]] = []
|
||||
|
||||
@@ -584,22 +589,26 @@ async def test_safe_page_run_messages_builds_scrape_command(engine, monkeypatch)
|
||||
|
||||
await sched._safe_page_run("messages")
|
||||
|
||||
# 2026-08-21: messages job 是 friends→wa-friends→四类消息的命令序列,
|
||||
# 每条都调 scrape_via_page.py;2026-08-25 末尾追加 moments 浅扫
|
||||
assert len(commands) == 7
|
||||
assert len(commands) == 10
|
||||
for args in commands:
|
||||
assert args[1].endswith("scrape_via_page.py")
|
||||
|
||||
def entity_of(args: list[str]) -> str:
|
||||
return args[args.index("--entity") + 1]
|
||||
|
||||
assert entity_of(commands[0]) == "friends"
|
||||
assert entity_of(commands[1]) == "wa-friends"
|
||||
for args in commands[2:6]:
|
||||
assert entity_of(args) in (
|
||||
"messages", "wa-messages", "group-messages", "wa-group-messages",
|
||||
)
|
||||
assert args[args.index("--max-conversations") + 1] == "99999"
|
||||
# 头部:账号刷新(员工列表「数据更新/最新消息时间」来源)
|
||||
assert entity_of(commands[0]) == "accounts"
|
||||
assert entity_of(commands[1]) == "wa-accounts"
|
||||
assert entity_of(commands[2]) == "friends"
|
||||
assert entity_of(commands[3]) == "wa-friends"
|
||||
# 群消息前刷群活跃(chatroom-stats 全窗——群活跃窗判定源,2026-09-10)
|
||||
entities_seq = [entity_of(a) for a in commands]
|
||||
assert entities_seq.index("chatroom-stats") < entities_seq.index("group-messages")
|
||||
# 四类消息:24h 活跃窗(2026-09-10 根治调度堵塞)
|
||||
for args in commands:
|
||||
if entity_of(args) in ("messages", "wa-messages", "group-messages", "wa-group-messages"):
|
||||
assert args[args.index("--max-conversations") + 1] == "99999"
|
||||
assert args[args.index("--active-window-hours") + 1] == "24"
|
||||
# moments 浅扫在序列末尾:每员工最多 3 页
|
||||
moments_cmd = commands[-1]
|
||||
assert entity_of(moments_cmd) == "moments"
|
||||
|
||||
@@ -178,6 +178,14 @@ export interface GroupQuery {
|
||||
room_id?: string
|
||||
/** 群名称模糊搜索(通讯录群 tab 搜索框)。 */
|
||||
q?: string
|
||||
/** 群备注子串(微信群统计筛选)。 */
|
||||
group_remark?: string
|
||||
/** 最新消息时间段(ms,含端点)。 */
|
||||
last_start?: number
|
||||
last_end?: number
|
||||
/** 统计时段(ms,含端点)——窗内输出沟通消息数/新增/退出成员数聚合。 */
|
||||
msg_start?: number
|
||||
msg_end?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
@@ -191,6 +199,11 @@ export function getGroups(query: GroupQuery = {}): Promise<Page<Group>> {
|
||||
user_id: query.user_id,
|
||||
room_id: query.room_id,
|
||||
q: query.q,
|
||||
group_remark: query.group_remark,
|
||||
last_start: query.last_start,
|
||||
last_end: query.last_end,
|
||||
msg_start: query.msg_start,
|
||||
msg_end: query.msg_end,
|
||||
page: query.page ?? 1,
|
||||
page_size: query.page_size ?? 20,
|
||||
}),
|
||||
|
||||
@@ -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 })
|
||||
@@ -113,6 +113,19 @@ export interface Group {
|
||||
friend_count?: number | null
|
||||
/** 建群时间 ms-epoch */
|
||||
create_time?: number | null
|
||||
/** 群备注(微信群统计页;getChatroomList.remark,2026-09-09) */
|
||||
group_remark?: string | null
|
||||
/** 员工微信列(getChatroomList 同款):昵称/微信号/头像 */
|
||||
staff_nickname?: string | null
|
||||
staff_wx_alias?: string | null
|
||||
staff_avatar?: string | null
|
||||
/** 员工信息列:姓名/手机(staff 关联) */
|
||||
staff_name?: string | null
|
||||
staff_phone?: string | null
|
||||
/** 统计时段窗内聚合(仅请求带 msg_start/msg_end 时非 null) */
|
||||
window_msg_count?: number | null
|
||||
window_added?: number | null
|
||||
window_lost?: number | null
|
||||
/**
|
||||
* Verbatim upstream chatroom record (camelCase keys: chatRoomId/chatRoomName,
|
||||
* memberCount/memberIds, headImageUrl, wechatId/wechatNickName, userName/
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="anomalies"
|
||||
:row-class-name="rowClass"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<el-table-column label="级别" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.severity === 'critical'" type="danger" size="small">严重</el-tag>
|
||||
<el-tag v-else type="warning" size="small">关注</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="异常" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-title">{{ row.title }}</div>
|
||||
<div v-if="showEmployee" class="cell-emp">
|
||||
<el-link
|
||||
type="primary"
|
||||
underline="never"
|
||||
@click.stop="goEmployee(row)"
|
||||
>{{ row.employee_name || row.employee_user_id.slice(0, 8) }}</el-link>
|
||||
</div>
|
||||
<div class="cell-sub">{{ row.reason }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="当前值" width="150">
|
||||
<template #default="{ row }">
|
||||
<div>{{ formatValue(row.current_value, row.unit) }}</div>
|
||||
<div class="cell-sub">
|
||||
{{ row.baseline_value === null ? '无基线' : `基线 ${formatValue(row.baseline_value, row.unit)}` }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="样本" prop="sample_size" align="right" width="80" />
|
||||
<el-table-column label="影响" align="right" width="90">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'affected-hot': row.affected_count >= 50 }">{{ row.affected_count }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="建议动作" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="cell-sub">{{ row.suggested_action }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无异常" :image-size="60" />
|
||||
</template>
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 异常榜(批次C 驾驶舱):后端 AnomalyCard[] 平铺表——级别 tag/标题+员工行
|
||||
// (showEmployee,批次D:employee_name 链接→员工详情,.stop 防触发行下钻)
|
||||
// +原因/单位感知当前值+基线/样本/影响(≥50 红显)/建议动作。行 click 打开
|
||||
// 下钻抽屉(emit drill,抽屉自治加载);critical 整行淡红底提示优先处置。
|
||||
// 纯展示+事件,不取数。
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { formatMsDuration, formatRatio } from '@/utils/format'
|
||||
import type { AnomalyCard } from '@/api/management'
|
||||
|
||||
// showEmployee(默认 true):驾驶舱异常榜显示员工行;员工详情页复用本组件
|
||||
// 传 false(自指链接无意义)
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
anomalies: AnomalyCard[]
|
||||
loading?: boolean
|
||||
showEmployee?: boolean
|
||||
}>(),
|
||||
{ showEmployee: true },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'drill', card: AnomalyCard): void
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 员工链接:带当前 URL query(channel/start/end/compare 随行,与列表
|
||||
// goDetail 带窗进详情同语义);detail 页对白名单外 query 键不敏感
|
||||
function goEmployee(row: AnomalyCard): void {
|
||||
router.push({
|
||||
path: `/management/employees/${row.employee_user_id}`,
|
||||
query: route.query,
|
||||
})
|
||||
}
|
||||
|
||||
// 单位感知格式化:ms→时长,0-1→比率,其余千分位(与指标口径字典 unit 对齐)
|
||||
function formatValue(v: number, unit: string): string {
|
||||
if (unit === 'ms') return formatMsDuration(v)
|
||||
if (unit === '0-1') return formatRatio(v)
|
||||
return v.toLocaleString()
|
||||
}
|
||||
|
||||
function rowClass({ row }: { row: AnomalyCard }): string {
|
||||
return row.severity === 'critical'
|
||||
? 'anomaly-critical-row yk-clickable-row'
|
||||
: 'yk-clickable-row'
|
||||
}
|
||||
|
||||
function onRowClick(row: AnomalyCard): void {
|
||||
emit('drill', row)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* critical 行整行淡红底(优先处置);hover 深一档由 EP 行 hover 自带 */
|
||||
:deep(.el-table .anomaly-critical-row) {
|
||||
background-color: var(--el-color-danger-light-9);
|
||||
}
|
||||
:deep(.el-table .anomaly-critical-row:hover > td.el-table__cell) {
|
||||
background-color: var(--el-color-danger-light-8);
|
||||
}
|
||||
.cell-title {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.cell-emp {
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.cell-emp .el-link {
|
||||
font-size: 12px;
|
||||
}
|
||||
.cell-sub {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.affected-hot {
|
||||
color: var(--el-color-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<span class="data-asof">
|
||||
<el-tag v-if="asOfText" size="small" type="info" effect="plain">数据截至 {{ asOfText }}</el-tag>
|
||||
<el-tag v-else size="small" type="info">无数据</el-tag>
|
||||
<el-tag
|
||||
v-if="metricVersion"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="asof-tag"
|
||||
:title="configSource === 'db' ? '口径配置:自定义(数据库)' : '口径配置:默认'"
|
||||
>口径 {{ metricVersion }}</el-tag>
|
||||
<el-tag v-if="trial" size="small" type="warning" class="asof-tag">试算</el-tag>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 数据截至角标(批次C 驾驶舱/员工页共用):数据时间 + 指标版本 + 试算标记。
|
||||
// 纯展示——数据由父级从 dashboard/employees 载荷透传(data_as_of/metric_version/
|
||||
// trial/config_source),不自行请求。configSource 无独立标签(避免噪音),
|
||||
// 挂在口径版本 tag 的 title 提示上。
|
||||
import { computed } from 'vue'
|
||||
import { formatTime } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
dataAsOf?: number | null
|
||||
metricVersion?: string
|
||||
trial?: boolean
|
||||
configSource?: 'db' | 'default'
|
||||
}>()
|
||||
|
||||
// ms 时间戳 → `YYYY-MM-DD HH:mm`(formatTime 去秒);缺失/非法 → 灰 tag「无数据」
|
||||
const asOfText = computed(() => {
|
||||
const v = props.dataAsOf
|
||||
if (v == null || !Number.isFinite(v) || v <= 0) return ''
|
||||
return formatTime(v).slice(0, 16)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.data-asof {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.asof-tag {
|
||||
margin-left: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
:model-value="modelValue"
|
||||
title="员工对比"
|
||||
size="620px"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<!-- v1 §6.6:对比只呈现可验证差异,不产生优劣结论(管理者需被提醒) -->
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="同口径同时窗对比,仅呈现可验证差异,不产生优劣结论"
|
||||
class="cmp-alert"
|
||||
/>
|
||||
|
||||
<!-- ① 维度对比:三可用维度,每人一行横向 bar(0-100;无样本灰「--」) -->
|
||||
<div v-for="dim in DIMS" :key="dim.key" class="dim-block">
|
||||
<div class="dim-title">{{ dim.label }}</div>
|
||||
<div v-for="r in rows" :key="r.user_id" class="dim-row">
|
||||
<span class="dim-name" :title="r.user_id">{{ displayName(r) }}</span>
|
||||
<template v-if="dimScore(r, dim.key) !== null">
|
||||
<div class="dim-track">
|
||||
<div class="dim-bar" :style="{ width: `${dimScore(r, dim.key)}%` }" />
|
||||
</div>
|
||||
<span class="dim-score">{{ Math.round(dimScore(r, dim.key) ?? 0) }}</span>
|
||||
</template>
|
||||
<span v-else class="dim-na">--</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ② 关键指标表:行=指标,列=每人 -->
|
||||
<el-table :data="metricRows" size="small" border class="cmp-table">
|
||||
<el-table-column prop="label" label="指标" min-width="96" />
|
||||
<el-table-column
|
||||
v-for="r in rows"
|
||||
:key="r.user_id"
|
||||
align="right"
|
||||
min-width="88"
|
||||
>
|
||||
<template #header>
|
||||
<span :title="r.user_id">{{ displayName(r) }}</span>
|
||||
</template>
|
||||
<template #default="{ row }">{{ row.values[r.user_id] }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 员工对比抽屉(批次D):总览页勾选 2-5 人后并排看维度分与关键指标。
|
||||
// 只读展示——数据来自已选行快照(EmployeeRow),不二次请求;口径/时窗与
|
||||
// 总览页完全一致(同载荷),故顶部钉 v1 §6.6 提示:仅呈现可验证差异。
|
||||
import { computed } from 'vue'
|
||||
import type { EmployeeRow } from '@/api/management'
|
||||
import { formatMsDuration, formatRatio } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
/** 已勾选行(2-5,由父级按钮门槛保证);行是勾选时的快照,翻页/重查不影响 */
|
||||
rows: EmployeeRow[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
}>()
|
||||
|
||||
// 一期可用三维度(action/risk pending_data 不参与)
|
||||
const DIMS = [
|
||||
{ key: 'customer', label: '客户经营' },
|
||||
{ key: 'service', label: '服务质量' },
|
||||
{ key: 'workload', label: '工作负载' },
|
||||
] as const
|
||||
|
||||
function displayName(r: EmployeeRow): string {
|
||||
return r.name || r.user_id.slice(0, 8)
|
||||
}
|
||||
|
||||
function dimScore(r: EmployeeRow, key: string): number | null {
|
||||
return r.score.dimensions[key]?.score ?? null
|
||||
}
|
||||
|
||||
// metrics 取数值键(user_id 透传键是字符串,typeof 守卫掉)
|
||||
function num(r: EmployeeRow, key: string): number | null {
|
||||
const v = r.metrics[key]
|
||||
return typeof v === 'number' ? v : null
|
||||
}
|
||||
|
||||
function ratio(numV: number | null, den: number | null): string {
|
||||
if (!den) return '--' // 分母 0/null → 无率(严格区分 0%)
|
||||
return formatRatio(numV == null ? null : numV / den)
|
||||
}
|
||||
|
||||
interface CmpMetricRow {
|
||||
label: string
|
||||
values: Record<string, string>
|
||||
}
|
||||
|
||||
const metricRows = computed<CmpMetricRow[]>(() => {
|
||||
const mk = (label: string, fmt: (r: EmployeeRow) => string): CmpMetricRow => {
|
||||
const values: Record<string, string> = {}
|
||||
for (const r of props.rows) values[r.user_id] = fmt(r)
|
||||
return { label, values }
|
||||
}
|
||||
return [
|
||||
mk('活跃客户', (r) => String(num(r, 'active_friends') ?? '--')),
|
||||
mk('消息量', (r) => String((num(r, 'msg_sent') ?? 0) + (num(r, 'msg_recv') ?? 0))),
|
||||
mk('首响P90', (r) => formatMsDuration(num(r, 'fr_p90_ms'))),
|
||||
mk('2h回复率', (r) => ratio(num(r, 'reply_2h_count'), num(r, 'cust_threads'))),
|
||||
mk('异常数', (r) => String(r.anomaly_count)),
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cmp-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.dim-block {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.dim-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.dim-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.dim-name {
|
||||
width: 88px;
|
||||
flex: none;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dim-track {
|
||||
flex: 1;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-light);
|
||||
overflow: hidden;
|
||||
}
|
||||
.dim-bar {
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
.dim-score {
|
||||
width: 28px;
|
||||
flex: none;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
.dim-na {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
.cmp-table {
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="mgmt-filter-bar">
|
||||
<el-radio-group :model-value="channel" @change="onChannelChange">
|
||||
<el-radio-button v-for="c in CHANNELS" :key="c.value" :value="c.value">{{ c.label }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<el-select
|
||||
v-model="preset"
|
||||
class="preset-select"
|
||||
placeholder="近30天"
|
||||
@change="onPresetChange"
|
||||
>
|
||||
<el-option-group label="常用">
|
||||
<el-option label="今日" value="today" />
|
||||
<el-option label="昨日" value="yesterday" />
|
||||
<el-option label="近7天" value="last7" />
|
||||
<el-option label="近30天" value="last30" />
|
||||
</el-option-group>
|
||||
<el-option-group label="日历">
|
||||
<el-option label="本周" value="thisWeek" />
|
||||
<el-option label="上周" value="lastWeek" />
|
||||
<el-option label="本月" value="thisMonth" />
|
||||
<el-option label="上月" value="lastMonth" />
|
||||
</el-option-group>
|
||||
<el-option label="自定义" value="custom" />
|
||||
</el-select>
|
||||
|
||||
<!-- 自定义区间:daterange 自保证 start≤end,选完才 emit;value-format
|
||||
直接给 yyyymmdd 字符串,转 number 走 URL 真源 -->
|
||||
<el-date-picker
|
||||
v-if="preset === 'custom'"
|
||||
v-model="customRange"
|
||||
type="daterange"
|
||||
size="small"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYYMMDD"
|
||||
:clearable="false"
|
||||
style="width: 240px"
|
||||
@change="onCustomRange"
|
||||
/>
|
||||
|
||||
<div class="spacer" />
|
||||
<el-button text type="primary" @click="emit('open-defs')">指标口径</el-button>
|
||||
<slot name="aside" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 管理页统一筛选栏(批次C):渠道 + 时间预设/自定义区间 + 指标口径入口。
|
||||
// 受控组件——channel/start/end 来自父级(useManagementFilters 的 URL 真源
|
||||
// computed),变更只 emit apply(patch) 由父写回 URL,组件不实例化 composable
|
||||
// (只 import CHANNELS/presetRange 常量,避免二次 useRoute/useRouter)。
|
||||
//
|
||||
// 预设选中值:内部 ref 记用户最近选择,初始 null 显示 placeholder「近30天」,
|
||||
// 不与 URL 双向同步(URL 直接访问无 start/end 时默认即 last30,placeholder
|
||||
// 恰好如实)。非 custom 预设把 presetRange 计算出的具体日期 emit 给父——
|
||||
// URL 真源可分享/刷新,且选中项与实际窗口一致(composable 的默认(last30)
|
||||
// 无法表达 last7/本月/上月,只清 start/end 会静默回落 30 天窗)。
|
||||
import { ref } from 'vue'
|
||||
import { CHANNELS, presetRange, type Preset } from '@/composables/useManagementFilters'
|
||||
|
||||
defineProps<{
|
||||
channel: string
|
||||
start: number
|
||||
end: number
|
||||
/** 预留(对比模式选择器未上),父级透传保持 props 面完整 */
|
||||
compare?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'apply', patch: { channel?: string; start?: number; end?: number }): void
|
||||
(e: 'open-defs'): void
|
||||
}>()
|
||||
|
||||
// 用户最近选择的预设;null = 未选(placeholder 近30天)。custom 选完后保持,
|
||||
// 日期改动走 onCustomRange 增量 emit。
|
||||
const preset = ref<Preset | null>(null)
|
||||
const customRange = ref<[string, string] | null>(null)
|
||||
|
||||
function onChannelChange(v: string | number | boolean): void {
|
||||
emit('apply', { channel: String(v) })
|
||||
}
|
||||
|
||||
function onPresetChange(v: string): void {
|
||||
if (v === 'custom') return // 等 daterange 选完区间再 emit
|
||||
emit('apply', presetRange(v as Preset))
|
||||
}
|
||||
|
||||
function onCustomRange(): void {
|
||||
const r = customRange.value
|
||||
if (!r?.[0] || !r?.[1]) return // 未选完区间不请求
|
||||
emit('apply', { start: Number(r[0]), end: Number(r[1]) })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mgmt-filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.preset-select {
|
||||
width: 120px;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<el-card shadow="never" class="kpi-card" :class="{ link: !!to }" @click="onClick">
|
||||
<div class="kpi-label">{{ label }}</div>
|
||||
<div class="kpi-value" :class="{ danger }">
|
||||
{{ valueText }}<span v-if="unit && value !== null" class="kpi-unit">{{ unit }}</span>
|
||||
</div>
|
||||
<div v-if="deltaText" class="kpi-delta" :class="deltaClass">
|
||||
环比{{ deltaText }}
|
||||
</div>
|
||||
<div v-if="hint" class="kpi-hint">{{ hint }}</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 经营管理 KPI 卡:label 上小灰字 + 主值大号字(千分位/unit 后缀/danger 红)
|
||||
// + 环比行(批次D,delta prop)+ hint 下小灰字。to 存在时整卡可点
|
||||
// (hover 手型)跳转——纯展示+导航,不取数。null 值语义(无样本)显 `--`,
|
||||
// 由父级决定传 null。
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
value: number | null
|
||||
unit?: string
|
||||
hint?: string
|
||||
to?: string
|
||||
danger?: boolean
|
||||
// 较前窗环比 (cur-prev)/prev:null/undefined/NaN(前窗缺数据)不显示。
|
||||
// 只支持升好语义(升绿↓红)——本批接入三卡(活跃客户/活跃员工/消息量)
|
||||
// 均为越高越好;将来若有降好指标(如首响时长)需先加方向语义位
|
||||
delta?: number | null
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const valueText = computed(() => (props.value === null ? '--' : props.value.toLocaleString()))
|
||||
|
||||
const deltaText = computed(() => {
|
||||
if (props.delta == null || !Number.isFinite(props.delta)) return null
|
||||
const arrow = props.delta > 0 ? '↑' : props.delta < 0 ? '↓' : '→'
|
||||
return `${arrow}${(Math.abs(props.delta) * 100).toFixed(1)}%`
|
||||
})
|
||||
const deltaClass = computed(() =>
|
||||
props.delta == null || !Number.isFinite(props.delta)
|
||||
? ''
|
||||
: props.delta > 0 ? 'up' : props.delta < 0 ? 'down' : 'flat')
|
||||
|
||||
function onClick(): void {
|
||||
if (props.to) router.push(props.to)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kpi-card.link {
|
||||
cursor: pointer;
|
||||
}
|
||||
.kpi-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.kpi-value {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
margin: 6px 0 2px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.kpi-value.danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
.kpi-unit {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-left: 2px;
|
||||
}
|
||||
.kpi-delta {
|
||||
font-size: 12px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.kpi-delta.up {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
.kpi-delta.down {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
.kpi-delta.flat {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.kpi-hint {
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
:model-value="modelValue"
|
||||
title="指标口径"
|
||||
direction="rtl"
|
||||
size="480px"
|
||||
@update:model-value="onVisible"
|
||||
>
|
||||
<div v-loading="loading">
|
||||
<el-alert v-if="error" type="error" :title="error" :closable="false" class="defs-alert" />
|
||||
|
||||
<template v-if="defs">
|
||||
<!-- 三方口径差异总述(管理域 vs 现有看板) -->
|
||||
<el-alert type="info" :closable="false" show-icon class="defs-alert">
|
||||
<div class="caliber-text">{{ defs.caliber_notes }}</div>
|
||||
</el-alert>
|
||||
|
||||
<!-- 评分配置摘要:版本/名称/试算/来源 + 样本门槛/等级线 + 维度权重 -->
|
||||
<div class="cfg-card">
|
||||
<div class="cfg-head">
|
||||
<b>{{ defs.score_config.name }}</b>
|
||||
<el-tag size="small" effect="plain">v{{ defs.score_config.version }}</el-tag>
|
||||
<el-tag v-if="defs.score_config.trial" size="small" type="warning">试算</el-tag>
|
||||
<el-tag size="small" :type="defs.config_source === 'db' ? 'primary' : 'info'" effect="plain">
|
||||
{{ defs.config_source === 'db' ? '自定义配置' : '默认配置' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="cfg-row">样本门槛:{{ guardsText }}</div>
|
||||
<div class="cfg-row">等级线:{{ gradesText }}(低于 D 线为 E)</div>
|
||||
<el-table :data="dims" size="small" class="dims-table">
|
||||
<el-table-column prop="name" label="维度" min-width="110" />
|
||||
<el-table-column label="权重" width="70" align="right">
|
||||
<template #default="{ row }">{{ row.weight }}%</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.status === 'pending_data'" size="small" type="info">待数据</el-tag>
|
||||
<el-tag v-else size="small" type="success">可用</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 指标定义:19 项平铺(端点不按维度分组),按 code 字母序逐项展示 -->
|
||||
<div v-for="[code, d] in defsSorted" :key="code" class="def-item">
|
||||
<div class="def-head">
|
||||
<b>{{ d.name }}</b>
|
||||
<code class="def-code">{{ code }}</code>
|
||||
<span class="def-unit">{{ d.unit }}</span>
|
||||
<el-tag size="small" :type="d.direction === 1 ? 'success' : 'warning'">
|
||||
{{ d.direction === 1 ? '越大越好' : '越小越好' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<pre v-if="d.formula" class="def-formula">{{ d.formula }}</pre>
|
||||
<div v-if="d.caliber" class="def-caliber">{{ d.caliber }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 指标口径抽屉(批次C):口径字典 + 当前评分配置。打开时拉取
|
||||
// /management/metric-definitions 并缓存一次(口径变化走配置版本,会话内
|
||||
// 不重拉)。definitions 为平铺 19 项(dimensions 只有名称/权重),不做按
|
||||
// 维度分组,按 code 字母序逐项列:名称+单位+方向 tag+formula+caliber。
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { getMetricDefs, type MetricDefsResp } from '@/api/management'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const defs = ref<MetricDefsResp | null>(null)
|
||||
let loaded = false // 缓存一次:首次打开拉取,后续打开直接用
|
||||
|
||||
// 打开时取数;成功后缓存(口径变化走配置版本,会话内不重拉),
|
||||
// 失败(无 defs 且有 error)则重开抽屉时再试一次
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(visible) => {
|
||||
if (visible && (!loaded || (!defs.value && error.value))) load()
|
||||
},
|
||||
)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loaded = true // 请求已发出即视为已加载,失败不反复自动重试
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
defs.value = await getMetricDefs()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onVisible(v: boolean): void {
|
||||
emit('update:modelValue', v)
|
||||
}
|
||||
|
||||
// 平铺定义按 code 字母序(中文环境 code 恒为 ASCII,localeCompare 稳定)
|
||||
const defsSorted = computed(() =>
|
||||
Object.entries(defs.value?.definitions ?? {}).sort(([a], [b]) => a.localeCompare(b)),
|
||||
)
|
||||
|
||||
// 配置维度行(仅名称/权重/状态)
|
||||
const dims = computed(() =>
|
||||
Object.entries(defs.value?.score_config.dimensions ?? {}).map(([key, d]) => ({
|
||||
key,
|
||||
name: d.name,
|
||||
weight: d.weight,
|
||||
status: d.status,
|
||||
})),
|
||||
)
|
||||
|
||||
const guardsText = computed(() => {
|
||||
const g = defs.value?.score_config.sample_guards ?? {}
|
||||
return Object.entries(g).map(([k, v]) => `${k} ≥ ${v}`).join(' · ') || '--'
|
||||
})
|
||||
|
||||
const gradesText = computed(() => {
|
||||
const gb = defs.value?.score_config.grade_bounds ?? {}
|
||||
return Object.entries(gb).map(([g, v]) => `${g} ≥ ${v}`).join(' · ') || '--'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.defs-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.caliber-text {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: normal;
|
||||
}
|
||||
.cfg-card {
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.cfg-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.cfg-row {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.dims-table {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.def-item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
.def-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.def-code {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
background: var(--el-fill-color-light);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.def-unit {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.def-formula {
|
||||
margin: 6px 0 0;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.def-caliber {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,369 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
:model-value="modelValue"
|
||||
:title="card?.title ?? '指标下钻'"
|
||||
direction="rtl"
|
||||
size="760px"
|
||||
@update:model-value="onVisible"
|
||||
>
|
||||
<!-- 无 metric_code 或 drilldown.type==='employee':员工维度异常,下钻
|
||||
明细属批次D 员工分析页——先给原因/建议动作兜底 -->
|
||||
<template v-if="unsupported">
|
||||
<el-empty description="员工分析页批次D上线,此类异常先看建议动作" :image-size="80" />
|
||||
<div v-if="card" class="fallback">
|
||||
<div class="fallback-label">原因</div>
|
||||
<div class="fallback-text">{{ card.reason }}</div>
|
||||
<div class="fallback-label">建议动作</div>
|
||||
<div class="fallback-text">{{ card.suggested_action }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<el-alert v-if="error" type="error" :title="error" :closable="false" class="drill-alert" />
|
||||
<el-alert v-if="resp?.note" type="info" :title="resp.note" :closable="false" class="drill-alert" />
|
||||
<el-alert
|
||||
v-if="resp?.truncated"
|
||||
type="warning"
|
||||
title="候选超500,列表可能不全"
|
||||
:closable="false"
|
||||
class="drill-alert"
|
||||
/>
|
||||
|
||||
<!-- call_followup:期聚合摘要(期通话/接通/跟进) -->
|
||||
<div v-if="kind === 'calls' && resp?.summary" class="call-summary">
|
||||
<div class="sum-item">
|
||||
<span class="sum-num">{{ resp.summary.call_count ?? 0 }}</span>
|
||||
<span class="sum-label">期通话</span>
|
||||
</div>
|
||||
<div class="sum-item">
|
||||
<span class="sum-num">{{ resp.summary.answered_call_count ?? 0 }}</span>
|
||||
<span class="sum-label">接通</span>
|
||||
</div>
|
||||
<div class="sum-item">
|
||||
<span class="sum-num">{{ resp.summary.call_followup_count ?? 0 }}</span>
|
||||
<span class="sum-label">跟进</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 首响分位:逐串证据行(后端已按 fr_ms desc),行 click 跳聊天 -->
|
||||
<el-table
|
||||
v-if="kind === 'first_reply'"
|
||||
v-loading="loading"
|
||||
:data="frRows"
|
||||
:row-class-name="clickableRow"
|
||||
@row-click="onFrRowClick"
|
||||
>
|
||||
<el-table-column prop="peer_id" label="客户" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="渠道" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.channel === 'whatsapp' ? 'success' : 'primary'">
|
||||
{{ chTag(row.channel) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="串末条时间" width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.thread_end_ts) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="回复时间" width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.reply_ts) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="首响时长" align="right" min-width="110">
|
||||
<template #default="{ row }">{{ formatMsDuration(row.fr_ms) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 未回串:等待中的客户,行 click 跳聊天(带 msgid 定位最后一条客户消息) -->
|
||||
<el-table
|
||||
v-else-if="kind === 'unreplied'"
|
||||
v-loading="loading"
|
||||
:data="unrepliedRows"
|
||||
:row-class-name="clickableRow"
|
||||
@row-click="onUnrepliedRowClick"
|
||||
>
|
||||
<el-table-column prop="peer_id" label="客户" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="渠道" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.channel === 'whatsapp' ? 'success' : 'primary'">
|
||||
{{ chTag(row.channel) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="等待时长" width="140">
|
||||
<template #default="{ row }">{{ formatMsDuration(row.wait_ms) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预览" min-width="220" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.preview ?? '' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 降温/沉睡客户名单。lifecycle 行没有 owner_account_id(互动史按
|
||||
(channel,peer) 跨 owner 合并,归属不定)——不提供跳转,批次D 员工
|
||||
分析页补员工账号维度的直达入口 -->
|
||||
<el-table v-else-if="kind === 'lifecycle'" v-loading="loading" :data="lifecycleRows">
|
||||
<el-table-column prop="nickname" label="客户" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="渠道" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.channel === 'whatsapp' ? 'success' : 'primary'">
|
||||
{{ chTag(row.channel) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后互动日" width="120">
|
||||
<template #default="{ row }">{{ fmtDay(row.last_day) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.state === 'dormant'" size="small" type="danger">沉睡</el-tag>
|
||||
<el-tag v-else-if="row.state === 'cooling'" size="small" type="warning">降温</el-tag>
|
||||
<el-tag v-else size="small" type="info">{{ row.state }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 通话跟进:窗口通话列表(通话无渠道过滤维度,后端恒全渠道) -->
|
||||
<el-table v-else-if="kind === 'calls'" v-loading="loading" :data="callRows">
|
||||
<el-table-column label="时间" width="180">
|
||||
<template #default="{ row }">{{ formatTime(row.start_call_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="120">
|
||||
<template #default="{ row }">{{ callTypeLabel(row.channel) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="号码" min-width="140">
|
||||
<template #default="{ row }">{{ row.call_number ?? '--' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时长(秒)" align="right" min-width="100">
|
||||
<template #default="{ row }">{{ formatCallDuration(row.call_duration) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
:current-page="page"
|
||||
:page-size="PAGE_SIZE"
|
||||
:total="resp?.total ?? 0"
|
||||
layout="total, prev, pager, next"
|
||||
class="pager"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 指标下钻抽屉(批次C):点异常卡按 metric_code 调 drilldown 白名单端点,
|
||||
// 四种明细形态——首响逐串证据/未回串/降温·沉睡名单/通话跟进。抽屉自治
|
||||
// 加载(打开即取数,后端分页);员工维度异常(drilldown.type==='employee')
|
||||
// 无 metric_code,给批次D 占位 + 原因/建议动作兜底。行 click 跳聊天:
|
||||
// WA→ChatTimeline(/chat/whatsapp/{owner}/{peer}),微信→员工聊天窗 single
|
||||
// tab(/staff-chat/{owner}/single/{peer},项目惯例路由名跳转,路径等价)。
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
getEmployeeDrilldown,
|
||||
type AnomalyCard,
|
||||
type DrilldownCode,
|
||||
type DrilldownResp,
|
||||
type MgmtChannel,
|
||||
} from '@/api/management'
|
||||
import { callTypeLabel, formatCallDuration, formatMsDuration, formatTime } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
userId: string
|
||||
card: AnomalyCard | null
|
||||
start: number
|
||||
end: number
|
||||
channel: MgmtChannel
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
const page = ref(1)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const resp = ref<DrilldownResp | null>(null)
|
||||
|
||||
let seq = 0 // 竞态守卫:快速换卡/翻页时旧响应作废(项目惯例)
|
||||
|
||||
// drilldown 白名单(与后端 _DRILLDOWN_CODES 一致;白名单外按不支持兜底)
|
||||
const DRILL_CODES: readonly string[] = [
|
||||
'first_reply_p50', 'first_reply_p90', 'unreplied',
|
||||
'cooling_customers', 'dormant_customers', 'call_followup',
|
||||
]
|
||||
|
||||
const metricCode = computed<DrilldownCode | null>(() => {
|
||||
const c = props.card?.drilldown.query.metric_code
|
||||
return c && DRILL_CODES.includes(c) ? (c as DrilldownCode) : null
|
||||
})
|
||||
|
||||
// 员工维度异常(无 metric_code / type==='employee')→ 批次D 占位兜底
|
||||
const unsupported = computed(
|
||||
() => !metricCode.value || props.card?.drilldown.type === 'employee',
|
||||
)
|
||||
|
||||
type Kind = 'first_reply' | 'unreplied' | 'lifecycle' | 'calls'
|
||||
const kind = computed<Kind | null>(() => {
|
||||
switch (metricCode.value) {
|
||||
case 'first_reply_p50':
|
||||
case 'first_reply_p90':
|
||||
return 'first_reply'
|
||||
case 'unreplied':
|
||||
return 'unreplied'
|
||||
case 'cooling_customers':
|
||||
case 'dormant_customers':
|
||||
return 'lifecycle'
|
||||
case 'call_followup':
|
||||
return 'calls'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
// 打开(或换卡重开)即取数;start/end/channel 变化由父级在抽屉关闭时发生,
|
||||
// 下次打开经 visible 变化自然重查
|
||||
watch(
|
||||
() => [props.modelValue, props.card] as const,
|
||||
([visible]) => {
|
||||
if (visible && !unsupported.value) {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (!metricCode.value) return
|
||||
const my = ++seq
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const r = await getEmployeeDrilldown(
|
||||
props.userId, metricCode.value, props.start, props.end,
|
||||
props.channel, page.value, PAGE_SIZE,
|
||||
)
|
||||
if (my !== seq) return
|
||||
resp.value = r
|
||||
} catch (e) {
|
||||
if (my === seq) error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
// 过期请求不清 loading(新一轮还在飞)也不覆盖 error
|
||||
if (my === seq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 分页翻页:vue-tsc strict 不推 inline-arrow 参数,单列包装(项目惯例)
|
||||
function onPageChange(p: number): void {
|
||||
page.value = p
|
||||
load()
|
||||
}
|
||||
|
||||
function onVisible(v: boolean): void {
|
||||
emit('update:modelValue', v)
|
||||
}
|
||||
|
||||
// ---- 各分支行类型(items 是 Record<string,unknown>[],按白名单端点形状收窄) ----
|
||||
|
||||
type FrRow = {
|
||||
day: number; channel: string; owner_account_id: string; peer_id: string
|
||||
thread_end_ts: number; reply_ts: number; fr_ms: number
|
||||
}
|
||||
type UnrepliedRow = {
|
||||
peer_id: string; channel: string; owner_account_id: string
|
||||
last_ts: number; wait_ms: number; preview?: string; msg_id?: string
|
||||
}
|
||||
type LifecycleRow = { friend_id: string; channel: string; nickname: string; last_day: number; state: string }
|
||||
type CallRow = { call_id: string; channel: string; call_number: string | null; start_call_time: number; call_duration: number }
|
||||
|
||||
const frRows = computed<FrRow[]>(() =>
|
||||
kind.value === 'first_reply' ? (resp.value?.items ?? []) as FrRow[] : [])
|
||||
const unrepliedRows = computed<UnrepliedRow[]>(() =>
|
||||
kind.value === 'unreplied' ? (resp.value?.items ?? []) as UnrepliedRow[] : [])
|
||||
const lifecycleRows = computed<LifecycleRow[]>(() =>
|
||||
kind.value === 'lifecycle' ? (resp.value?.items ?? []) as LifecycleRow[] : [])
|
||||
const callRows = computed<CallRow[]>(() =>
|
||||
kind.value === 'calls' ? (resp.value?.items ?? []) as CallRow[] : [])
|
||||
|
||||
// ---- 展示辅助 ----
|
||||
|
||||
// 渠道短标签:异常下钻列窄,微信/WA 两字(全称 channelLabel 留给宽列页面)
|
||||
const CHANNEL_TAG: Record<string, string> = { wechat: '微信', whatsapp: 'WA' }
|
||||
const chTag = (c: string): string => CHANNEL_TAG[c] ?? c
|
||||
|
||||
// yyyymmdd(20260810)→ 2026-08-10:最后互动日跨月/年,保留完整年份
|
||||
const fmtDay = (d: number): string =>
|
||||
`${String(d).slice(0, 4)}-${String(d).slice(4, 6)}-${String(d).slice(6, 8)}`
|
||||
|
||||
// ---- 行点击跳聊天 ----
|
||||
|
||||
function goChat(owner: string, peer: string, channel: string, msgId?: string): void {
|
||||
if (channel === 'whatsapp') {
|
||||
router.push({ name: 'chat', params: { channel: 'whatsapp', ownerId: owner, talkerId: peer } })
|
||||
return
|
||||
}
|
||||
router.push({
|
||||
name: 'staff-chat',
|
||||
params: { ownerId: owner, tab: 'single', talkerId: peer },
|
||||
query: msgId ? { msgid: msgId } : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// owner 理论恒非空(fact/未回清单均来自 owner 维度查询),防御判空不亏
|
||||
function clickableRow({ row }: { row: { owner_account_id?: string } }): string {
|
||||
return row.owner_account_id ? 'yk-clickable-row' : ''
|
||||
}
|
||||
|
||||
function onFrRowClick(row: FrRow): void {
|
||||
goChat(row.owner_account_id, row.peer_id, row.channel)
|
||||
}
|
||||
|
||||
// 微信跳转带 ?msgid= 定位最后一条客户消息(同 UnrepliedView 惯例)
|
||||
function onUnrepliedRowClick(row: UnrepliedRow): void {
|
||||
if (!row.owner_account_id) return
|
||||
goChat(row.owner_account_id, row.peer_id, row.channel, row.msg_id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drill-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.call-summary {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.sum-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.sum-num {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sum-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.fallback {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.fallback-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin: 12px 0 4px;
|
||||
}
|
||||
.fallback-text {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.pager {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
// useManagementFilters 单测(vitest)。本文件不进 app 构建/类型检查
|
||||
// (tsconfig.app.json exclude __tests__,vitest 自行解析类型),
|
||||
// `npm run test` 执行。
|
||||
//
|
||||
// composable 依赖 vue-router 注入,仓库无先例;node 环境(无 DOM),
|
||||
// 选用 createApp + app.use(router) + app.runWithContext() 提供真实
|
||||
// route/router 注入,不 mock、不挂载组件。
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createApp } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { presetRange, useManagementFilters } from '../useManagementFilters'
|
||||
|
||||
/** 建一个带 memory 路由的 filters 实例;query 为初始 URL query。 */
|
||||
async function setup(query: Record<string, string> = {}) {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', component: { render: () => null } }],
|
||||
})
|
||||
const app = createApp({ render: () => null })
|
||||
app.use(router)
|
||||
await router.push({ path: '/', query })
|
||||
const filters = app.runWithContext(() => useManagementFilters())
|
||||
return { router, filters }
|
||||
}
|
||||
|
||||
const D = (ymdNum: number) => new Date(Math.floor(ymdNum / 10000), (Math.floor(ymdNum / 100) % 100) - 1, ymdNum % 100)
|
||||
|
||||
describe('presetRange(注入 today,确定性日期)', () => {
|
||||
it('last7 → 含今天共 7 天(2026-09-10 → 20260904..20260910)', () => {
|
||||
expect(presetRange('last7', D(20260910))).toEqual({ start: 20260904, end: 20260910 })
|
||||
})
|
||||
|
||||
it('last30 → 30 天窗(2026-09-10 → 20260812..20260910)', () => {
|
||||
expect(presetRange('last30', D(20260910))).toEqual({ start: 20260812, end: 20260910 })
|
||||
})
|
||||
|
||||
it('thisMonth → 月初到今天(2026-09-10 → 20260901..20260910)', () => {
|
||||
expect(presetRange('thisMonth', D(20260910))).toEqual({ start: 20260901, end: 20260910 })
|
||||
})
|
||||
|
||||
it('lastMonth → 上月整月(2026-08-15 → 20260701..20260731,31 天月)', () => {
|
||||
expect(presetRange('lastMonth', D(20260815))).toEqual({ start: 20260701, end: 20260731 })
|
||||
})
|
||||
|
||||
it('lastMonth 30 天月(2026-07-15 → 20260601..20260630)', () => {
|
||||
expect(presetRange('lastMonth', D(20260715))).toEqual({ start: 20260601, end: 20260630 })
|
||||
})
|
||||
|
||||
it('lastMonth 跨年(2026-01-15 → 20251201..20251231)', () => {
|
||||
expect(presetRange('lastMonth', D(20260115))).toEqual({ start: 20251201, end: 20251231 })
|
||||
})
|
||||
|
||||
it('today → 今天单日(2026-09-10 → 20260910..20260910)', () => {
|
||||
expect(presetRange('today', D(20260910))).toEqual({ start: 20260910, end: 20260910 })
|
||||
})
|
||||
|
||||
it('yesterday → 昨日单日(2026-09-10 → 20260909..20260909)', () => {
|
||||
expect(presetRange('yesterday', D(20260910))).toEqual({ start: 20260909, end: 20260909 })
|
||||
})
|
||||
|
||||
it('thisWeek 周中(2026-07-02 周四 → 周一到今天 20260629..20260702)', () => {
|
||||
expect(presetRange('thisWeek', D(20260702))).toEqual({ start: 20260629, end: 20260702 })
|
||||
})
|
||||
|
||||
it('thisWeek 周一当天 → 单日(2026-06-29 周一 → 20260629..20260629)', () => {
|
||||
expect(presetRange('thisWeek', D(20260629))).toEqual({ start: 20260629, end: 20260629 })
|
||||
})
|
||||
|
||||
it('lastWeek 跨月边界(2026-07-02 周四 → 上周一..上周日 20260622..20260628)', () => {
|
||||
expect(presetRange('lastWeek', D(20260702))).toEqual({ start: 20260622, end: 20260628 })
|
||||
})
|
||||
|
||||
it('custom → 今天单日兜底(2026-09-10 → 20260910..20260910)', () => {
|
||||
expect(presetRange('custom', D(20260910))).toEqual({ start: 20260910, end: 20260910 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useManagementFilters(URL query 为真源)', () => {
|
||||
it('空 query → channel=all / start,end=last30 默认 / compare=previous', async () => {
|
||||
const { filters } = await setup()
|
||||
expect(filters.channel.value).toBe('all')
|
||||
expect(filters.compare.value).toBe('previous')
|
||||
// 默认值与 presetRange('last30')(真实今天)同源,同日执行必一致
|
||||
expect(filters.start.value).toBe(presetRange('last30').start)
|
||||
expect(filters.end.value).toBe(presetRange('last30').end)
|
||||
})
|
||||
|
||||
it('合法 query → 原样读出(wechat / 日期 / team_median)', async () => {
|
||||
const { filters } = await setup({
|
||||
channel: 'wechat',
|
||||
start: '20250101',
|
||||
end: '20250131',
|
||||
compare: 'team_median',
|
||||
})
|
||||
expect(filters.channel.value).toBe('wechat')
|
||||
expect(filters.start.value).toBe(20250101)
|
||||
expect(filters.end.value).toBe(20250131)
|
||||
expect(filters.compare.value).toBe('team_median')
|
||||
})
|
||||
|
||||
it('非法 query 值 → 回退默认(channel=xx / start=abc / end=19990101 早于下限 / compare=foo)', async () => {
|
||||
const { filters } = await setup({ channel: 'xx', start: 'abc', end: '19990101', compare: 'foo' })
|
||||
expect(filters.channel.value).toBe('all')
|
||||
expect(filters.compare.value).toBe('previous')
|
||||
expect(filters.start.value).toBe(presetRange('last30').start)
|
||||
expect(filters.end.value).toBe(presetRange('last30').end)
|
||||
})
|
||||
|
||||
it('apply({channel}) → query 更新且其余参数保留', async () => {
|
||||
const { router, filters } = await setup({ compare: 'team_median' })
|
||||
filters.apply({ channel: 'whatsapp' })
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.query.channel).toBe('whatsapp'))
|
||||
expect(filters.channel.value).toBe('whatsapp')
|
||||
// 未 patch 的 compare 原样保留
|
||||
expect(router.currentRoute.value.query.compare).toBe('team_median')
|
||||
expect(filters.compare.value).toBe('team_median')
|
||||
})
|
||||
|
||||
it('applyPreset(预设) → 清掉 query 里的 start/end,默认值(仍为 last30)生效', async () => {
|
||||
const { router, filters } = await setup({ start: '20250101', end: '20250131', channel: 'wechat' })
|
||||
filters.applyPreset('last7')
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.query.start).toBeUndefined())
|
||||
expect(router.currentRoute.value.query.end).toBeUndefined()
|
||||
// channel 不在清理范围,保留
|
||||
expect(filters.channel.value).toBe('wechat')
|
||||
// 清掉后回退默认(last30,与真实今天同源)
|
||||
expect(filters.start.value).toBe(presetRange('last30').start)
|
||||
expect(filters.end.value).toBe(presetRange('last30').end)
|
||||
})
|
||||
|
||||
it("applyPreset('custom', {...}) → query 带具体日期", async () => {
|
||||
const { router, filters } = await setup()
|
||||
filters.applyPreset('custom', { start: 20260101, end: 20260131 })
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.query.start).toBe('20260101'))
|
||||
expect(router.currentRoute.value.query.end).toBe('20260131')
|
||||
expect(filters.start.value).toBe(20260101)
|
||||
expect(filters.end.value).toBe(20260131)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
// 管理页统一筛选(URL query 为真源——刷新/分享保持)。批次C:驾驶舱用;
|
||||
// 批次D 员工页复用(view 等额外参数由页面自行挂 query)。
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
export type Preset = 'today' | 'yesterday' | 'last7' | 'last30' | 'thisWeek' | 'lastWeek' | 'thisMonth' | 'lastMonth' | 'custom'
|
||||
export const CHANNELS = [
|
||||
{ value: 'all', label: '全部渠道' },
|
||||
{ value: 'wechat', label: '微信' },
|
||||
{ value: 'whatsapp', label: 'WhatsApp' },
|
||||
] as const
|
||||
|
||||
function ymd(d: Date): number {
|
||||
return d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate()
|
||||
}
|
||||
function addDays(d: Date, n: number): Date {
|
||||
const x = new Date(d)
|
||||
x.setDate(x.getDate() + n)
|
||||
return x
|
||||
}
|
||||
|
||||
/** 预设 → [start,end] yyyymmdd。 */
|
||||
export function presetRange(p: Preset, today = new Date()): { start: number; end: number } {
|
||||
const t0 = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||||
// 周一为一周起点(getDay 周日=0 → 偏移成周一=0..周日=6)
|
||||
const dow = (t0.getDay() + 6) % 7
|
||||
switch (p) {
|
||||
case 'today':
|
||||
return { start: ymd(t0), end: ymd(t0) }
|
||||
case 'yesterday':
|
||||
return { start: ymd(addDays(t0, -1)), end: ymd(addDays(t0, -1)) }
|
||||
case 'thisWeek':
|
||||
// 周一..今天;周一当天即单日
|
||||
return { start: ymd(addDays(t0, -dow)), end: ymd(t0) }
|
||||
case 'lastWeek': {
|
||||
const monday = addDays(t0, -dow - 7)
|
||||
return { start: ymd(monday), end: ymd(addDays(monday, 6)) }
|
||||
}
|
||||
case 'last7':
|
||||
return { start: ymd(addDays(t0, -6)), end: ymd(t0) }
|
||||
case 'last30':
|
||||
return { start: ymd(addDays(t0, -29)), end: ymd(t0) }
|
||||
case 'thisMonth':
|
||||
return { start: ymd(new Date(t0.getFullYear(), t0.getMonth(), 1)), end: ymd(t0) }
|
||||
case 'lastMonth': {
|
||||
const first = new Date(t0.getFullYear(), t0.getMonth() - 1, 1)
|
||||
const last = new Date(t0.getFullYear(), t0.getMonth(), 0)
|
||||
return { start: ymd(first), end: ymd(last) }
|
||||
}
|
||||
}
|
||||
// custom/未知:今天单日
|
||||
return { start: ymd(t0), end: ymd(t0) }
|
||||
}
|
||||
|
||||
export function useManagementFilters() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const channel = computed<'all' | 'wechat' | 'whatsapp'>(() => {
|
||||
const v = route.query.channel
|
||||
return v === 'wechat' || v === 'whatsapp' ? v : 'all'
|
||||
})
|
||||
const start = computed(() => {
|
||||
const v = Number(route.query.start)
|
||||
return Number.isFinite(v) && v >= 20000101 && v <= 99991231 ? v : presetRange('last30').start
|
||||
})
|
||||
const end = computed(() => {
|
||||
const v = Number(route.query.end)
|
||||
return Number.isFinite(v) && v >= 20000101 && v <= 99991231 ? v : presetRange('last30').end
|
||||
})
|
||||
const compare = computed(() => {
|
||||
const v = route.query.compare
|
||||
return v === 'personal_baseline' || v === 'team_median' ? v : 'previous'
|
||||
})
|
||||
|
||||
function apply(patch: Record<string, string | number | undefined>) {
|
||||
const q: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries({ ...route.query, ...patch })) {
|
||||
if (v !== undefined && v !== null && v !== '') q[k] = String(v)
|
||||
}
|
||||
router.replace({ query: q })
|
||||
}
|
||||
|
||||
// 预设切换:清掉 start/end 让默认值生效,custom 则带具体日期
|
||||
function applyPreset(p: Preset, custom?: { start: number; end: number }) {
|
||||
if (p === 'custom' && custom) apply({ start: custom.start, end: custom.end })
|
||||
else apply({ start: undefined, end: undefined })
|
||||
}
|
||||
|
||||
return { channel, start, end, compare, apply, applyPreset, presetRange }
|
||||
}
|
||||
@@ -11,9 +11,11 @@ const channelStore = useChannelStore()
|
||||
|
||||
// `el-menu` with `router` mode; we drive `default-active` from the current path
|
||||
// so the active item highlights on load and on browser back/forward.
|
||||
// 带参路由(路径与菜单 index 不等)按 route.name 映射父菜单路径。
|
||||
const activeMenu = computed(() => {
|
||||
if (route.name === 'chat') return '/customers'
|
||||
if (route.name === 'group-chat') return '/groups'
|
||||
if (route.name === 'management-employees-detail') return '/management/employees'
|
||||
return route.path
|
||||
})
|
||||
|
||||
@@ -99,6 +101,19 @@ function logout(): void {
|
||||
全局搜索
|
||||
</el-menu-item>
|
||||
|
||||
<!-- 经营管理 -->
|
||||
<el-sub-menu index="management">
|
||||
<template #title>
|
||||
<span class="yk-sidenav__group">经营管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/management/dashboard" @click="goPlain('/management/dashboard')">
|
||||
管理驾驶舱
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/management/employees" @click="goPlain('/management/employees')">
|
||||
员工分析
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 数据分析 -->
|
||||
<el-sub-menu index="analytics">
|
||||
<template #title>
|
||||
|
||||
@@ -92,6 +92,31 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/analytics/SensitiveView.vue'),
|
||||
meta: { title: '敏感词' },
|
||||
},
|
||||
{
|
||||
// 经营管理板块(批次C):驾驶舱;员工分析/考核快照批次D/E再加
|
||||
path: '/management/dashboard',
|
||||
name: 'management-dashboard',
|
||||
component: () => import('@/views/management/ManagementDashboardView.vue'),
|
||||
meta: { title: '管理驾驶舱' },
|
||||
},
|
||||
{
|
||||
// 员工分析(批次D):四视图总览;详情路由 /management/employees/{user_id}
|
||||
// 由批次D后续任务补挂(D2)
|
||||
path: '/management/employees',
|
||||
name: 'management-employees',
|
||||
component: () => import('@/views/management/EmployeeAnalyticsView.vue'),
|
||||
meta: { title: '员工分析' },
|
||||
},
|
||||
{
|
||||
// 员工详情(批次D2):总览页姓名下钻;query 真源 channel/start/end/section
|
||||
// (section=当前页签,刷新回到该页签)。静态路由 /management/employees
|
||||
// 先注册,带参路由在后——vue-router 按注册序精确匹配,静态优先命中。
|
||||
path: '/management/employees/:userId',
|
||||
name: 'management-employees-detail',
|
||||
component: () => import('@/views/management/EmployeeAnalyticsDetailView.vue'),
|
||||
props: true,
|
||||
meta: { title: '员工分析详情' },
|
||||
},
|
||||
{
|
||||
path: '/chat/:channel/:ownerId/:talkerId?',
|
||||
name: 'chat',
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
* ],
|
||||
* filename: '微信客户列表',
|
||||
* })
|
||||
*
|
||||
* Optional `preamble: string[]` writes comment lines (口径/窗口注释等) before
|
||||
* the header — each line occupies the first column of its own row.
|
||||
*/
|
||||
|
||||
export interface CsvColumn<T> {
|
||||
@@ -30,6 +33,12 @@ export interface ExportOptions<T> {
|
||||
pageSize?: number
|
||||
/** Max rows to export (safety cap, default 10000) */
|
||||
maxRows?: number
|
||||
/**
|
||||
* Optional comment lines written before the header row (batch D: management
|
||||
* employee CSV carries caliber/window notes so the export is self-describing
|
||||
* outside the app). Each line becomes a single-cell row in the first column.
|
||||
*/
|
||||
preamble?: string[]
|
||||
}
|
||||
|
||||
function escapeCsv(value: string | number | null | undefined): string {
|
||||
@@ -43,7 +52,7 @@ function escapeCsv(value: string | number | null | undefined): string {
|
||||
}
|
||||
|
||||
export async function exportListToCsv<T>(options: ExportOptions<T>): Promise<{ rows: number; filename: string }> {
|
||||
const { fetchPage, columns, filename, pageSize = 100, maxRows = 10000 } = options
|
||||
const { fetchPage, columns, filename, pageSize = 100, maxRows = 10000, preamble } = options
|
||||
|
||||
// Fetch all pages
|
||||
const allRows: T[] = []
|
||||
@@ -68,7 +77,7 @@ export async function exportListToCsv<T>(options: ExportOptions<T>): Promise<{ r
|
||||
return escapeCsv(val as string | number | null | undefined)
|
||||
}).join(',')
|
||||
)
|
||||
const csv = '' + header + '\n' + lines.join('\n') // BOM for Excel Chinese support
|
||||
const csv = '' + [...(preamble ?? []).map((p) => escapeCsv(p)), header, ...lines].join('\n') // BOM for Excel Chinese support
|
||||
|
||||
// Trigger download
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||
|
||||
@@ -55,6 +55,28 @@ export function formatDuration(seconds?: number | null): string {
|
||||
return `${m}'${String(rem).padStart(2, '0')}"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 经营管理板块共享格式化(批次C)——自 DashboardView 本地 fmtFr/fmtPct 提升,
|
||||
// 驾驶舱 KPI / 异常榜 / 下钻抽屉与旧看板共用同一展示口径。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 毫秒时长 → `<1h 显「X分Y秒」/ ≥1h 显「X小时Y分」`;null(无样本)显 `-`。
|
||||
* 与 formatDuration(秒,聊天气泡口径)区分:此处是指标口径(首响等)。
|
||||
*/
|
||||
export function formatMsDuration(ms?: number | null): string {
|
||||
if (ms == null) return '-'
|
||||
const totalSec = Math.round(ms / 1000)
|
||||
const mins = Math.floor(totalSec / 60)
|
||||
if (mins < 60) return `${mins}分${totalSec % 60}秒`
|
||||
return `${Math.floor(mins / 60)}小时${mins % 60}分`
|
||||
}
|
||||
|
||||
/** 比率(0-1) → 百分比 1 位小数(回复率/接通率等);null 显 `-`。 */
|
||||
export function formatRatio(r?: number | null): string {
|
||||
return r == null ? '-' : `${(r * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
/** Friendly label for a Channel value. */
|
||||
export function channelLabel(channel?: string | null): string {
|
||||
switch (channel) {
|
||||
|
||||
@@ -77,7 +77,7 @@ export const GROUP_FIELD_GROUPS: GroupFieldGroup[] = [
|
||||
},
|
||||
{
|
||||
title: '成员',
|
||||
keys: ['memberCount', 'memberIds'],
|
||||
keys: ['memberCount'], // memberIds 原始串由群详情成员墙替代(2026-09-10)
|
||||
},
|
||||
{
|
||||
title: '归属微信与群主',
|
||||
@@ -105,7 +105,11 @@ export const GROUP_FIELD_GROUPS: GroupFieldGroup[] = [
|
||||
]
|
||||
|
||||
/** Every key already surfaced via a group (so 其他 can skip duplicates). */
|
||||
const KNOWN_KEYS = new Set(GROUP_FIELD_GROUPS.flatMap((g) => g.keys))
|
||||
const KNOWN_KEYS = new Set([
|
||||
...GROUP_FIELD_GROUPS.flatMap((g) => g.keys),
|
||||
// 原始 wxid 串由群详情成员墙替代,不在任何区(含「其他」)显示(2026-09-10)
|
||||
'memberIds',
|
||||
])
|
||||
|
||||
/** Resolve the 中文 label for a raw_json key (falls back to the key). */
|
||||
export function fieldLabel(key: string): string {
|
||||
|
||||
@@ -53,6 +53,13 @@ const channelTyped = computed(() => props.channel as Channel)
|
||||
/** True when this timeline is a group chat (drives the query + title). */
|
||||
const isGroup = computed(() => !!props.roomId)
|
||||
|
||||
/**
|
||||
* 专注模式(?bare=1):群统计等列表入口的「查看详情」——隐藏会话侧栏,
|
||||
* 对齐云客群详情页(无好友/群列表,纯聊天记录窗)。员工聊天窗口入口
|
||||
* (不带 bare)侧栏常驻(2026-09-09 修复保持)。
|
||||
*/
|
||||
const isBare = computed(() => route.query.bare === '1')
|
||||
|
||||
/** 当前会话类型(高亮 sidebar + 驱动空态)。账号级入口(无 talkerId/roomId)→ null。 */
|
||||
const activeType = computed<'friend' | 'group' | null>(() =>
|
||||
isGroup.value ? 'group' : props.talkerId ? 'friend' : null,
|
||||
@@ -344,8 +351,11 @@ onMounted(() => {
|
||||
<div class="yk-chat__body">
|
||||
<!-- 侧栏常驻(含群聊路由):好友/群列表随时可切,对齐云客聊天窗体验。
|
||||
旧版 v-if="!isGroup" 在群路由下藏侧栏,点群后「好友列表/群列表」
|
||||
消失(2026-09-09 用户报);onSelectSession 本就双类型导航。 -->
|
||||
消失(2026-09-09 用户报);onSelectSession 本就双类型导航。
|
||||
bare 模式(群统计「查看详情」入口)例外——纯聊天记录窗无侧栏,
|
||||
对齐云客群详情页(2026-09-10 用户反馈)。 -->
|
||||
<ChatSidebar
|
||||
v-if="!isBare"
|
||||
:channel="channelTyped"
|
||||
:owner-id="props.ownerId"
|
||||
:active-type="activeType"
|
||||
|
||||
+228
-56
@@ -3,14 +3,12 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Download } from '@element-plus/icons-vue'
|
||||
import { getGroups } from '@/api'
|
||||
import { downloadBlob } from '@/api/client'
|
||||
import type { Channel, Group } from '@/api/types'
|
||||
import { getAccounts, getGroupMembers, getGroups } from '@/api'
|
||||
import type { Account, Channel, Group, GroupMember } from '@/api/types'
|
||||
import { useChannelStore } from '@/stores/channel'
|
||||
import { channelLabel, formatTime } from '@/utils/format'
|
||||
import { exportListToCsv } from '@/utils/csvExport'
|
||||
import { buildGroupSections, readField } from '@/utils/groupFields'
|
||||
import { fromTypeLabel } from '@/utils/customerFields'
|
||||
|
||||
/**
|
||||
* 群列表 — channel-tabbed view mirroring 云客「沟通」板块的群列表.
|
||||
@@ -84,10 +82,6 @@ function cellNum(row: Group, camelKey: string, fallback = 0): number {
|
||||
const v = cell(row, camelKey)
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : fallback
|
||||
}
|
||||
/** 数据来源列:fromType 代码→文案(复用 customerFields.fromTypeLabel)。 */
|
||||
function fromTypeText(row: Group): string {
|
||||
return fromTypeLabel(cell(row, 'fromType'))
|
||||
}
|
||||
function imageCell(row: Group, camelKey: string): string | undefined {
|
||||
const v = cell(row, camelKey)
|
||||
return typeof v === 'string' && v !== '' ? v : undefined
|
||||
@@ -108,7 +102,16 @@ async function loadTab(ch: TabKey): Promise<void> {
|
||||
channel: ch,
|
||||
page: st.page,
|
||||
page_size: st.pageSize,
|
||||
owner_account_id: ownerId.value ?? undefined,
|
||||
owner_account_id: ch === 'wechat' ? (staffOwner.value || ownerId.value) || undefined : ownerId.value ?? undefined,
|
||||
// 微信群统计筛选(云客「微信群统计」同款,2026-09-09)
|
||||
...(ch === 'wechat'
|
||||
? {
|
||||
q: gName.value.trim() || undefined,
|
||||
group_remark: gRemark.value.trim() || undefined,
|
||||
...(lastRange.value ? { last_start: dayStart(lastRange.value[0]), last_end: dayEnd(lastRange.value[1]) } : {}),
|
||||
...(statRange.value ? { msg_start: dayStart(statRange.value[0]), msg_end: dayEnd(statRange.value[1]) } : {}),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
st.items = res.items
|
||||
st.total = res.total
|
||||
@@ -120,6 +123,47 @@ async function loadTab(ch: TabKey): Promise<void> {
|
||||
st.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 微信群统计筛选(云客同款:统计时段/最新消息时间段/群名称/群备注/员工) ----
|
||||
function defaultStatRange(): [Date, Date] {
|
||||
// 云客默认近 7 天(截图 09-03→09-09)
|
||||
const end = new Date()
|
||||
const start = new Date(end.getTime() - 6 * 86_400_000)
|
||||
return [start, end]
|
||||
}
|
||||
const statRange = ref<[Date, Date] | null>(defaultStatRange())
|
||||
const lastRange = ref<[Date, Date] | null>(null)
|
||||
const gName = ref('')
|
||||
const gRemark = ref('')
|
||||
/** 员工筛选(下拉;入口 query 的 owner_account_id 作初值,选择后接管)。 */
|
||||
const staffOwner = ref<string>(ownerId.value ?? '')
|
||||
const staffOptions = ref<Account[]>([])
|
||||
|
||||
function dayStart(d: Date): number {
|
||||
const x = new Date(d)
|
||||
x.setHours(0, 0, 0, 0)
|
||||
return x.getTime()
|
||||
}
|
||||
function dayEnd(d: Date): number {
|
||||
const x = new Date(d)
|
||||
x.setHours(23, 59, 59, 999)
|
||||
return x.getTime()
|
||||
}
|
||||
|
||||
async function loadStaffOptions(): Promise<void> {
|
||||
try {
|
||||
const res = await getAccounts({ channel: 'wechat', page: 1, page_size: 100 })
|
||||
staffOptions.value = res.items
|
||||
if (ownerId.value && !staffOwner.value) staffOwner.value = ownerId.value
|
||||
} catch {
|
||||
// 静默降级:员工筛选为空
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch(): void {
|
||||
tabState.wechat.page = 1
|
||||
loadTab('wechat')
|
||||
}
|
||||
function handlePageChange(ch: TabKey, p: number): void {
|
||||
tabState[ch].page = p
|
||||
loadTab(ch)
|
||||
@@ -145,6 +189,9 @@ function viewChat(row: Group): void {
|
||||
router.push({
|
||||
name: 'group-chat',
|
||||
params: { channel: row.channel, ownerId: row.owner_account_id, roomId: row.room_id },
|
||||
// bare=专注模式:无会话侧栏,对齐云客群详情页(2026-09-10)。
|
||||
// 员工聊天窗口入口不带 bare,侧栏常驻。
|
||||
query: row.channel === 'wechat' ? { bare: '1' } : undefined,
|
||||
})
|
||||
}
|
||||
/** drawer 内「查看聊天记录」入口:drawerGroup 是 ref<Group|null>,模板嵌套不保证窄化,用 wrapper 守卫。 */
|
||||
@@ -154,28 +201,6 @@ function viewChatFromDrawer(): void {
|
||||
viewChat(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信群「导出HTML聊天记录」→ 后端 /api/export/chat-html(room_id)。
|
||||
* 云客群列表导出按钮直接导出,无时间选择 modal(仅微信客户有「请选择时间」)。
|
||||
* WA 群 tab 无此操作(云客 WA 群列表无导出)。
|
||||
*/
|
||||
async function exportGroupHtml(row: Group): Promise<void> {
|
||||
if (!row.owner_account_id || !row.room_id) {
|
||||
ElMessage.warning('该群缺少归属账号/群标识,无法导出')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await downloadBlob('/export/chat-html', {
|
||||
channel: row.channel,
|
||||
owner_account_id: row.owner_account_id,
|
||||
room_id: row.room_id,
|
||||
})
|
||||
ElMessage.success('导出已开始')
|
||||
} catch (e) {
|
||||
ElMessage.error(`导出失败,请稍后重试:${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- header 导出:当前 tab 渠道全量群 CSV(含当前员工筛选) ----
|
||||
const exporting = ref(false)
|
||||
|
||||
@@ -222,9 +247,35 @@ function clearOwnerFilter(): void {
|
||||
}
|
||||
|
||||
// ---- drawer ----
|
||||
// 群成员墙(2026-09-10):点开群详情拉 members,渲染头像+昵称(消息派生群昵称
|
||||
// 优先,customer 好友昵称兜底,都没有回退 wxid)。
|
||||
const drawerMembers = ref<GroupMember[]>([])
|
||||
const membersLoading = ref(false)
|
||||
|
||||
async function loadDrawerMembers(roomId: string): Promise<void> {
|
||||
drawerMembers.value = []
|
||||
membersLoading.value = true
|
||||
try {
|
||||
drawerMembers.value = await getGroupMembers(roomId)
|
||||
} catch {
|
||||
drawerMembers.value = [] // 静默:成员区空态
|
||||
} finally {
|
||||
membersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function memberLabel(m: GroupMember): string {
|
||||
return m.nickname || m.display_name || m.member_id
|
||||
}
|
||||
|
||||
function openDrawer(row: Group): void {
|
||||
drawerGroup.value = row
|
||||
drawerOpen.value = true
|
||||
if (row.channel === 'wechat') {
|
||||
loadDrawerMembers(row.room_id)
|
||||
} else {
|
||||
drawerMembers.value = []
|
||||
}
|
||||
}
|
||||
const drawerSections = computed(() =>
|
||||
drawerGroup.value ? buildGroupSections(drawerGroup.value) : [],
|
||||
@@ -262,6 +313,7 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
loadStaffOptions()
|
||||
loadTab(activeTab.value)
|
||||
})
|
||||
</script>
|
||||
@@ -287,9 +339,52 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
微信群 — 7 列,对齐云客 wechatdetail/2/{wxId}.
|
||||
微信群统计 — 对齐云客「沟通→微信号管理→微信群统计」
|
||||
(/cms/wechat/wechatgroup,2026-09-09 真机对齐)。
|
||||
统计时段窗内:沟通消息数=本地日汇总;新增/退出成员数=按天进出表
|
||||
求和(上线日起积累,历史日期为 0)。
|
||||
============================================================ -->
|
||||
<template v-if="activeTab === 'wechat'">
|
||||
<div class="yk-groups__statfilters">
|
||||
<div class="yk-groups__filterrow">
|
||||
<span class="yk-groups__flabel">数据统计时段:</span>
|
||||
<el-date-picker
|
||||
v-model="statRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
range-separator="→"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
size="small"
|
||||
style="width: 260px"
|
||||
/>
|
||||
<span class="yk-groups__flabel" style="margin-left: 16px">最新消息时间段:</span>
|
||||
<el-date-picker
|
||||
v-model="lastRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
range-separator="→"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
size="small"
|
||||
style="width: 260px"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="yk-groups__filterrow">
|
||||
<el-input v-model="gName" placeholder="请输入群名称" size="small" clearable style="width: 180px" @keyup.enter="onSearch" />
|
||||
<el-input v-model="gRemark" placeholder="请输入群备注" size="small" clearable style="width: 180px" @keyup.enter="onSearch" />
|
||||
<el-select v-model="staffOwner" placeholder="员工微信昵称" size="small" clearable filterable style="width: 200px">
|
||||
<el-option
|
||||
v-for="a in staffOptions"
|
||||
:key="a.account_id"
|
||||
:label="a.nickname || a.user_name || a.account_id"
|
||||
:value="a.account_id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button type="primary" size="small" @click="onSearch">查 询</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="tabState.wechat.errorMsg"
|
||||
:title="tabState.wechat.errorMsg"
|
||||
@@ -309,40 +404,55 @@ onMounted(() => {
|
||||
empty-text="暂无微信群数据"
|
||||
@row-click="openDrawer"
|
||||
>
|
||||
<el-table-column label="群信息" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="yk-staffinfo__nick" :title="cellText(row, 'chatRoomName')">{{ cellText(row, 'chatRoomName') }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数据来源" min-width="110">
|
||||
<template #default="{ row }">{{ fromTypeText(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="群主信息" min-width="180">
|
||||
<el-table-column label="群信息" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="yk-group-owner">
|
||||
<el-avatar :size="36" :src="imageCell(row, 'ownerImage') || undefined" shape="circle">
|
||||
{{ (cellText(row, 'ownerName') || '?').slice(0, 1) }}
|
||||
<el-avatar :size="34" :src="row.head_image_url || undefined" shape="circle">
|
||||
{{ (row.room_name || '?').slice(0, 1) }}
|
||||
</el-avatar>
|
||||
<div class="yk-group-owner__meta">
|
||||
<div class="yk-staffinfo__nick">{{ cellText(row, 'ownerName') }}</div>
|
||||
<div class="yk-staffinfo__muted">{{ cellText(row, 'realName') }}</div>
|
||||
<div class="yk-staffinfo__nick">{{ row.room_name || '--' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="群成员数量" width="110" align="right">
|
||||
<template #default="{ row }">{{ cellNum(row, 'memberCount') }}</template>
|
||||
<el-table-column label="群备注" min-width="110">
|
||||
<template #default="{ row }">{{ row.group_remark || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="群成员好友数量" width="130" align="right">
|
||||
<template #default="{ row }">{{ cellNum(row, 'friendCount') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="消息数量" width="100" align="right">
|
||||
<template #default="{ row }">{{ cellNum(row, 'messageCount') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right" align="center">
|
||||
<el-table-column label="员工微信" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click.stop="viewChat(row)">查看聊天记录</el-button>
|
||||
<el-button link type="primary" size="small" @click.stop="exportGroupHtml(row)">导出HTML聊天记录</el-button>
|
||||
<div class="yk-group-owner">
|
||||
<el-avatar :size="34" :src="row.staff_avatar || undefined" shape="square">
|
||||
{{ (row.staff_nickname || '?').slice(0, 1) }}
|
||||
</el-avatar>
|
||||
<div class="yk-group-owner__meta">
|
||||
<div class="yk-staffinfo__nick">{{ row.staff_nickname || '--' }}</div>
|
||||
<div class="yk-staffinfo__muted">{{ row.staff_wx_alias || row.owner_account_id || '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="员工信息" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="yk-staffinfo__nick">{{ row.staff_name || '--' }}</div>
|
||||
<div class="yk-staffinfo__muted">{{ row.staff_phone || '' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="群成员数量" width="105" align="right">
|
||||
<template #default="{ row }">{{ row.member_count ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="新增成员数" width="105" align="right">
|
||||
<template #default="{ row }">{{ row.window_added ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="退出成员数" width="105" align="right">
|
||||
<template #default="{ row }">{{ row.window_lost ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="沟通消息数" width="105" align="right">
|
||||
<template #default="{ row }">{{ row.window_msg_count ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="110" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click.stop="viewChat(row)">查看详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -465,6 +575,26 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 群成员墙:头像+昵称(群昵称>好友昵称兜底>wxid),替代原始 memberIds 串 -->
|
||||
<div v-if="drawerGroup.channel === 'wechat'" class="yk-drawer__section">
|
||||
<el-divider content-position="left">
|
||||
群成员({{ drawerMembers.length || (drawerGroup.member_count ?? 0) }})
|
||||
</el-divider>
|
||||
<div v-loading="membersLoading" class="yk-members">
|
||||
<div v-if="!membersLoading && drawerMembers.length === 0" class="yk-members__empty">
|
||||
暂无成员数据(群消息派生,发过言/是本司好友的成员可识别)
|
||||
</div>
|
||||
<div v-for="m in drawerMembers" :key="m.member_id" class="yk-members__item">
|
||||
<el-avatar :size="34" :src="m.avatar || undefined">
|
||||
{{ memberLabel(m).slice(0, 1) }}
|
||||
</el-avatar>
|
||||
<div class="yk-members__meta">
|
||||
<div class="yk-members__name" :title="memberLabel(m)">{{ memberLabel(m) }}</div>
|
||||
<div v-if="memberLabel(m) !== m.member_id" class="yk-members__id">{{ m.member_id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="section in drawerSections" :key="section.title" class="yk-drawer__section">
|
||||
<el-divider content-position="left">{{ section.title }}</el-divider>
|
||||
<dl class="yk-drawer__kv">
|
||||
@@ -513,4 +643,46 @@ onMounted(() => {
|
||||
.yk-group-owner__meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 群成员墙:头像+昵称网格(2026-09-10) */
|
||||
.yk-members {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
min-height: 48px;
|
||||
}
|
||||
.yk-members__empty {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.yk-members__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.yk-members__meta {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.yk-members__name {
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.yk-members__id {
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -143,16 +143,16 @@
|
||||
<template #default="{ row }">{{ fmt(row.first_contact_cnt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="首响" align="right">
|
||||
<template #default="{ row }">{{ fmtFr(row.fr_avg_ms) }}</template>
|
||||
<template #default="{ row }">{{ formatMsDuration(row.fr_avg_ms) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="回复率" align="right">
|
||||
<template #default="{ row }">{{ fmtPct(row.reply_rate) }}</template>
|
||||
<template #default="{ row }">{{ formatRatio(row.reply_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="通话时长" align="right">
|
||||
<template #default="{ row }">{{ fmtDur(row.call_seconds) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="接通率" align="right">
|
||||
<template #default="{ row }">{{ fmtPct(row.answer_rate) }}</template>
|
||||
<template #default="{ row }">{{ formatRatio(row.answer_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="删粉/拉黑" align="right">
|
||||
<template #default="{ row }">{{ fmtDelBlocked(row) }}</template>
|
||||
@@ -187,7 +187,7 @@ import {
|
||||
type StaffRankingRow,
|
||||
} from '@/api/analytics'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
import { formatTimeShort } from '@/utils/format'
|
||||
import { formatMsDuration, formatRatio, formatTimeShort } from '@/utils/format'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -295,16 +295,8 @@ const fmt = (n: number | undefined | null) => (n ?? 0).toLocaleString()
|
||||
const assets = computed<CustomerAssets | null>(() => data.value?.customer_assets ?? null)
|
||||
|
||||
// ── 排行新列格式化 ──
|
||||
// 首响:<1h 显「X分Y秒」,≥1h 显「X小时Y分」;null(无样本)显 '-'
|
||||
const fmtFr = (ms: number | null) => {
|
||||
if (ms == null) return '-'
|
||||
const totalSec = Math.round(ms / 1000)
|
||||
const mins = Math.floor(totalSec / 60)
|
||||
if (mins < 60) return `${mins}分${totalSec % 60}秒`
|
||||
return `${Math.floor(mins / 60)}小时${mins % 60}分`
|
||||
}
|
||||
// 百分比 1 位小数(回复率/接通率);null 显 '-'
|
||||
const fmtPct = (v: number | null) => (v == null ? '-' : `${(v * 100).toFixed(1)}%`)
|
||||
// 首响/百分比用共享版(formatMsDuration/formatRatio,utils/format——批次C
|
||||
// 驾驶舱组件同口径,原本地 fmtFr/fmtPct 已提升去重)
|
||||
// 通话时长(秒):<1h 显「X分」,否则「X小时Y分」;0/null 显 '-'
|
||||
const fmtDur = (s: number | null) => {
|
||||
if (!s) return '-'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,726 @@
|
||||
<template>
|
||||
<div class="emp-analytics">
|
||||
<!-- 筛选栏:渠道/时间(URL 真源)+口径入口;aside=对比模式选择器。
|
||||
compare 仅客户端显示态:employees 行恒带四对比(current/prev/
|
||||
baseline/median),后端 employees 端点也无 compare 入参 -->
|
||||
<ManagementFilterBar
|
||||
:channel="channel"
|
||||
:start="start"
|
||||
:end="end"
|
||||
:compare="compare"
|
||||
@apply="apply"
|
||||
@open-defs="defsVisible = true"
|
||||
>
|
||||
<template #aside>
|
||||
<el-select
|
||||
:model-value="compare"
|
||||
class="compare-select"
|
||||
@change="onCompareChange"
|
||||
>
|
||||
<el-option label="对比:上期" value="previous" />
|
||||
<el-option label="对比:个人基线" value="personal_baseline" />
|
||||
<el-option label="对比:团队中位数" value="team_median" />
|
||||
</el-select>
|
||||
</template>
|
||||
</ManagementFilterBar>
|
||||
|
||||
<!-- 工具行:关键词(防抖)/在职状态/四视图;口径入口在筛选栏自带 -->
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="keywordInput"
|
||||
placeholder="姓名/ID"
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
class="kw-input"
|
||||
/>
|
||||
<el-select :model-value="status" class="status-select" @change="onStatusChange">
|
||||
<el-option label="全部" value="all" />
|
||||
<el-option label="在职" value="on_job" />
|
||||
<el-option label="离线" value="offline" />
|
||||
</el-select>
|
||||
<el-radio-group :model-value="view" @change="onViewChange">
|
||||
<el-radio-button value="management">管理概览</el-radio-button>
|
||||
<el-radio-button value="workload">工作负载</el-radio-button>
|
||||
<el-radio-button value="service">服务质量</el-radio-button>
|
||||
<el-radio-button value="customer">客户经营</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div class="spacer" />
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="selectedRows.length < 2 || selectedRows.length > 5"
|
||||
@click="compareVisible = true"
|
||||
>对比已选 ({{ selectedRows.length }})</el-button>
|
||||
<el-button :loading="exporting" @click="exportCsv">导出CSV</el-button>
|
||||
<!-- trial 常显:载荷无页面级 trial 键,批次C/D 评分处于试算期(同驾驶舱) -->
|
||||
<DataAsOfBadge
|
||||
:data-as-of="resp?.data_as_of ?? null"
|
||||
:metric-version="resp?.metric_version"
|
||||
:trial="true"
|
||||
:config-source="resp?.config_source"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 错误态:alert+重试;resp 不清空,保留上次成功数据 -->
|
||||
<el-alert v-if="loadError" type="error" :closable="false" class="top-alert">
|
||||
<template #title>
|
||||
<span>{{ loadError }}</span>
|
||||
<el-button size="small" type="primary" class="retry-btn" @click="load">重试</el-button>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<!-- 主表:服务端排序/分页(sortable=custom,数据恒随 URL 查询);
|
||||
列组随视图切换;多选(跨页保留)供对比抽屉。非排序列头 hover 提示,
|
||||
关键列头「?」点击开指标口径抽屉 -->
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
v-loading="loading"
|
||||
:data="rows"
|
||||
row-key="user_id"
|
||||
:default-sort="defaultSort"
|
||||
@selection-change="onSelectionChange"
|
||||
@sort-change="onSortChange"
|
||||
>
|
||||
<el-table-column type="selection" width="42" reserve-selection />
|
||||
<el-table-column label="姓名" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<!-- 带窗进详情:goDetail 随行 channel/start/end/compare(D3) -->
|
||||
<el-link type="primary" underline="never" @click="goDetail(row)">
|
||||
{{ row.name || row.user_id }}
|
||||
</el-link>
|
||||
<div class="uid">{{ row.user_id }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column width="64">
|
||||
<template #header>
|
||||
<el-tooltip content="该列不支持服务端排序" placement="top"><span>在职</span></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.is_on_job === true" size="small" type="success">在职</el-tag>
|
||||
<el-tag v-else-if="row.is_on_job === false" size="small" type="info">离职</el-tag>
|
||||
<span v-else class="dim">--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- ── 管理概览:评分/异常/规模/维度 ── -->
|
||||
<template v-if="view === 'management'">
|
||||
<el-table-column
|
||||
prop="total_score"
|
||||
width="120"
|
||||
sortable="custom"
|
||||
:sort-orders="['descending', 'ascending']"
|
||||
>
|
||||
<template #header>
|
||||
<span>综合分
|
||||
<el-tooltip content="查看指标口径" placement="top">
|
||||
<el-icon class="hd-q" @click.stop="defsVisible = true"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<el-tooltip
|
||||
v-if="row.score.score_status === 'insufficient_sample'"
|
||||
content="样本不足·不参与考核"
|
||||
placement="top"
|
||||
>
|
||||
<span class="dim">--</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="score-cell">
|
||||
{{ Math.round(row.score.total_score ?? 0) }}
|
||||
<el-tag
|
||||
v-if="row.score.grade"
|
||||
size="small"
|
||||
:type="gradeType(row.score.grade)"
|
||||
class="grade-tag"
|
||||
>{{ row.score.grade }}</el-tag>
|
||||
<span v-if="row.score.trial" class="trial-mark" title="试算期评分">试</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="anomaly_severity"
|
||||
label="异常"
|
||||
width="90"
|
||||
sortable="custom"
|
||||
:sort-orders="['descending', 'ascending']"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<span :class="row.anomaly_count > 0 ? 'anomaly-hot' : 'dim'">{{ row.anomaly_count }}</span>
|
||||
<div v-if="row.anomaly_count > 0" class="cell-sub">权重 {{ row.anomaly_weight }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="active_friends"
|
||||
width="100"
|
||||
sortable="custom"
|
||||
:sort-orders="['descending', 'ascending']"
|
||||
>
|
||||
<template #header>
|
||||
<span>活跃客户
|
||||
<el-tooltip content="查看指标口径" placement="top">
|
||||
<el-icon class="hd-q" @click.stop="defsVisible = true"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<template #default="{ row }">{{ fmtInt(num(row, 'active_friends')) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column min-width="100">
|
||||
<template #header>
|
||||
<el-tooltip content="该列不支持服务端排序" placement="top"><span>消息量</span></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">{{ fmtInt(msgTotal(row)) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column min-width="110">
|
||||
<template #header>
|
||||
<el-tooltip content="该列不支持服务端排序" placement="top"><span>串/被回</span></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
{{ fmtInt(num(row, 'cust_threads')) }} / {{ fmtInt(num(row, 'threads_answered')) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column min-width="90">
|
||||
<template #header>
|
||||
<el-tooltip content="该列不支持服务端排序" placement="top"><span>客户经营</span></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<span :class="dimScore(row, 'customer') == null ? 'dim' : ''">{{ dimScoreText(row, 'customer') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column min-width="90">
|
||||
<template #header>
|
||||
<el-tooltip content="该列不支持服务端排序" placement="top"><span>服务质量</span></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<span :class="dimScore(row, 'service') == null ? 'dim' : ''">{{ dimScoreText(row, 'service') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column min-width="90">
|
||||
<template #header>
|
||||
<el-tooltip content="该列不支持服务端排序" placement="top"><span>工作负载</span></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<span :class="dimScore(row, 'workload') == null ? 'dim' : ''">{{ dimScoreText(row, 'workload') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
|
||||
<!-- ── 工作负载:规模/消息/通话/串 ── -->
|
||||
<template v-else-if="view === 'workload'">
|
||||
<el-table-column
|
||||
prop="active_friends"
|
||||
width="100"
|
||||
sortable="custom"
|
||||
:sort-orders="['descending', 'ascending']"
|
||||
>
|
||||
<template #header>
|
||||
<span>活跃客户
|
||||
<el-tooltip content="查看指标口径" placement="top">
|
||||
<el-icon class="hd-q" @click.stop="defsVisible = true"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<template #default="{ row }">{{ fmtInt(num(row, 'active_friends')) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column min-width="100">
|
||||
<template #header>
|
||||
<el-tooltip content="该列不支持服务端排序" placement="top"><span>活跃群(日均)</span></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">{{ fmtAvg(num(row, 'active_rooms_avg')) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column min-width="130">
|
||||
<template #header>
|
||||
<el-tooltip content="该列不支持服务端排序" placement="top"><span>消息发/收</span></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
{{ fmtInt(num(row, 'msg_sent')) }} / {{ fmtInt(num(row, 'msg_recv')) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column min-width="130">
|
||||
<template #header>
|
||||
<el-tooltip content="该列不支持服务端排序" placement="top"><span>通话量/时长</span></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
{{ fmtInt(num(row, 'call_count')) }} / {{ fmtSec(num(row, 'call_seconds')) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="cust_threads"
|
||||
label="串"
|
||||
width="80"
|
||||
sortable="custom"
|
||||
:sort-orders="['descending', 'ascending']"
|
||||
>
|
||||
<template #default="{ row }">{{ fmtInt(num(row, 'cust_threads')) }}</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
|
||||
<!-- ── 服务质量:首响分位/回复率/被回串 ── -->
|
||||
<template v-else-if="view === 'service'">
|
||||
<el-table-column min-width="110">
|
||||
<template #header>
|
||||
<el-tooltip content="该列不支持服务端排序" placement="top"><span>首响P50</span></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">{{ formatMsDuration(num(row, 'fr_p50_ms')) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column min-width="110">
|
||||
<template #header>
|
||||
<span>
|
||||
<el-tooltip content="该列不支持服务端排序" placement="top"><span>首响P90</span></el-tooltip>
|
||||
<el-tooltip content="查看指标口径" placement="top">
|
||||
<el-icon class="hd-q" @click.stop="defsVisible = true"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<template #default="{ row }">{{ formatMsDuration(num(row, 'fr_p90_ms')) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column min-width="100">
|
||||
<template #header>
|
||||
<el-tooltip content="该列不支持服务端排序" placement="top"><span>2h回复率</span></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">{{ replyRate(row, 'reply_2h_count') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column min-width="110">
|
||||
<template #header>
|
||||
<el-tooltip content="该列不支持服务端排序" placement="top"><span>24h回复率</span></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">{{ replyRate(row, 'reply_24h_count') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column min-width="110">
|
||||
<template #header>
|
||||
<el-tooltip content="该列不支持服务端排序" placement="top"><span>被回/串</span></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
{{ fmtInt(num(row, 'threads_answered')) }} / {{ fmtInt(num(row, 'cust_threads')) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
|
||||
<!-- ── 客户经营:生命周期四态+活跃占比 ── -->
|
||||
<template v-else>
|
||||
<el-table-column min-width="80">
|
||||
<template #header>
|
||||
<el-tooltip content="该列不支持服务端排序" placement="top"><span>新客</span></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">{{ fmtInt(num(row, 'new_customer')) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column min-width="80">
|
||||
<template #header>
|
||||
<el-tooltip content="该列不支持服务端排序" placement="top"><span>降温</span></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">{{ fmtInt(num(row, 'cooling_customer')) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column min-width="100">
|
||||
<template #header>
|
||||
<span>
|
||||
<el-tooltip content="该列不支持服务端排序" placement="top"><span>沉睡(期末)</span></el-tooltip>
|
||||
<el-tooltip content="查看指标口径" placement="top">
|
||||
<el-icon class="hd-q" @click.stop="defsVisible = true"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<template #default="{ row }">{{ fmtInt(num(row, 'dormant_customer')) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column min-width="100">
|
||||
<template #header>
|
||||
<el-tooltip content="该列不支持服务端排序" placement="top"><span>回流(期内)</span></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">{{ fmtInt(num(row, 'reactivated_customer')) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column min-width="100">
|
||||
<template #header>
|
||||
<el-tooltip content="该列不支持服务端排序" placement="top"><span>活跃占比</span></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">{{ activeShare(row) }}</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<el-empty :image-size="72" description="无匹配员工——调整关键词/状态" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
:current-page="page"
|
||||
:page-size="PAGE_SIZE"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
class="pager"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
|
||||
<!-- 对比抽屉(2-5 行)与指标口径抽屉(自缓存) -->
|
||||
<EmployeeCompareDrawer v-model="compareVisible" :rows="selectedRows" />
|
||||
<MetricDefsDrawer v-model="defsVisible" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 员工总览(经营管理批次D):四视图列组共用 /management/employees 行集,
|
||||
// 服务端排序(仅 综合分/异常/活跃客户/串 四列)+ 服务端分页,query 全量
|
||||
// 真源(view/keyword/status/sort_by/sort_order/page),刷新/分享保持。
|
||||
// 多选 2-5 人开对比抽屉;CSV 导出当前视图列并带口径注释前置行。
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, type TableInstance } from 'element-plus'
|
||||
// QuestionFilled 全局已注册(main.ts 全量挂载),显式导入与项目其他页一致
|
||||
import { QuestionFilled, Search } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getEmployees,
|
||||
type EmployeeRow,
|
||||
type EmployeesResp,
|
||||
} from '@/api/management'
|
||||
import { useManagementFilters } from '@/composables/useManagementFilters'
|
||||
import { exportListToCsv, type CsvColumn } from '@/utils/csvExport'
|
||||
import { formatMsDuration, formatRatio } from '@/utils/format'
|
||||
import ManagementFilterBar from '@/components/management/ManagementFilterBar.vue'
|
||||
import DataAsOfBadge from '@/components/management/DataAsOfBadge.vue'
|
||||
import MetricDefsDrawer from '@/components/management/MetricDefsDrawer.vue'
|
||||
import EmployeeCompareDrawer from '@/components/management/EmployeeCompareDrawer.vue'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
type EmpView = 'management' | 'workload' | 'service' | 'customer'
|
||||
type SortBy = 'anomaly_severity' | 'total_score' | 'active_friends' | 'cust_threads'
|
||||
const VIEW_VALUES: readonly EmpView[] = ['management', 'workload', 'service', 'customer']
|
||||
const SORT_BY_VALUES: readonly SortBy[] = ['anomaly_severity', 'total_score', 'active_friends', 'cust_threads']
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { channel, start, end, compare, apply } = useManagementFilters()
|
||||
|
||||
// ── 页面级 query 真源(白名单外回默认;与后端 Literal 校验同域) ──
|
||||
const view = computed<EmpView>(() =>
|
||||
VIEW_VALUES.includes(route.query.view as EmpView) ? (route.query.view as EmpView) : 'management')
|
||||
const keyword = computed(() => (typeof route.query.keyword === 'string' ? route.query.keyword.trim() : ''))
|
||||
const status = computed<'all' | 'on_job' | 'offline'>(() =>
|
||||
route.query.status === 'on_job' || route.query.status === 'offline' ? route.query.status : 'all')
|
||||
const sortBy = computed<SortBy>(() =>
|
||||
SORT_BY_VALUES.includes(route.query.sort_by as SortBy) ? (route.query.sort_by as SortBy) : 'anomaly_severity')
|
||||
const sortOrder = computed<'asc' | 'desc'>(() => (route.query.sort_order === 'asc' ? 'asc' : 'desc'))
|
||||
const page = computed(() => {
|
||||
const n = Number(route.query.page)
|
||||
return Number.isInteger(n) && n >= 1 ? n : 1
|
||||
})
|
||||
|
||||
// ── 加载(seq 守卫沿用驾驶舱/ChurnView 模式) ──
|
||||
const resp = ref<EmployeesResp | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<string | null>(null)
|
||||
let seq = 0
|
||||
|
||||
function queryParams(over: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
channel: channel.value,
|
||||
start: start.value,
|
||||
end: end.value,
|
||||
view: view.value,
|
||||
keyword: keyword.value || undefined,
|
||||
status: status.value,
|
||||
sort_by: sortBy.value,
|
||||
sort_order: sortOrder.value,
|
||||
page: page.value,
|
||||
page_size: PAGE_SIZE,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const my = ++seq
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const r = await getEmployees(queryParams())
|
||||
if (my !== seq) return
|
||||
resp.value = r
|
||||
} catch (e) {
|
||||
// 失败保留上次成功数据,仅标记 error 供 alert+重试
|
||||
if (my === seq) loadError.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
if (my === seq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([channel, start, end, view, keyword, status, sortBy, sortOrder, page], load, { immediate: true })
|
||||
|
||||
const rows = computed<EmployeeRow[]>(() => resp.value?.rows ?? [])
|
||||
const total = computed(() => resp.value?.total ?? 0)
|
||||
|
||||
// ── 工具行交互(全部写回 URL;筛选变化重置分页) ──
|
||||
const keywordInput = ref(keyword.value)
|
||||
let kwTimer: ReturnType<typeof setTimeout> | undefined
|
||||
watch(keywordInput, (v) => {
|
||||
if (kwTimer) clearTimeout(kwTimer)
|
||||
kwTimer = setTimeout(() => {
|
||||
if ((v.trim() || '') !== keyword.value) apply({ keyword: v.trim() || undefined, page: undefined })
|
||||
}, 300)
|
||||
})
|
||||
// 外部导航(回退/直链)同步输入框显示
|
||||
watch(keyword, (v) => {
|
||||
if (v !== keywordInput.value.trim()) keywordInput.value = v
|
||||
})
|
||||
|
||||
function onStatusChange(v: string | number | boolean): void {
|
||||
apply({ status: v === 'all' ? undefined : String(v), page: undefined })
|
||||
}
|
||||
|
||||
// 切视图:列组变化,排序回默认(异常降序)、回第一页
|
||||
function onViewChange(v: string | number | boolean): void {
|
||||
apply({ view: String(v), sort_by: undefined, sort_order: undefined, page: undefined })
|
||||
}
|
||||
|
||||
function onCompareChange(v: string | number | boolean): void {
|
||||
apply({ compare: v === 'previous' ? undefined : String(v) })
|
||||
}
|
||||
|
||||
function onPageChange(p: number): void {
|
||||
apply({ page: p === 1 ? undefined : p })
|
||||
}
|
||||
|
||||
// ── 服务端排序:el-table custom 模式(全仓首个),prop 即后端 sort_by 值 ──
|
||||
const tableRef = ref<TableInstance>()
|
||||
const defaultSort = computed(() => ({
|
||||
prop: sortBy.value,
|
||||
order: sortOrder.value === 'asc' ? ('ascending' as const) : ('descending' as const),
|
||||
}))
|
||||
|
||||
function onSortChange({ prop, order }: { prop: string; order: 'ascending' | 'descending' | null }): void {
|
||||
const by = SORT_BY_VALUES.find((v) => v === prop)
|
||||
if (!by || !order) return // :sort-orders 两态,order 不会 null,防御
|
||||
apply({ sort_by: by, sort_order: order === 'ascending' ? 'asc' : 'desc', page: undefined })
|
||||
}
|
||||
|
||||
// URL 驱动的排序/视图变化(回退/切视图重置/直链)同步表头指示:sort() 对
|
||||
// custom 列只设指示不本地排序(EP watcher sortData 对字符串 sortable 直接
|
||||
// 返回原数据);prop 在当前视图无该列时为无害 no-op
|
||||
watch([view, sortBy, sortOrder], ([, by, order]) => {
|
||||
tableRef.value?.sort(by, order === 'asc' ? 'ascending' : 'descending')
|
||||
}, { flush: 'post' })
|
||||
|
||||
// ── 取数/格式化(metrics 数值键;user_id 透传键是字符串,typeof 守卫) ──
|
||||
function num(r: EmployeeRow, key: string): number | null {
|
||||
const v = r.metrics[key]
|
||||
return typeof v === 'number' ? v : null
|
||||
}
|
||||
function fmtInt(v: number | null | undefined): string {
|
||||
return v == null ? '--' : v.toLocaleString()
|
||||
}
|
||||
function fmtAvg(v: number | null | undefined): string {
|
||||
return v == null ? '--' : v.toLocaleString('zh-CN', { maximumFractionDigits: 1 })
|
||||
}
|
||||
function fmtSec(s: number | null): string {
|
||||
return s && s > 0 ? formatMsDuration(s * 1000) : '-'
|
||||
}
|
||||
function msgTotal(r: EmployeeRow): number {
|
||||
return (num(r, 'msg_sent') ?? 0) + (num(r, 'msg_recv') ?? 0)
|
||||
}
|
||||
function replyRate(r: EmployeeRow, countKey: 'reply_2h_count' | 'reply_24h_count'): string {
|
||||
const den = num(r, 'cust_threads')
|
||||
if (!den) return '--' // 分母 0/null → 无率(严格区分 0%)
|
||||
return formatRatio((num(r, countKey) ?? 0) / den)
|
||||
}
|
||||
function activeShare(r: EmployeeRow): string {
|
||||
const ea = num(r, 'end_active_friends') ?? 0
|
||||
const den = ea + (num(r, 'cooling_customer') ?? 0) + (num(r, 'dormant_customer') ?? 0)
|
||||
return den > 0 ? formatRatio(ea / den) : '--'
|
||||
}
|
||||
function dimScore(r: EmployeeRow, key: string): number | null {
|
||||
return r.score.dimensions[key]?.score ?? null
|
||||
}
|
||||
function dimScoreText(r: EmployeeRow, key: string): string {
|
||||
const s = dimScore(r, key)
|
||||
return s == null ? '--' : String(Math.round(s))
|
||||
}
|
||||
const GRADE_TYPES: Record<string, 'success' | 'primary' | 'warning' | 'danger' | 'info'> = {
|
||||
A: 'success', B: 'primary', C: 'warning', D: 'danger', E: 'info',
|
||||
}
|
||||
function gradeType(g: string | null): 'success' | 'primary' | 'warning' | 'danger' | 'info' {
|
||||
return (g && GRADE_TYPES[g]) || 'info'
|
||||
}
|
||||
|
||||
// 带窗进详情(D3):channel/start/end/compare 随行——列表口径延续到详情页;
|
||||
// view/keyword/status/sort_by/sort_order/page 是列表态,不带(详情页只认
|
||||
// channel/start/end/compare/section,其余键即使带上也被白名单忽略)
|
||||
function goDetail(row: EmployeeRow): void {
|
||||
const query: Record<string, string> = {}
|
||||
for (const key of ['channel', 'start', 'end', 'compare'] as const) {
|
||||
const v = route.query[key]
|
||||
if (typeof v === 'string' && v !== '') query[key] = v
|
||||
}
|
||||
router.push({ name: 'management-employees-detail', params: { userId: row.user_id }, query })
|
||||
}
|
||||
|
||||
// ── 多选 → 对比抽屉(reserve-selection 跨页/重查保留;行是勾选时快照) ──
|
||||
const selectedRows = ref<EmployeeRow[]>([])
|
||||
const compareVisible = ref(false)
|
||||
// 指标口径抽屉(列头「?」与筛选栏「指标口径」共用)
|
||||
const defsVisible = ref(false)
|
||||
|
||||
function onSelectionChange(sel: EmployeeRow[]): void {
|
||||
selectedRows.value = sel
|
||||
}
|
||||
|
||||
// ── CSV:当前视图列全量翻页导出,preamble 写口径/窗口注释(Excel 首列) ──
|
||||
const exporting = ref(false)
|
||||
|
||||
const csvColumns = computed<CsvColumn<EmployeeRow>[]>(() => {
|
||||
const base: CsvColumn<EmployeeRow>[] = [
|
||||
{ key: 'name', label: '姓名', format: (r) => r.name ?? r.user_id },
|
||||
{ key: 'user_id', label: 'ID' },
|
||||
{
|
||||
key: 'is_on_job',
|
||||
label: '在职',
|
||||
format: (r) => (r.is_on_job === true ? '在职' : r.is_on_job === false ? '离职' : '--'),
|
||||
},
|
||||
]
|
||||
const dimCol = (key: string, label: string): CsvColumn<EmployeeRow> => ({
|
||||
key: `dim_${key}`, label, format: (r) => dimScoreText(r, key),
|
||||
})
|
||||
if (view.value === 'management') {
|
||||
return base.concat([
|
||||
{
|
||||
key: 'total_score',
|
||||
label: '综合分',
|
||||
format: (r) =>
|
||||
r.score.score_status === 'insufficient_sample' || r.score.total_score == null
|
||||
? '--(样本不足)'
|
||||
: String(Math.round(r.score.total_score)),
|
||||
},
|
||||
{ key: 'grade', label: '等级', format: (r) => r.score.grade ?? '--' },
|
||||
{ key: 'anomaly_count', label: '异常数', format: (r) => String(r.anomaly_count) },
|
||||
{ key: 'anomaly_weight', label: '异常权重', format: (r) => String(r.anomaly_weight) },
|
||||
{ key: 'active_friends', label: '活跃客户', format: (r) => fmtInt(num(r, 'active_friends')) },
|
||||
{ key: 'msg_total', label: '消息量', format: (r) => fmtInt(msgTotal(r)) },
|
||||
{ key: 'cust_threads', label: '串', format: (r) => fmtInt(num(r, 'cust_threads')) },
|
||||
{ key: 'threads_answered', label: '被回', format: (r) => fmtInt(num(r, 'threads_answered')) },
|
||||
dimCol('customer', '客户经营分'),
|
||||
dimCol('service', '服务质量分'),
|
||||
dimCol('workload', '工作负载分'),
|
||||
])
|
||||
}
|
||||
if (view.value === 'workload') {
|
||||
return base.concat([
|
||||
{ key: 'active_friends', label: '活跃客户', format: (r) => fmtInt(num(r, 'active_friends')) },
|
||||
{ key: 'active_rooms_avg', label: '活跃群(日均)', format: (r) => fmtAvg(num(r, 'active_rooms_avg')) },
|
||||
{ key: 'msg_sent', label: '消息发', format: (r) => fmtInt(num(r, 'msg_sent')) },
|
||||
{ key: 'msg_recv', label: '消息收', format: (r) => fmtInt(num(r, 'msg_recv')) },
|
||||
{ key: 'call_count', label: '通话量', format: (r) => fmtInt(num(r, 'call_count')) },
|
||||
{ key: 'call_seconds', label: '通话秒', format: (r) => String(num(r, 'call_seconds') ?? 0) },
|
||||
{ key: 'cust_threads', label: '串', format: (r) => fmtInt(num(r, 'cust_threads')) },
|
||||
])
|
||||
}
|
||||
if (view.value === 'service') {
|
||||
return base.concat([
|
||||
{ key: 'fr_p50_ms', label: '首响P50', format: (r) => formatMsDuration(num(r, 'fr_p50_ms')) },
|
||||
{ key: 'fr_p90_ms', label: '首响P90', format: (r) => formatMsDuration(num(r, 'fr_p90_ms')) },
|
||||
{ key: 'reply_2h_rate', label: '2h回复率', format: (r) => replyRate(r, 'reply_2h_count') },
|
||||
{ key: 'reply_24h_rate', label: '24h回复率', format: (r) => replyRate(r, 'reply_24h_count') },
|
||||
{ key: 'threads_answered', label: '被回', format: (r) => fmtInt(num(r, 'threads_answered')) },
|
||||
{ key: 'cust_threads', label: '串', format: (r) => fmtInt(num(r, 'cust_threads')) },
|
||||
])
|
||||
}
|
||||
return base.concat([
|
||||
{ key: 'new_customer', label: '新客', format: (r) => fmtInt(num(r, 'new_customer')) },
|
||||
{ key: 'cooling_customer', label: '降温', format: (r) => fmtInt(num(r, 'cooling_customer')) },
|
||||
{ key: 'dormant_customer', label: '沉睡(期末)', format: (r) => fmtInt(num(r, 'dormant_customer')) },
|
||||
{ key: 'reactivated_customer', label: '回流(期内)', format: (r) => fmtInt(num(r, 'reactivated_customer')) },
|
||||
{ key: 'active_share', label: '活跃占比', format: (r) => activeShare(r) },
|
||||
])
|
||||
})
|
||||
|
||||
async function exportCsv(): Promise<void> {
|
||||
if (!total.value) {
|
||||
ElMessage.warning('当前列表为空,无数据可导出')
|
||||
return
|
||||
}
|
||||
exporting.value = true
|
||||
try {
|
||||
const result = await exportListToCsv<EmployeeRow>({
|
||||
// 适配器:getEmployees 返回 {rows,total} → csvExport 期望 {items,total}
|
||||
fetchPage: (p, size) =>
|
||||
getEmployees(queryParams({ page: p, page_size: size })).then((r) => ({ items: r.rows, total: r.total })),
|
||||
columns: csvColumns.value,
|
||||
filename: `员工分析_${view.value}_${channel.value}`,
|
||||
preamble: [
|
||||
'口径:1:1私聊·跨日精确串(与数据看板混合口径不同)',
|
||||
`窗口:${start.value}-${end.value} 渠道:${channel.value}`,
|
||||
`评分为试算(${resp.value?.metric_version ?? 'v1.0'})·样本不足员工不计分`,
|
||||
],
|
||||
})
|
||||
ElMessage.success(`已导出 ${result.rows} 条`)
|
||||
} catch (e) {
|
||||
ElMessage.error(`导出失败:${e instanceof Error ? e.message : String(e)}`)
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.kw-input {
|
||||
width: 200px;
|
||||
}
|
||||
.compare-select {
|
||||
width: 150px;
|
||||
}
|
||||
.status-select {
|
||||
width: 90px;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.top-alert {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.retry-btn {
|
||||
margin-left: 12px;
|
||||
}
|
||||
.uid {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dim {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
.score-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.grade-tag {
|
||||
margin-left: 4px;
|
||||
}
|
||||
.trial-mark {
|
||||
margin-left: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--el-color-warning);
|
||||
vertical-align: super;
|
||||
}
|
||||
.anomaly-hot {
|
||||
color: var(--el-color-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
.cell-sub {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.hd-q {
|
||||
margin-left: 2px;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: pointer;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
.hd-q:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.pager {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,537 @@
|
||||
<template>
|
||||
<div class="mgmt-dash">
|
||||
<!-- 筛选栏:渠道/时间(URL query 真源)+口径入口;aside=数据截至角标 -->
|
||||
<ManagementFilterBar
|
||||
:channel="channel"
|
||||
:start="start"
|
||||
:end="end"
|
||||
:compare="compare"
|
||||
@apply="apply"
|
||||
@open-defs="defsVisible = true"
|
||||
>
|
||||
<template #aside>
|
||||
<!-- trial 常显:dashboard 载荷无 trial 键(评分配置层字段),批次C/D
|
||||
评分处于试算期(v2 §0 全默认+试算),批次E 转正时随配置联动 -->
|
||||
<DataAsOfBadge
|
||||
:data-as-of="resp?.data_as_of ?? null"
|
||||
:metric-version="resp?.metric_version"
|
||||
:trial="true"
|
||||
:config-source="resp?.config_source"
|
||||
/>
|
||||
</template>
|
||||
</ManagementFilterBar>
|
||||
|
||||
<!-- 错误态:alert+重试;resp 不清空,保留上次成功数据 -->
|
||||
<el-alert v-if="loadError" type="error" :closable="false" class="dash-alert">
|
||||
<template #title>
|
||||
<span>{{ loadError }}</span>
|
||||
<el-button size="small" type="primary" class="retry-btn" @click="load">重试</el-button>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<!-- 空态:窗口内无任何业务数据(数据截至角标同步显「无数据」) -->
|
||||
<el-alert
|
||||
v-if="noData"
|
||||
type="info"
|
||||
title="该窗口无数据——试试调整时间范围"
|
||||
:closable="false"
|
||||
class="dash-alert"
|
||||
/>
|
||||
|
||||
<!-- 六 KPI 卡:两行三卡(≥768px 三列/窄屏两列);后三卡行动入口直达处置页 -->
|
||||
<el-row :gutter="12" class="block-row kpi-row" v-loading="loading">
|
||||
<el-col :xs="12" :sm="8">
|
||||
<ManagementKpiCard
|
||||
label="活跃客户"
|
||||
:value="kpis?.active_customers ?? null"
|
||||
:delta="deltaOf('active_customers')"
|
||||
unit="人"
|
||||
:hint="threadsHint"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8">
|
||||
<ManagementKpiCard
|
||||
label="活跃员工"
|
||||
:value="kpis?.active_employees ?? null"
|
||||
:delta="deltaOf('active_employees')"
|
||||
unit="人"
|
||||
hint="期内有数据天数>0 口径"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8">
|
||||
<!-- tooltip 说明混合 vs 1:1 口径差异,hover 才展开,卡面 hint 保持一行 -->
|
||||
<el-tooltip
|
||||
placement="top"
|
||||
content="管理域消息量仅统计 1:1 私聊消息;「数据分析·数据看板」为私聊+群聊混合口径,两者数值不可直接对比"
|
||||
>
|
||||
<ManagementKpiCard
|
||||
label="消息量"
|
||||
:value="kpis?.msg_total ?? null"
|
||||
:delta="deltaOf('msg_total')"
|
||||
unit="条"
|
||||
hint="1:1口径·与数据看板口径不同"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8">
|
||||
<ManagementKpiCard
|
||||
label="待回复"
|
||||
:value="actions?.unreplied_2h ?? null"
|
||||
unit="人"
|
||||
hint="当前时点指标·不随所选窗口"
|
||||
:danger="(actions?.unreplied_2h ?? 0) > 0"
|
||||
to="/analytics/unreplied"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8">
|
||||
<ManagementKpiCard
|
||||
label="即将流失"
|
||||
:value="actions?.churn_real_30d ?? null"
|
||||
unit="人"
|
||||
to="/analytics/churn"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8">
|
||||
<ManagementKpiCard
|
||||
label="风险待处理"
|
||||
:value="actions?.sensitive_pending ?? null"
|
||||
unit="条"
|
||||
hint="当前时点指标·不随所选窗口"
|
||||
to="/analytics/sensitive"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 双趋势图:左沟通量(消息线+通话柱双 y 轴),右服务质量(首响分位线+回复率右轴) -->
|
||||
<el-row :gutter="12" class="block-row">
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card shadow="never" class="chart-card" v-loading="loading">
|
||||
<template #header>客户沟通趋势</template>
|
||||
<div ref="commEl" class="chart"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card shadow="never" class="chart-card" v-loading="loading">
|
||||
<template #header>服务质量趋势</template>
|
||||
<div ref="svcEl" class="chart"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 双条形图:左生命周期四态(横向),右员工维度均分(0-100,无样本灰) -->
|
||||
<el-row :gutter="12" class="block-row">
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card shadow="never" class="chart-card" v-loading="loading">
|
||||
<template #header>生命周期分布</template>
|
||||
<div ref="lifeEl" class="chart"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card shadow="never" class="chart-card" v-loading="loading">
|
||||
<template #header>员工维度分布</template>
|
||||
<div ref="dimsEl" class="chart"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 异常榜:critical 在前(后端已排);点行开下钻抽屉,两次点击到事实 -->
|
||||
<el-card shadow="never" class="block-row anomaly-card" v-loading="loading">
|
||||
<template #header>
|
||||
异常榜
|
||||
<span class="anomaly-sub">按严重度排序 · 点行查看明细</span>
|
||||
</template>
|
||||
<AnomalyList :anomalies="resp?.anomalies ?? []" :loading="loading" @drill="onDrill" />
|
||||
</el-card>
|
||||
|
||||
<!-- 下钻抽屉:metric 类异常逐串/逐行明细,行点击跳聊天;employee 类批次D 占位 -->
|
||||
<MetricDrilldownDrawer
|
||||
v-model="drillVisible"
|
||||
:card="drillCard"
|
||||
:user-id="drillCard?.employee_user_id ?? ''"
|
||||
:start="start"
|
||||
:end="end"
|
||||
:channel="channel"
|
||||
/>
|
||||
|
||||
<!-- 指标口径抽屉:19 项定义+评分配置,自缓存 -->
|
||||
<MetricDefsDrawer v-model="defsVisible" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 管理驾驶舱(经营管理板块批次C首页):筛选(URL query 真源)→ 六 KPI →
|
||||
// 双趋势(沟通量/服务质量)→ 生命周期四态 + 员工维度均分 → 异常榜(下钻
|
||||
// 抽屉两次点击到事实)。管理者 5 分钟看整体:先 KPI 判断总体,异常卡
|
||||
// 直达事实。载荷 /management/dashboard 单往返;compare 仅回显(对比值
|
||||
// 批次D 员工表格呈现)。seq 守卫/双图 useChart/格式化均沿用项目惯例。
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
getDashboard,
|
||||
type AnomalyCard,
|
||||
type DashboardResp,
|
||||
} from '@/api/management'
|
||||
import { useManagementFilters } from '@/composables/useManagementFilters'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
import { formatMsDuration, formatRatio } from '@/utils/format'
|
||||
import ManagementFilterBar from '@/components/management/ManagementFilterBar.vue'
|
||||
import ManagementKpiCard from '@/components/management/ManagementKpiCard.vue'
|
||||
import DataAsOfBadge from '@/components/management/DataAsOfBadge.vue'
|
||||
import AnomalyList from '@/components/management/AnomalyList.vue'
|
||||
import MetricDrilldownDrawer from '@/components/management/MetricDrilldownDrawer.vue'
|
||||
import MetricDefsDrawer from '@/components/management/MetricDefsDrawer.vue'
|
||||
|
||||
// ── 筛选(URL query 为真源,刷新/分享保持)与加载 ──
|
||||
const { channel, start, end, compare, apply } = useManagementFilters()
|
||||
|
||||
const resp = ref<DashboardResp | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<string | null>(null)
|
||||
|
||||
let seq = 0 // 竞态守卫:筛选快速切换时旧响应作废(项目惯例)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const my = ++seq
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const r = await getDashboard(channel.value, start.value, end.value, compare.value)
|
||||
if (my !== seq) return
|
||||
resp.value = r
|
||||
} catch (e) {
|
||||
// 失败保留上次成功数据(resp 不清空),仅标记 error 供 alert+重试
|
||||
if (my === seq) loadError.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
// 过期请求不清 loading(新一轮还在飞)也不覆盖 error
|
||||
if (my === seq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// immediate 首帧即取数,替代 onMounted;筛选任一变化重查
|
||||
watch([channel, start, end, compare], load, { immediate: true })
|
||||
|
||||
const kpis = computed(() => resp.value?.kpis ?? null)
|
||||
const actions = computed(() => resp.value?.action_summary ?? null)
|
||||
|
||||
// 三 KPI 卡环比(批次D):客户端统一按 (cur-prev)/prev 自 prev_kpis 计算
|
||||
// (数据同载荷都在;后端 msg_delta 备用未用)。prev_kpis 缺失(旧缓存
|
||||
// 响应)或 prev=0(前窗无数据)→ null 不显示
|
||||
function deltaOf(key: 'active_customers' | 'active_employees' | 'msg_total'): number | null {
|
||||
const k = resp.value?.kpis
|
||||
const p = resp.value?.prev_kpis
|
||||
if (!k || !p || !p[key]) return null
|
||||
return (k[key] - p[key]) / p[key]
|
||||
}
|
||||
|
||||
// 活跃客户辅助行:串/被回(口径与 KPI 同载荷,管理域跨日精确串)
|
||||
const threadsHint = computed(() => {
|
||||
const k = kpis.value
|
||||
if (!k) return undefined
|
||||
return `串 ${k.cust_threads.toLocaleString()} · 被回 ${k.threads_answered.toLocaleString()}`
|
||||
})
|
||||
|
||||
// 空态:核心 KPI 全 0(趋势按日脚手架恒非空,以 KPI 判空才可靠)
|
||||
const noData = computed(() => {
|
||||
const k = kpis.value
|
||||
if (!k || loadError.value) return false
|
||||
return !k.active_customers && !k.active_employees && !k.msg_total && !k.call_count
|
||||
})
|
||||
|
||||
// ── 异常下钻 ──
|
||||
const drillVisible = ref(false)
|
||||
const drillCard = ref<AnomalyCard | null>(null)
|
||||
const defsVisible = ref(false)
|
||||
|
||||
function onDrill(card: AnomalyCard): void {
|
||||
drillCard.value = card
|
||||
drillVisible.value = true
|
||||
}
|
||||
|
||||
// ── 图表(四张,容器恒在 DOM——useChart 只 init 一次,不 v-if) ──
|
||||
const commEl = ref<HTMLElement | null>(null)
|
||||
const svcEl = ref<HTMLElement | null>(null)
|
||||
const lifeEl = ref<HTMLElement | null>(null)
|
||||
const dimsEl = ref<HTMLElement | null>(null)
|
||||
const { render: renderComm } = useChart(commEl)
|
||||
const { render: renderSvc } = useChart(svcEl)
|
||||
const { render: renderLife } = useChart(lifeEl)
|
||||
const { render: renderDims } = useChart(dimsEl)
|
||||
|
||||
// yyyymmdd(20260810)→ 08-10 短格式(轴/tooltip 共用)
|
||||
const shortDay = (d: number): string => `${String(d).slice(4, 6)}-${String(d).slice(6)}`
|
||||
// 大数压缩:轴刻度统一 1.1万 口径(与 DashboardView 一致)
|
||||
const compact = (n: number): string =>
|
||||
n >= 10000 ? `${(n / 10000).toFixed(1)}万` : n.toLocaleString()
|
||||
// 服务质量左轴刻度:ms → 紧凑时长(轴上省秒,tooltip 仍用 formatMsDuration)
|
||||
const fmtMsAxis = (v: number): string =>
|
||||
v >= 3_600_000 ? `${(v / 3_600_000).toFixed(1)}时` : `${Math.round(v / 60_000)}分`
|
||||
// X 轴 ~6 个刻度(30 个日期全排会挤成一串不可读)
|
||||
const axisInterval = (n: number): number => Math.max(0, Math.ceil(n / 6) - 1)
|
||||
|
||||
const AXIS_LABEL = { color: '#909399' }
|
||||
const SPLIT_LINE = { lineStyle: { type: 'solid' as const, color: '#f0f1f3' } }
|
||||
|
||||
function drawComm(): void {
|
||||
const t = resp.value?.communication_trend
|
||||
if (!t || !t.length) return
|
||||
const labels = t.map((p) => shortDay(p.day))
|
||||
renderComm({
|
||||
grid: { left: 56, right: 56, top: 40, bottom: 28 },
|
||||
tooltip: { trigger: 'axis', valueFormatter: (v: unknown) => compact(Number(v)) },
|
||||
legend: { data: ['消息量', '通话量'], top: 4, itemWidth: 14, textStyle: { color: '#606266' } },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: labels,
|
||||
axisLabel: { interval: axisInterval(labels.length), hideOverlap: true, ...AXIS_LABEL },
|
||||
axisLine: { lineStyle: { color: '#dcdfe6' } },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '消息量',
|
||||
axisLabel: { formatter: (v: number) => compact(v), ...AXIS_LABEL },
|
||||
splitLine: SPLIT_LINE,
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '通话量',
|
||||
axisLabel: { formatter: (v: number) => compact(v), ...AXIS_LABEL },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '消息量',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
yAxisIndex: 0,
|
||||
lineStyle: { width: 2, color: '#409EFF' },
|
||||
itemStyle: { color: '#409EFF' },
|
||||
areaStyle: { opacity: 0.1, color: '#409EFF' },
|
||||
showSymbol: false,
|
||||
data: t.map((p) => p.msg_total),
|
||||
},
|
||||
{
|
||||
name: '通话量',
|
||||
type: 'bar',
|
||||
yAxisIndex: 1,
|
||||
barMaxWidth: 12,
|
||||
itemStyle: { color: '#67C23A', borderRadius: [3, 3, 0, 0] },
|
||||
data: t.map((p) => p.call_cnt),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// 服务质量 tooltip:ms→时长/率→百分比(null 显 '-',与断点语义一致)
|
||||
interface TipParam { marker?: string; seriesName?: string; value?: number | null; axisValueLabel?: string }
|
||||
function svcTooltip(ps: unknown): string {
|
||||
const arr = (Array.isArray(ps) ? ps : [ps]) as TipParam[]
|
||||
if (!arr.length) return ''
|
||||
const rows = arr.map((p) => {
|
||||
const v = typeof p.value === 'number' ? p.value : null
|
||||
const text = v === null ? '-' : p.seriesName === '2h回复率' ? formatRatio(v) : formatMsDuration(v)
|
||||
return `<div>${p.marker ?? ''}${p.seriesName}: ${text}</div>`
|
||||
})
|
||||
return `<div>${arr[0].axisValueLabel ?? ''}</div>${rows.join('')}`
|
||||
}
|
||||
|
||||
function drawSvc(): void {
|
||||
const t = resp.value?.service_trend
|
||||
if (!t || !t.length) return
|
||||
const labels = t.map((p) => shortDay(p.day))
|
||||
renderSvc({
|
||||
grid: { left: 56, right: 56, top: 40, bottom: 28 },
|
||||
tooltip: { trigger: 'axis', formatter: svcTooltip },
|
||||
legend: {
|
||||
data: ['首响中位', '首响九分位', '2h回复率'],
|
||||
top: 4, itemWidth: 14, textStyle: { color: '#606266' },
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: labels,
|
||||
axisLabel: { interval: axisInterval(labels.length), hideOverlap: true, ...AXIS_LABEL },
|
||||
axisLine: { lineStyle: { color: '#dcdfe6' } },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '首响',
|
||||
axisLabel: { formatter: fmtMsAxis, ...AXIS_LABEL },
|
||||
splitLine: SPLIT_LINE,
|
||||
},
|
||||
{
|
||||
// 右轴 0-1 率:不单列双轴则率线贴底不可读
|
||||
type: 'value',
|
||||
name: '2h回复率',
|
||||
min: 0,
|
||||
max: 1,
|
||||
axisLabel: { formatter: (v: number) => formatRatio(v), ...AXIS_LABEL },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
// null(无样本日)=断点:connectNulls:false 显式钉死,不跨缺口连线
|
||||
{
|
||||
name: '首响中位',
|
||||
type: 'line', smooth: true, showSymbol: false, connectNulls: false,
|
||||
lineStyle: { width: 2 },
|
||||
data: t.map((p) => p.fr_p50_ms),
|
||||
},
|
||||
{
|
||||
name: '首响九分位',
|
||||
type: 'line', smooth: true, showSymbol: false, connectNulls: false,
|
||||
lineStyle: { width: 2 },
|
||||
data: t.map((p) => p.fr_p90_ms),
|
||||
},
|
||||
{
|
||||
name: '2h回复率',
|
||||
type: 'line', smooth: true, showSymbol: false, connectNulls: false,
|
||||
yAxisIndex: 1,
|
||||
lineStyle: { width: 2, color: '#67C23A' },
|
||||
itemStyle: { color: '#67C23A' },
|
||||
data: t.map((p) => p.reply_2h_rate),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// 生命周期四态:横向条形(名义类别,状态色——新客蓝/降温橙/沉睡红/回流绿)
|
||||
const LIFE_META = [
|
||||
{ key: 'new', label: '新客', color: '#409EFF' },
|
||||
{ key: 'cooling', label: '降温', color: '#E6A23C' },
|
||||
{ key: 'dormant', label: '沉睡', color: '#F56C6C' },
|
||||
{ key: 'reactivated', label: '回流', color: '#67C23A' },
|
||||
] as const
|
||||
|
||||
function drawLife(): void {
|
||||
const lc = resp.value?.lifecycle
|
||||
if (!lc) return
|
||||
renderLife({
|
||||
grid: { left: 8, right: 48, top: 8, bottom: 8, containLabel: true },
|
||||
tooltip: { trigger: 'item', valueFormatter: (v: unknown) => compact(Number(v)) },
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { formatter: (v: number) => compact(v), ...AXIS_LABEL },
|
||||
splitLine: SPLIT_LINE,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
inverse: true, // 新客在顶部(生命周期顺序)
|
||||
data: LIFE_META.map((m) => m.label),
|
||||
axisTick: { show: false },
|
||||
axisLine: { show: false },
|
||||
axisLabel: { color: '#606266' },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
barWidth: 14,
|
||||
itemStyle: { borderRadius: [0, 4, 4, 0] },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
formatter: (p: { value: unknown }) => compact(Number(p.value)),
|
||||
color: '#606266',
|
||||
fontSize: 11,
|
||||
},
|
||||
data: LIFE_META.map((m) => ({ value: lc[m.key], itemStyle: { color: m.color } })),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// 员工维度分布:pending_data 维(action/risk)恒无 scored/insufficient 计数,
|
||||
// 以计数>0 过滤,只留真实参与评分的维度(customer/service/workload)
|
||||
const dimRows = computed(() =>
|
||||
Object.entries(resp.value?.dimension_summary ?? {})
|
||||
.filter(([, d]) => d.scored_count > 0 || d.insufficient_count > 0)
|
||||
.map(([key, d]) => ({ key, ...d })),
|
||||
)
|
||||
|
||||
function dimsTooltip(p: { dataIndex?: number }): string {
|
||||
const r = p.dataIndex != null ? dimRows.value[p.dataIndex] : undefined
|
||||
if (!r) return ''
|
||||
const avg = r.avg_score === null ? '-' : Math.round(r.avg_score)
|
||||
return (
|
||||
`<div>${r.name}</div>` +
|
||||
`<div>均分 ${avg}(权重 ${r.weight}%)</div>` +
|
||||
`<div>已评分 ${r.scored_count} · 样本不足 ${r.insufficient_count} · 待数据 ${r.pending_count}</div>`
|
||||
)
|
||||
}
|
||||
|
||||
function drawDims(): void {
|
||||
const rows = dimRows.value
|
||||
if (!rows.length) return
|
||||
renderDims({
|
||||
grid: { left: 40, right: 16, top: 24, bottom: 28, containLabel: true },
|
||||
tooltip: { trigger: 'item', formatter: dimsTooltip },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: rows.map((r) => r.name),
|
||||
axisTick: { show: false },
|
||||
axisLine: { lineStyle: { color: '#dcdfe6' } },
|
||||
axisLabel: { color: '#606266' },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 100, // 维度分定义域 0-100
|
||||
axisLabel: AXIS_LABEL,
|
||||
splitLine: SPLIT_LINE,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
barWidth: 28,
|
||||
itemStyle: { borderRadius: [3, 3, 0, 0] },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
formatter: (p: { dataIndex: number; value: unknown }) =>
|
||||
(rows[p.dataIndex]?.avg_score ?? null) === null ? '-' : compact(Number(p.value)),
|
||||
color: '#606266',
|
||||
fontSize: 11,
|
||||
},
|
||||
data: rows.map((r) =>
|
||||
r.avg_score === null
|
||||
// 无样本维度(全员未评):0 值灰条——有位置但不冒充分数
|
||||
? { value: 0, itemStyle: { color: '#c0c4cc' } }
|
||||
: { value: Math.round(r.avg_score), itemStyle: { color: '#409EFF' } },
|
||||
),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// post:等 DOM 更新后再画,保证首帧容器已挂载;resp 单源,四图同刷
|
||||
watch(resp, () => { drawComm(); drawSvc(); drawLife(); drawDims() }, { flush: 'post' })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dash-alert {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.retry-btn {
|
||||
margin-left: 12px;
|
||||
}
|
||||
.block-row {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.kpi-row {
|
||||
min-height: 100px;
|
||||
}
|
||||
.chart-card :deep(.el-card__body) {
|
||||
padding: 12px 16px 8px;
|
||||
}
|
||||
.chart {
|
||||
height: 300px;
|
||||
}
|
||||
.anomaly-sub {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user