Compare commits
15 Commits
b0ebe8a15a
...
10b7e5e534
| Author | SHA1 | Date | |
|---|---|---|---|
| 10b7e5e534 | |||
| e3fadfe1a6 | |||
| 3cf77ec6d5 | |||
| 7343e1c9aa | |||
| 5196a72ac2 | |||
| df8d2b30d5 | |||
| c56f13059d | |||
| 7b0e1601b7 | |||
| 940762e7ff | |||
| 2b10c07fb7 | |||
| 9fbd572bb7 | |||
| 23ac95f2e4 | |||
| 3d5644c42a | |||
| 31cd2d92aa | |||
| 57ace98792 |
@@ -241,3 +241,65 @@
|
||||
| 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 |
|
||||
|
||||
@@ -190,7 +190,7 @@ DEFAULT_SCORE_CONFIG: dict = {
|
||||
"workload": {"weight": 15, "name": "有效负载", "metrics": {
|
||||
"active_friends_band": {"type": "band", "metric": "active_friends",
|
||||
"low": 5, "high": 80,
|
||||
"basis": "period_daily_avg"},
|
||||
"basis": "period_distinct"},
|
||||
"call_band": {"type": "band", "metric": "call_count",
|
||||
"low": 1, "high": 200, "basis": "period_total",
|
||||
"note": "通话低不扣分仅提示;band 得分下限 80"},
|
||||
@@ -452,7 +452,7 @@ def score_employee(config, current: PeriodMetrics, baseline: PeriodMetrics | Non
|
||||
```
|
||||
|
||||
规则(写死进测试):
|
||||
- 周期值派生:`reply_2h_rate = reply_2h_count/cust_threads`(threads=0 → None);`fr_p90_ms` 直取;`active_share` **分子=期末该渠道最近一行(≤end)的 active_friends,分母=同一行的 active_friends+cooling_customer+dormant_customer**(分母 0 → None;宽表无生命周期 active 存量列,active 以 active_friends 列充当);band 用 active_friends 日均与 call_count 总量。
|
||||
- 周期值派生:`reply_2h_rate = reply_2h_count/cust_threads`(threads=0 → None);`fr_p90_ms` 直取;`active_share` **分子=期末该渠道最近一行(≤end)的 active_friends,分母=同一行的 active_friends+cooling_customer+dormant_customer**(分母 0 → None;宽表无生命周期 active 存量列,active 以 active_friends 列充当);band 用 active_friends 周期 distinct(B2 决议:抗单日噪声,与 B1 口径一致)与 call_count 总量。
|
||||
- 样本保护:`cust_threads < min_threads(30)` → 该 service/customer 指标 sample_status="insufficient",不评分;soft(10)~min 之间 "low_confidence"(评分但标注)。
|
||||
- 维度分 = 各指标得分的均值(等权);任一指标 None 时该指标剔除并按剩余归一;**全部 None → 维度 pending**。
|
||||
- 总分 = Σ(可用维度 weight×dim_score)/Σ(可用维度 weight)×100;可用=pending_data 以外且维度分非 None;全 None → score_status="insufficient_sample"(total None)。
|
||||
@@ -478,7 +478,7 @@ def score_employee(config, current: PeriodMetrics, baseline: PeriodMetrics | Non
|
||||
| reply_p90_worsened | p90 > 基线p90×1.5 且样本≥soft;×2.5 升 critical | warning/critical | metric_code=first_reply_p90 |
|
||||
| reply_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 日均 > 80(band 上限) | warning | 员工详情 |
|
||||
| workload_overload | active_friends 周期 distinct > band 上限 80 | warning | 员工详情 |
|
||||
|
||||
卡片字段照 v1 §5.4 JSON(rule_code/severity/employee_user_id/title/current_value/baseline_value/unit/sample_size/**affected_count**/reason[纯数据事实]/drilldown/suggested_action——文案固定模板,不用 LLM)。`affected_count` 按规则定义:reply_p90_worsened 与 reply_rate_dropped=期内未回串数(cust_threads−threads_answered);activity_drop=当前期活跃客户数;workload_overload=active_friends 日均。
|
||||
|
||||
|
||||
@@ -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 索引)+部署上线。
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
"""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,
|
||||
}
|
||||
|
||||
# ---- 维度分布(全员 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 ----
|
||||
anomalies = [a 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,
|
||||
"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,
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
"""管理 API 组装层(批次B Task B4)——router 六端点共用的重查询与行构造。
|
||||
|
||||
拆出 router 的原因:employees/dashboard 的组装(多窗聚合 + 评分 + 异常 +
|
||||
对比派生)上百行,放 router 会淹没端点定义;本模块只做数据组装,参数
|
||||
校验/HTTP 语义留在 router。
|
||||
|
||||
查询纪律(与 analytics.metrics 同):**禁止 asyncio.gather**——单
|
||||
AsyncSession 不能并发执行查询,全部顺序 await。重端点不做 N+1:
|
||||
period_metrics 每窗一次调用全员;员工循环(评分/异常)是纯函数。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import statistics
|
||||
from bisect import bisect_right
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.analytics.lifecycle import classify
|
||||
from app.db.models import (
|
||||
Customer,
|
||||
EmployeeCallDaily,
|
||||
EmployeeMetricDaily,
|
||||
EmployeeScoreConfig,
|
||||
FirstReplyFact,
|
||||
MsgDailyStats,
|
||||
PhoneCall,
|
||||
Staff,
|
||||
)
|
||||
from app.management.anomalies import detect_anomalies
|
||||
from app.management.employee_metrics import (
|
||||
PeriodMetrics,
|
||||
baseline_window,
|
||||
day_range,
|
||||
percentile,
|
||||
period_metrics,
|
||||
prev_window,
|
||||
team_medians,
|
||||
)
|
||||
from app.management.metric_definitions import DEFAULT_SCORE_CONFIG
|
||||
from app.management.scoring import derive_metrics, score_employee
|
||||
from app.storage.employee_metrics import METRIC_VERSION
|
||||
from app.storage.staff_summary import account_map
|
||||
from app.storage.summary import day_bounds_ms
|
||||
|
||||
# 异常权重(anomaly_severity 排序/汇总):critical=2 / warning=1
|
||||
SEVERITY_WEIGHT = {"critical": 2, "warning": 1}
|
||||
|
||||
# 派生指标中需对「员工派生值」另取中位的键(scoring.team 键=派生指标名;
|
||||
# 其余如 fr_p90_ms/active_friends/call_count 直接用 team_medians 输出)
|
||||
_DERIVED_MEDIAN_KEYS = ("reply_2h_rate", "reply_24h_rate", "active_share")
|
||||
|
||||
|
||||
async def load_score_config(session: AsyncSession) -> tuple[dict, str]:
|
||||
"""激活评分配置(DB)→ (config_json, "db");无 active 行 →
|
||||
(DEFAULT_SCORE_CONFIG 深拷贝, "default")——未跑种子的环境也能出分。"""
|
||||
row = (await session.execute(
|
||||
sa.select(EmployeeScoreConfig)
|
||||
.where(EmployeeScoreConfig.status == "active")
|
||||
.order_by(EmployeeScoreConfig.id.desc())
|
||||
.limit(1)
|
||||
)).scalar_one_or_none()
|
||||
if row is not None:
|
||||
return row.config_json, "db"
|
||||
return copy.deepcopy(DEFAULT_SCORE_CONFIG), "default"
|
||||
|
||||
|
||||
def team_derived_medians(metrics: dict[str, PeriodMetrics]) -> dict[str, float | None]:
|
||||
"""评分用团队中位:PeriodMetrics 字段中位 + 派生率/占比的员工级中位
|
||||
(scoring 要求 team 键=派生指标名,见其模块 docstring)。"""
|
||||
team = dict(team_medians(metrics))
|
||||
for key in _DERIVED_MEDIAN_KEYS:
|
||||
vals = [
|
||||
v for v in (derive_metrics(pm).get(key) for pm in metrics.values())
|
||||
if v is not None
|
||||
]
|
||||
team[key] = statistics.median(vals) if vals else None
|
||||
return team
|
||||
|
||||
|
||||
def score_with_guards(
|
||||
config: dict,
|
||||
cur: PeriodMetrics,
|
||||
baseline: PeriodMetrics | None = None,
|
||||
team: dict[str, float] | None = None,
|
||||
prev: PeriodMetrics | None = None,
|
||||
) -> dict:
|
||||
"""score_employee + min_days 接线(B2 评审遗留):当前窗天数 <
|
||||
config.sample_guards.min_days → score_status 覆盖 insufficient_sample,
|
||||
total/明细保留展示(按天稀疏的数据不参与考核但可查看)。"""
|
||||
score = score_employee(config, cur, baseline=baseline, team=team, prev=prev)
|
||||
min_days = config.get("sample_guards", {}).get("min_days", 5)
|
||||
if cur.days_with_data < min_days:
|
||||
score["score_status"] = "insufficient_sample"
|
||||
return score
|
||||
|
||||
|
||||
async def assemble_employees(
|
||||
session: AsyncSession,
|
||||
config: dict,
|
||||
start_day: int,
|
||||
end_day: int,
|
||||
channel: str | None,
|
||||
*,
|
||||
with_prev: bool = True,
|
||||
) -> dict[str, dict]:
|
||||
"""全员组装:staff → 当前/基线(/前)窗 period_metrics → 逐员工
|
||||
score_with_guards + detect_anomalies(纯函数循环)。
|
||||
|
||||
dashboard 传 with_prev=False(只需评分+异常,省一次前窗聚合);
|
||||
employees/detail 需要 prev/团队中位时传 True。返回
|
||||
{uid: {name, is_on_job, cur, prev, baseline, score, anomalies,
|
||||
anomaly_count, anomaly_weight}}——过滤/排序/分页由调用方做。
|
||||
"""
|
||||
staff_rows = (await session.execute(
|
||||
sa.select(Staff.user_id, Staff.name, Staff.is_on_job)
|
||||
)).all()
|
||||
staff = {uid: (name, onjob) for uid, name, onjob in staff_rows if uid}
|
||||
if not staff:
|
||||
return {}
|
||||
|
||||
n_days = len(day_range(start_day, end_day))
|
||||
cur = await period_metrics(session, start_day, end_day, channel)
|
||||
bs, be = baseline_window(start_day)
|
||||
base = await period_metrics(session, bs, be, channel)
|
||||
prev: dict[str, PeriodMetrics] | None = None
|
||||
team: dict[str, float | None] | None = None
|
||||
if with_prev:
|
||||
ps, pe = prev_window(start_day, end_day)
|
||||
prev = await period_metrics(session, ps, pe, channel)
|
||||
team = team_derived_medians(cur)
|
||||
|
||||
out: dict[str, dict] = {}
|
||||
for uid, (name, onjob) in staff.items():
|
||||
c = cur.get(uid)
|
||||
if c is None: # 防御:period_metrics 恒含全员,此处不该走到
|
||||
continue
|
||||
b = base.get(uid)
|
||||
p = prev.get(uid) if prev is not None else None
|
||||
score = score_with_guards(config, c, baseline=b, team=team, prev=p)
|
||||
anomalies = detect_anomalies(uid, c, b, n_days=n_days, config=config)
|
||||
out[uid] = {
|
||||
"name": name,
|
||||
"is_on_job": onjob,
|
||||
"cur": c,
|
||||
"prev": p,
|
||||
"baseline": b,
|
||||
"score": score,
|
||||
"anomalies": anomalies,
|
||||
"anomaly_count": len(anomalies),
|
||||
"anomaly_weight": sum(
|
||||
SEVERITY_WEIGHT.get(a["severity"], 0) for a in anomalies
|
||||
),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def _code_to_source(config: dict) -> dict[str, str]:
|
||||
"""配置指标 code → 源指标名(spec.metric,无则 code 本身)。
|
||||
|
||||
comparisons 键域统一为源指标名(与 derive_metrics / sections 端点一致,
|
||||
冻结批次C契约):active_friends_band→active_friends、call_band→call_count,
|
||||
其余 code 本身即源名。"""
|
||||
out: dict[str, str] = {}
|
||||
for dim in config.get("dimensions", {}).values():
|
||||
for code, spec in dim.get("metrics", {}).items():
|
||||
out[code] = spec.get("metric", code)
|
||||
return out
|
||||
|
||||
|
||||
def build_employee_row(uid: str, data: dict, config: dict) -> dict:
|
||||
"""employees 列表行:metrics 全字段 + comparisons 四值(自评分
|
||||
metrics_detail 透传,键映射为源指标名)+ score 摘要 + 异常计数/权重。"""
|
||||
score = data["score"]
|
||||
code_src = _code_to_source(config)
|
||||
return {
|
||||
"user_id": uid,
|
||||
"name": data["name"],
|
||||
"is_on_job": data["is_on_job"],
|
||||
"metrics": data["cur"]._asdict(),
|
||||
"comparisons": {
|
||||
code_src.get(code, code): {
|
||||
"current": d["value"],
|
||||
"prev": d["prev"],
|
||||
"baseline": d["baseline"],
|
||||
"median": d["median"],
|
||||
}
|
||||
for code, d in score["metrics_detail"].items()
|
||||
},
|
||||
"score": {
|
||||
"total_score": score["total_score"],
|
||||
"grade": score["grade"],
|
||||
"score_status": score["score_status"],
|
||||
"trial": score["trial"],
|
||||
"dimensions": {
|
||||
key: {"score": dim["score"], "status": dim["status"]}
|
||||
for key, dim in score["dimensions"].items()
|
||||
},
|
||||
},
|
||||
"anomaly_count": data["anomaly_count"],
|
||||
"anomaly_weight": data["anomaly_weight"],
|
||||
}
|
||||
|
||||
|
||||
def derived_comparisons(
|
||||
cur: PeriodMetrics,
|
||||
prev: PeriodMetrics | None,
|
||||
baseline: PeriodMetrics | None,
|
||||
team: dict[str, float | None] | None,
|
||||
) -> dict[str, dict]:
|
||||
"""四对比(current/prev/baseline/median),派生口径=scoring.derive_metrics
|
||||
——评分、异常、展示三方同一派生式。"""
|
||||
cv = derive_metrics(cur)
|
||||
pv = derive_metrics(prev) if prev is not None else {}
|
||||
bv = derive_metrics(baseline) if baseline is not None else {}
|
||||
return {
|
||||
key: {
|
||||
"current": cv.get(key),
|
||||
"prev": pv.get(key),
|
||||
"baseline": bv.get(key),
|
||||
"median": team.get(key) if team else None,
|
||||
}
|
||||
for key in cv
|
||||
}
|
||||
|
||||
|
||||
async def wide_daily_rows(
|
||||
session: AsyncSession,
|
||||
start_day: int,
|
||||
end_day: int,
|
||||
channel: str | None,
|
||||
user_id: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""宽表逐日聚合(加性列跨渠道求和;user_id=None 为全员合计)。
|
||||
|
||||
first_reply_p50/p90_ms 是 (day,channel,user) 行内单值不可求和:
|
||||
单员工视图(channel=None 双渠道同日)取当日 cust_threads 较大渠道的
|
||||
值(当日主力渠道,并列取 wechat);全员视图不取用这两列(分位由
|
||||
service_daily_trend 从 fact 精确算)。
|
||||
"""
|
||||
conds = [
|
||||
EmployeeMetricDaily.day.between(start_day, end_day),
|
||||
EmployeeMetricDaily.metric_version == METRIC_VERSION,
|
||||
]
|
||||
if channel is not None:
|
||||
conds.append(EmployeeMetricDaily.channel == channel)
|
||||
if user_id is not None:
|
||||
conds.append(EmployeeMetricDaily.user_id == user_id)
|
||||
rows = (await session.execute(
|
||||
sa.select(
|
||||
EmployeeMetricDaily.day.label("day"),
|
||||
EmployeeMetricDaily.channel.label("ch"),
|
||||
sa.func.sum(EmployeeMetricDaily.msg_sent).label("sent"),
|
||||
sa.func.sum(EmployeeMetricDaily.msg_recv).label("recv"),
|
||||
sa.func.sum(EmployeeMetricDaily.active_friends).label("af"),
|
||||
sa.func.sum(EmployeeMetricDaily.active_rooms).label("ar"),
|
||||
sa.func.sum(EmployeeMetricDaily.cust_threads).label("thr"),
|
||||
sa.func.sum(EmployeeMetricDaily.threads_answered).label("ans"),
|
||||
sa.func.sum(EmployeeMetricDaily.fr_sum_ms).label("frs"),
|
||||
sa.func.sum(EmployeeMetricDaily.new_customer_count).label("nw"),
|
||||
sa.func.sum(EmployeeMetricDaily.cooling_customer_count).label("cool"),
|
||||
sa.func.sum(EmployeeMetricDaily.dormant_customer_count).label("dorm"),
|
||||
sa.func.sum(EmployeeMetricDaily.reactivated_customer_count).label("react"),
|
||||
sa.func.max(EmployeeMetricDaily.first_reply_p50_ms).label("p50"),
|
||||
sa.func.max(EmployeeMetricDaily.first_reply_p90_ms).label("p90"),
|
||||
).where(*conds).group_by(EmployeeMetricDaily.day, EmployeeMetricDaily.channel)
|
||||
)).all()
|
||||
|
||||
per_day: dict[int, dict] = {}
|
||||
|
||||
def _blank(day: int) -> dict:
|
||||
return {
|
||||
"day": day, "msg_sent": 0, "msg_recv": 0, "active_friends": 0,
|
||||
"active_rooms": 0, "cust_threads": 0, "threads_answered": 0,
|
||||
"fr_sum_ms": 0, "new_customer_count": 0,
|
||||
"cooling_customer_count": 0, "dormant_customer_count": 0,
|
||||
"reactivated_customer_count": 0,
|
||||
"first_reply_p50_ms": None, "first_reply_p90_ms": None,
|
||||
"_pick": (-1, -1), # 主力渠道追踪 (cust_threads, wechat 加分)
|
||||
}
|
||||
|
||||
for (day, ch, sent, recv, af, ar, thr, ans, frs, nw, cool, dorm,
|
||||
react, p50, p90) in rows:
|
||||
d = per_day.setdefault(day, _blank(day))
|
||||
d["msg_sent"] += int(sent or 0)
|
||||
d["msg_recv"] += int(recv or 0)
|
||||
d["active_friends"] += int(af or 0)
|
||||
d["active_rooms"] += int(ar or 0)
|
||||
d["cust_threads"] += int(thr or 0)
|
||||
d["threads_answered"] += int(ans or 0)
|
||||
d["fr_sum_ms"] += int(frs or 0)
|
||||
d["new_customer_count"] += int(nw or 0)
|
||||
d["cooling_customer_count"] += int(cool or 0)
|
||||
d["dormant_customer_count"] += int(dorm or 0)
|
||||
d["reactivated_customer_count"] += int(react or 0)
|
||||
if user_id is not None:
|
||||
pick = (int(thr or 0), 1 if ch == "wechat" else 0)
|
||||
if pick > d["_pick"]:
|
||||
d["_pick"] = pick
|
||||
d["first_reply_p50_ms"] = p50
|
||||
d["first_reply_p90_ms"] = p90
|
||||
|
||||
out = []
|
||||
for day in sorted(per_day):
|
||||
d = per_day[day]
|
||||
d.pop("_pick", None)
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
async def call_daily_rows(
|
||||
session: AsyncSession,
|
||||
start_day: int,
|
||||
end_day: int,
|
||||
user_id: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""通话小表逐日聚合(恒全渠道——表无渠道维);user_id=None 为全员。"""
|
||||
conds = [EmployeeCallDaily.day.between(start_day, end_day)]
|
||||
if user_id is not None:
|
||||
conds.append(EmployeeCallDaily.user_id == user_id)
|
||||
rows = (await session.execute(
|
||||
sa.select(
|
||||
EmployeeCallDaily.day,
|
||||
sa.func.sum(EmployeeCallDaily.call_count),
|
||||
sa.func.sum(EmployeeCallDaily.call_seconds),
|
||||
sa.func.sum(EmployeeCallDaily.answered_call_count),
|
||||
sa.func.sum(EmployeeCallDaily.call_followup_count),
|
||||
).where(*conds).group_by(EmployeeCallDaily.day)
|
||||
)).all()
|
||||
return [
|
||||
{
|
||||
"day": day, "call_count": int(cc or 0),
|
||||
"call_seconds": int(cs or 0),
|
||||
"answered_call_count": int(ca or 0),
|
||||
"call_followup_count": int(cf or 0),
|
||||
}
|
||||
for day, cc, cs, ca, cf in rows
|
||||
]
|
||||
|
||||
|
||||
async def service_daily_trend(
|
||||
session: AsyncSession,
|
||||
start_day: int,
|
||||
end_day: int,
|
||||
channel: str | None,
|
||||
threads_by_day: dict[int, int],
|
||||
) -> list[dict]:
|
||||
"""服务趋势(dashboard 全团队):fact 逐日 (day, fr_ms) 行拉回 Python
|
||||
最近邻秩分位(跨员工合并样本——团队级分位);reply_2h_rate = fact
|
||||
当日 ≤2h 计数 / 宽表当日 cust_threads 合计(分母 0 → None)。
|
||||
|
||||
输出全窗口逐日(无数据日 p50/p90 为 None),threads_by_day 来自
|
||||
wide_daily_rows 的全员 cust_threads 列。
|
||||
"""
|
||||
conds = [FirstReplyFact.day.between(start_day, end_day)]
|
||||
if channel is not None:
|
||||
conds.append(FirstReplyFact.channel == channel)
|
||||
rows = (await session.execute(
|
||||
sa.select(FirstReplyFact.day, FirstReplyFact.fr_ms).where(*conds)
|
||||
)).all()
|
||||
by_day: dict[int, list[int]] = {}
|
||||
for day, fr in rows:
|
||||
by_day.setdefault(day, []).append(int(fr))
|
||||
out = []
|
||||
for day in day_range(start_day, end_day):
|
||||
frs = by_day.get(day, [])
|
||||
cnt_2h = sum(1 for f in frs if f <= 7_200_000)
|
||||
denom = threads_by_day.get(day, 0)
|
||||
out.append({
|
||||
"day": day,
|
||||
"fr_p50_ms": percentile(frs, 1, 2),
|
||||
"fr_p90_ms": percentile(frs, 9, 10),
|
||||
"reply_2h_rate": (cnt_2h / denom) if denom else None,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
async def user_owner_accounts(
|
||||
session: AsyncSession, user_id: str, channel: str | None
|
||||
) -> list[str]:
|
||||
"""员工在所选渠道下的 owner 账号(channel=None → 两渠道合并)。"""
|
||||
chs = ("wechat", "whatsapp") if channel is None else (channel,)
|
||||
out: list[str] = []
|
||||
for ch in chs:
|
||||
amap = await account_map(session, ch)
|
||||
out.extend(owner for owner, uid in amap.items() if uid == user_id)
|
||||
return out
|
||||
|
||||
|
||||
async def lifecycle_customers(
|
||||
session: AsyncSession,
|
||||
owner_accounts: list[str],
|
||||
as_of_day: int,
|
||||
want_state: str,
|
||||
) -> list[dict]:
|
||||
"""drilldown cooling/dormant:该员工账号归属客户(delete_flag 过滤)+
|
||||
msg_daily_stats friend 行全历史 → lifecycle.classify(as_of=窗口末日),
|
||||
返回状态匹配行 {friend_id, channel, nickname, last_day, state},按 last_day asc
|
||||
(最陈旧在前)、friend_id asc 排序。
|
||||
|
||||
历史按 (channel, peer) 键控、跨 owner 合并——与批次A _lifecycle_daily
|
||||
的 peer 键控口径一致(归属由 Customer.owner_account_id 决定,互动史
|
||||
不随归属走:共享/换归属客户的全部历史都算,否则 KPI 计了而名单丢人)。
|
||||
hist 为 ≤as_of 的历史消息数(时点语义,as_of 之后的消息不虚增沉睡门槛)。"""
|
||||
custs = (await session.execute(
|
||||
sa.select(Customer).where(
|
||||
sa.or_(Customer.delete_flag.is_(None), Customer.delete_flag == 0),
|
||||
Customer.owner_account_id.in_(owner_accounts),
|
||||
)
|
||||
)).scalars().all()
|
||||
if not custs:
|
||||
return []
|
||||
ids = [c.friend_id for c in custs]
|
||||
rows = (await session.execute(
|
||||
sa.select(
|
||||
MsgDailyStats.channel,
|
||||
MsgDailyStats.peer_id,
|
||||
MsgDailyStats.day,
|
||||
MsgDailyStats.send_cnt,
|
||||
MsgDailyStats.recv_cnt,
|
||||
).where(
|
||||
MsgDailyStats.peer_kind == "friend",
|
||||
MsgDailyStats.peer_id.in_(ids),
|
||||
)
|
||||
)).all()
|
||||
hist: dict[tuple[str, str], dict[int, int]] = {}
|
||||
for ch, peer, day, snd, rcv in rows:
|
||||
dm = hist.setdefault((ch, peer), {})
|
||||
dm[day] = dm.get(day, 0) + int(snd or 0) + int(rcv or 0)
|
||||
|
||||
matches: list[dict] = []
|
||||
for c in custs:
|
||||
dm = hist.get((c.channel, c.friend_id))
|
||||
if not dm:
|
||||
continue
|
||||
days_list = sorted(dm)
|
||||
idx = bisect_right(days_list, as_of_day) - 1
|
||||
if idx < 0:
|
||||
continue # 窗口末日之前从未互动
|
||||
last = days_list[idx]
|
||||
prev_d = days_list[idx - 1] if idx > 0 else None
|
||||
hist_msgs = sum(dm[d] for d in days_list[: idx + 1])
|
||||
state = classify(days_list[0], last, prev_d, as_of_day, hist_msgs)
|
||||
if state == want_state:
|
||||
matches.append({
|
||||
"friend_id": c.friend_id,
|
||||
"channel": c.channel,
|
||||
"nickname": c.friend_nickname or c.friend_remark or c.friend_id,
|
||||
"last_day": last,
|
||||
"state": state,
|
||||
})
|
||||
matches.sort(key=lambda m: (m["last_day"], m["friend_id"]))
|
||||
return matches
|
||||
|
||||
|
||||
async def fr_fact_rows(
|
||||
session: AsyncSession,
|
||||
owner_accounts: list[str],
|
||||
channel: str | None,
|
||||
start_day: int,
|
||||
end_day: int,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> tuple[int, list[dict]]:
|
||||
"""drilldown 首响分位:该员工账号窗内 fact 逐串证据行,fr_ms desc
|
||||
(同值按日/串末时刻 desc 决胜)分页。chat_hint 给出会话定位参数
|
||||
(owner+talker 直达聊天记录)。返回 (total, items)。"""
|
||||
conds = [
|
||||
FirstReplyFact.day.between(start_day, end_day),
|
||||
FirstReplyFact.owner_account_id.in_(owner_accounts),
|
||||
]
|
||||
if channel is not None:
|
||||
conds.append(FirstReplyFact.channel == channel)
|
||||
total = (await session.execute(
|
||||
sa.select(sa.func.count()).select_from(FirstReplyFact).where(*conds)
|
||||
)).scalar_one()
|
||||
rows = (await session.execute(
|
||||
sa.select(FirstReplyFact).where(*conds)
|
||||
.order_by(FirstReplyFact.fr_ms.desc(), FirstReplyFact.day.desc(),
|
||||
FirstReplyFact.thread_end_ts.desc())
|
||||
.offset((page - 1) * page_size).limit(page_size)
|
||||
)).scalars().all()
|
||||
items = [{
|
||||
"day": f.day,
|
||||
"channel": f.channel,
|
||||
"owner_account_id": f.owner_account_id,
|
||||
"peer_id": f.peer_id,
|
||||
"thread_end_ts": f.thread_end_ts,
|
||||
"reply_ts": f.reply_ts,
|
||||
"fr_ms": f.fr_ms,
|
||||
"chat_hint": {"owner_account_id": f.owner_account_id,
|
||||
"talker_id": f.peer_id},
|
||||
} for f in rows]
|
||||
return int(total), items
|
||||
|
||||
|
||||
async def user_calls_page(
|
||||
session: AsyncSession,
|
||||
user_id: str,
|
||||
start_day: int,
|
||||
end_day: int,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> tuple[int, list[dict]]:
|
||||
"""drilldown 通话跟进:该员工窗内通话行(start_call_time desc,
|
||||
call_id desc 决胜)分页。返回 (total, items)。"""
|
||||
lo, _ = day_bounds_ms(start_day)
|
||||
_, hi = day_bounds_ms(end_day) # 末日次日 00:00(左闭右开上界)
|
||||
conds = [
|
||||
PhoneCall.user_id == user_id,
|
||||
PhoneCall.start_call_time.is_not(None),
|
||||
PhoneCall.start_call_time >= lo,
|
||||
PhoneCall.start_call_time < hi,
|
||||
]
|
||||
total = (await session.execute(
|
||||
sa.select(sa.func.count()).select_from(PhoneCall).where(*conds)
|
||||
)).scalar_one()
|
||||
rows = (await session.execute(
|
||||
sa.select(PhoneCall).where(*conds)
|
||||
.order_by(PhoneCall.start_call_time.desc(), PhoneCall.call_id.desc())
|
||||
.offset((page - 1) * page_size).limit(page_size)
|
||||
)).scalars().all()
|
||||
items = [{
|
||||
"call_id": c.call_id,
|
||||
"channel": c.channel,
|
||||
"call_number": c.call_number,
|
||||
"start_call_time": c.start_call_time,
|
||||
"call_duration": c.call_duration,
|
||||
} for c in rows]
|
||||
return int(total), items
|
||||
@@ -88,12 +88,13 @@ def baseline_window(start: int) -> tuple[int, int]:
|
||||
return _to_key(bs), _to_key(be)
|
||||
|
||||
|
||||
def _percentile(vals: list[int], q_num: int, q_den: int) -> int | None:
|
||||
def percentile(vals: list[int], q_num: int, q_den: int) -> int | None:
|
||||
"""最近邻秩分位 ceil(q·n) 的样本值,q=q_num/q_den(1/2、9/10)。
|
||||
|
||||
整数算术 rank=(q_num*n + q_den - 1)//q_den 对正整数 n 恒落在 [1,n]
|
||||
(与 math.ceil(q·n) 等价,与批次A _percentile 同口径);空列表→None
|
||||
(与 0 严格区分)。
|
||||
公有供同包 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
|
||||
@@ -246,8 +247,8 @@ async def period_metrics(
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -108,6 +108,8 @@ def validate_config(config: dict) -> list[str]:
|
||||
- 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", {})
|
||||
@@ -125,6 +127,7 @@ def validate_config(config: dict) -> list[str]:
|
||||
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
|
||||
@@ -133,6 +136,13 @@ def validate_config(config: dict) -> list[str]:
|
||||
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")
|
||||
|
||||
@@ -602,50 +602,72 @@ 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"], None)] # (args, timeout)——full 不设超时(全量本身 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),
|
||||
("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",
|
||||
],
|
||||
None, # 每周对齐不设超时
|
||||
)
|
||||
for e in entities
|
||||
]
|
||||
else:
|
||||
@@ -653,8 +675,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
|
||||
@@ -703,8 +725,13 @@ 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。"""
|
||||
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 +741,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
|
||||
|
||||
@@ -514,7 +514,9 @@ 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]]:
|
||||
async def _discover_conversations(
|
||||
channel: str, voice_channel: str, active_since_ms: int | None = None
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Conversation-pair discovery for the message scrapers (wechat + WA).
|
||||
|
||||
Sources (union, dedup):
|
||||
@@ -527,6 +529,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 +543,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 = (
|
||||
@@ -558,16 +565,27 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
|
||||
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:
|
||||
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 +594,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 +655,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 +668,13 @@ 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
|
||||
),
|
||||
)
|
||||
watermarks = await _conversation_watermarks("wechat")
|
||||
cust_activity = await _customer_activity("wechat")
|
||||
|
||||
@@ -853,7 +879,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 +894,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()
|
||||
@@ -2143,7 +2174,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}).
|
||||
|
||||
@@ -2158,7 +2189,13 @@ 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
|
||||
),
|
||||
)
|
||||
watermarks = await _conversation_watermarks("whatsapp")
|
||||
cust_activity = await _customer_activity("whatsapp")
|
||||
|
||||
@@ -2281,7 +2318,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 同套路)。
|
||||
|
||||
@@ -2295,11 +2332,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()
|
||||
@@ -2473,6 +2515,8 @@ async def main():
|
||||
parser.add_argument("--max-pages", type=int, default=200, help="列表翻页上限 (accounts/wa-accounts/wa-friends/voice-calls/moments)")
|
||||
parser.add_argument("--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()
|
||||
|
||||
@@ -2496,9 +2540,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":
|
||||
@@ -2514,9 +2558,9 @@ async def _dispatch_entity(args, cookie: str) -> None:
|
||||
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":
|
||||
|
||||
@@ -0,0 +1,710 @@
|
||||
"""管理 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);
|
||||
- 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"]
|
||||
|
||||
# 趋势逐日 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
|
||||
anoms = body["anomalies"]
|
||||
assert anoms[0]["severity"] == "critical"
|
||||
assert anoms[0]["employee_user_id"] == "u2"
|
||||
assert all(a["severity"] == "critical" for a in anoms[:1])
|
||||
assert all(a["severity"] == "warning" for a in anoms[1:])
|
||||
|
||||
assert body["action_summary"] == {"unreplied_2h": 1, "churn_real_30d": 0,
|
||||
"sensitive_pending": 0}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 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)
|
||||
assert len(body["anomalies"]) == 2
|
||||
assert all(a["severity"] == "warning" for a in body["anomalies"])
|
||||
assert body["prev"]["msg_sent"] == 70
|
||||
assert body["baseline"]["msg_sent"] == 70
|
||||
assert body["data_as_of"] == _ts(2026, 9, 3, 9, 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- sections
|
||||
|
||||
|
||||
async def test_employee_sections(make_client):
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
|
||||
client = await make_client(seed)
|
||||
base = "/api/management/employees/u1/metrics"
|
||||
for section in ("overview", "service", "customer", "workload", "calls"):
|
||||
r = await client.get(base, params={
|
||||
"section": section, "channel": "wechat",
|
||||
"start": START, "end": END})
|
||||
assert r.status_code == 200, section
|
||||
body = r.json()
|
||||
assert body["section"] == section
|
||||
assert len(body["trend"]) == 5 # 全窗口逐日(含零填充)
|
||||
assert all("day" in t for t in body["trend"])
|
||||
assert body["comparisons"]["reply_2h_rate"]["current"] == 0.2
|
||||
assert set(body["comparisons"]["reply_2h_rate"]) == {
|
||||
"current", "prev", "baseline", "median"}
|
||||
|
||||
r = await client.get(base, params={"section": "service",
|
||||
"channel": "wechat",
|
||||
"start": START, "end": END})
|
||||
body = r.json()
|
||||
assert body["metrics"]["cust_threads"] == 10
|
||||
assert body["metrics"]["reply_2h_count"] == 2
|
||||
t0 = body["trend"][0]
|
||||
assert t0 == {"day": 20260901, "cust_threads": 2, "threads_answered": 2,
|
||||
"fr_sum_ms": 240_000, "first_reply_p50_ms": 110_000,
|
||||
"first_reply_p90_ms": 220_000}
|
||||
|
||||
r = await client.get(base, params={"section": "calls", "channel": "all",
|
||||
"start": START, "end": END})
|
||||
calls = {t["day"]: t for t in r.json()["trend"]}
|
||||
assert calls[20260901]["call_count"] == 2
|
||||
assert calls[20260901]["call_followup_count"] == 1
|
||||
assert calls[20260905]["call_count"] == 0 # 零填充
|
||||
assert r.json()["metrics"]["call_count"] == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- drilldown
|
||||
|
||||
|
||||
async def test_drilldown_fr_p90(make_client):
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
|
||||
client = await make_client(seed)
|
||||
url = "/api/management/employees/u2/drilldown"
|
||||
r = await client.get(url, params={
|
||||
"metric_code": "first_reply_p90", "channel": "all",
|
||||
"start": START, "end": END, "page": 1, "page_size": 1})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["total"] == 2
|
||||
assert len(body["items"]) == 1
|
||||
item = body["items"][0]
|
||||
# fr_ms desc + 同值按日 desc 决胜:0902 的 pD 在前
|
||||
assert item["fr_ms"] == 600_000
|
||||
assert item["peer_id"] == "pD"
|
||||
assert item["channel"] == "wechat"
|
||||
assert item["owner_account_id"] == "wx2"
|
||||
assert item["chat_hint"] == {"owner_account_id": "wx2", "talker_id": "pD"}
|
||||
r = await client.get(url, params={
|
||||
"metric_code": "first_reply_p90", "channel": "all",
|
||||
"start": START, "end": END, "page": 2, "page_size": 1})
|
||||
assert r.json()["items"][0]["peer_id"] == "pC"
|
||||
# u2 的 WA 账号无 fact 行
|
||||
r = await client.get(url, params={
|
||||
"metric_code": "first_reply_p50", "channel": "whatsapp",
|
||||
"start": START, "end": END})
|
||||
assert r.json()["total"] == 0
|
||||
|
||||
|
||||
async def test_drilldown_unreplied(make_client):
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/employees/u1/drilldown",
|
||||
params={"metric_code": "unreplied",
|
||||
"channel": "wechat",
|
||||
"start": START, "end": END})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["total"] == 1 # p1(wx1)进入,u1 过滤后
|
||||
assert body["items"][0]["peer_id"] == "p1"
|
||||
assert body["items"][0]["owner_account_id"] == "wx1"
|
||||
# u2 名下无未回候选(p3 尾部为员工消息)
|
||||
r = await client.get("/api/management/employees/u2/drilldown",
|
||||
params={"metric_code": "unreplied",
|
||||
"channel": "wechat",
|
||||
"start": START, "end": END})
|
||||
assert r.json()["total"] == 0
|
||||
|
||||
|
||||
async def test_drilldown_lifecycle_customers(make_client):
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/employees/u1/drilldown",
|
||||
params={"metric_code": "cooling_customers",
|
||||
"channel": "all",
|
||||
"start": START, "end": END})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["total"] == 1 # f2 降温;f1 沉睡、f9 已删
|
||||
assert body["items"][0] == {"friend_id": "f2", "channel": "wechat",
|
||||
"nickname": "客户二",
|
||||
"last_day": 20260825, "state": "cooling"}
|
||||
|
||||
r = await client.get("/api/management/employees/u1/drilldown",
|
||||
params={"metric_code": "dormant_customers",
|
||||
"channel": "all",
|
||||
"start": START, "end": END})
|
||||
body = r.json()
|
||||
assert body["total"] == 1
|
||||
assert body["items"][0]["friend_id"] == "f1"
|
||||
assert body["items"][0]["state"] == "dormant"
|
||||
|
||||
|
||||
async def test_drilldown_shared_customer_history(make_client):
|
||||
"""I1:共享/换归属客户——互动史挂在他人账号(wx2)名下而 Customer.owner
|
||||
=wx1:历史按 (channel, peer) 键控跨 owner 合并(对齐批次A _lifecycle_daily
|
||||
的 peer 键控口径),客户不丢、历史完整(hist 12 ≥ 5 判沉睡)。"""
|
||||
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
for day, month in ((20260701, 7), (20260801, 8)):
|
||||
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx2",
|
||||
peer_kind="friend", peer_id="f3", day=day,
|
||||
send_cnt=3, recv_cnt=3,
|
||||
last_ts=_ts(2026, month, day % 100, 12, 0),
|
||||
last_is_send=True))
|
||||
s.add(Customer(friend_id="f3", channel="wechat",
|
||||
owner_account_id="wx1", friend_nickname="共享客户",
|
||||
user_id="u1", delete_flag=0))
|
||||
await s.commit()
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/employees/u1/drilldown",
|
||||
params={"metric_code": "dormant_customers",
|
||||
"channel": "all",
|
||||
"start": START, "end": END})
|
||||
body = r.json()
|
||||
assert body["total"] == 2 # f1 + f3(历史全在 wx2 名下也不丢)
|
||||
f3 = next(i for i in body["items"] if i["friend_id"] == "f3")
|
||||
assert f3 == {"friend_id": "f3", "channel": "wechat",
|
||||
"nickname": "共享客户",
|
||||
"last_day": 20260801, "state": "dormant"}
|
||||
# 归属口径仍是 Customer.owner(wx1):u2(wx2)名下看不到
|
||||
r = await client.get("/api/management/employees/u2/drilldown",
|
||||
params={"metric_code": "dormant_customers",
|
||||
"channel": "all",
|
||||
"start": START, "end": END})
|
||||
assert r.json()["total"] == 0
|
||||
|
||||
|
||||
async def test_drilldown_call_followup(make_client):
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/employees/u1/drilldown",
|
||||
params={"metric_code": "call_followup",
|
||||
"channel": "all",
|
||||
"start": START, "end": END})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["total"] == 2
|
||||
# start_call_time desc:c2(0902)在前
|
||||
assert [i["call_id"] for i in body["items"]] == ["c2", "c1"]
|
||||
assert set(body["items"][0]) == {
|
||||
"call_id", "channel", "call_number", "start_call_time",
|
||||
"call_duration"}
|
||||
assert body["summary"] == {"call_count": 2, "call_seconds": 100,
|
||||
"answered_call_count": 1,
|
||||
"call_followup_count": 1}
|
||||
|
||||
|
||||
# ------------------------------------------------------- metric-definitions
|
||||
|
||||
|
||||
async def test_metric_definitions(make_client):
|
||||
async def seed(s):
|
||||
await _seed_common(s)
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/management/metric-definitions")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["definitions"]["cust_threads"]["unit"] == "串"
|
||||
assert body["score_config"]["trial"] is True
|
||||
assert body["score_config"]["dimensions"]["risk"]["status"] == "pending_data"
|
||||
assert body["config_source"] == "default" # 未种子配置→回退默认
|
||||
assert "1:1" in body["caliber_notes"]
|
||||
@@ -300,6 +300,16 @@ def test_validate_config_cross_dimension_code_unique():
|
||||
assert any("active_share" in p and "重复" in p for p in validate_config(dup))
|
||||
|
||||
|
||||
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) == []
|
||||
|
||||
@@ -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,25 @@ 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) == 9
|
||||
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(commands[0]) == "accounts"
|
||||
assert entity_of(commands[1]) == "wa-accounts"
|
||||
assert entity_of(commands[2]) == "friends"
|
||||
assert entity_of(commands[3]) == "wa-friends"
|
||||
# 四类消息:24h 活跃窗(2026-09-10 根治调度堵塞)
|
||||
for args in commands[4:8]:
|
||||
assert 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"
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// 经营管理板块 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
|
||||
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 }
|
||||
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')
|
||||
@@ -0,0 +1,101 @@
|
||||
<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 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/标题+原因
|
||||
// 双行/单位感知当前值+基线/样本/影响(≥50 红显)/建议动作。行 click 打开
|
||||
// 下钻抽屉(emit drill,抽屉自治加载);critical 整行淡红底提示优先处置。
|
||||
// 纯展示+事件,不取数。
|
||||
import { formatMsDuration, formatRatio } from '@/utils/format'
|
||||
import type { AnomalyCard } from '@/api/management'
|
||||
|
||||
defineProps<{
|
||||
anomalies: AnomalyCard[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'drill', card: AnomalyCard): void
|
||||
}>()
|
||||
|
||||
// 单位感知格式化: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-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,103 @@
|
||||
<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 label="近7天" value="last7" />
|
||||
<el-option label="近30天" value="last30" />
|
||||
<el-option label="本月" value="thisMonth" />
|
||||
<el-option label="上月" value="lastMonth" />
|
||||
<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,63 @@
|
||||
<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="hint" class="kpi-hint">{{ hint }}</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 经营管理 KPI 卡:label 上小灰字 + 主值大号字(千分位/unit 后缀/danger 红)
|
||||
// + 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
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const valueText = computed(() => (props.value === null ? '--' : props.value.toLocaleString()))
|
||||
|
||||
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-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,119 @@
|
||||
// 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('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,78 @@
|
||||
// 管理页统一筛选(URL query 为真源——刷新/分享保持)。批次C:驾驶舱用;
|
||||
// 批次D 员工页复用(view 等额外参数由页面自行挂 query)。
|
||||
import { computed } 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 <= 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 }
|
||||
}
|
||||
@@ -99,6 +99,16 @@ 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-sub-menu>
|
||||
|
||||
<!-- 数据分析 -->
|
||||
<el-sub-menu index="analytics">
|
||||
<template #title>
|
||||
|
||||
@@ -92,6 +92,13 @@ 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: '管理驾驶舱' },
|
||||
},
|
||||
{
|
||||
path: '/chat/:channel/:ownerId/:talkerId?',
|
||||
name: 'chat',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Download } from '@element-plus/icons-vue'
|
||||
import { getAccounts, getGroups } from '@/api'
|
||||
import { downloadBlob } from '@/api/client'
|
||||
import type { Account, Channel, Group } from '@/api/types'
|
||||
import { useChannelStore } from '@/stores/channel'
|
||||
import { channelLabel, formatTime } from '@/utils/format'
|
||||
@@ -190,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 守卫。 */
|
||||
@@ -199,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)
|
||||
|
||||
@@ -444,11 +424,9 @@ onMounted(() => {
|
||||
<el-table-column label="沟通消息数" width="105" align="right">
|
||||
<template #default="{ row }">{{ row.window_msg_count ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right" align="center">
|
||||
<el-table-column label="操作" width="110" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click.stop="openDrawer(row)">查看详情</el-button>
|
||||
<el-button link type="primary" size="small" @click.stop="viewChat(row)">查看聊天记录</el-button>
|
||||
<el-button link type="primary" size="small" @click.stop="exportGroupHtml(row)">导出HTML</el-button>
|
||||
<el-button link type="primary" size="small" @click.stop="viewChat(row)">查看详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
@@ -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 '-'
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
<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"
|
||||
unit="人"
|
||||
:hint="threadsHint"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8">
|
||||
<ManagementKpiCard
|
||||
label="活跃员工"
|
||||
:value="kpis?.active_employees ?? null"
|
||||
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"
|
||||
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 同载荷,管理域跨日精确串)
|
||||
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