Compare commits
15 Commits
5bfcebf20c
...
b0ebe8a15a
| Author | SHA1 | Date | |
|---|---|---|---|
| b0ebe8a15a | |||
| 03c08881b6 | |||
| 07c9d091a1 | |||
| c9f8c844d6 | |||
| b51b32a14b | |||
| 729710da63 | |||
| 8b555a5ad4 | |||
| afc71e61ef | |||
| d523813435 | |||
| 7758a3351f | |||
| 639edf56a6 | |||
| 0fce21aaf7 | |||
| b2e8edacc2 | |||
| e725c7032a | |||
| ad3beb7625 |
@@ -228,6 +228,32 @@ alembic 链自当前线上版本起新增 3 支迁移:`a7d3e9f1c2b4` msg_daily_s
|
||||
幂等自动补种,无需手插;后续加新词默认只扫增量,要扫历史消息需重跑
|
||||
`--full`。
|
||||
|
||||
### 管理后台批次A附加步骤(一次性,2026-09-09)
|
||||
|
||||
迁移链新增 `b7c8d9e0f1a2`(经营管理域六表:first_reply_fact /
|
||||
employee_metric_daily / employee_call_daily / employee_score_config /
|
||||
employee_score_snapshot / management_followup)。**服务器 backend 容器
|
||||
不会自动跑 alembic**,须手动:
|
||||
|
||||
```bash
|
||||
sudo docker compose -f docker-compose.server.yml exec backend alembic upgrade head
|
||||
```
|
||||
|
||||
部署带该迁移后在**夜间低峰**跑两条一次性回填(幂等;本地实测耗时:
|
||||
fact 72s / 宽表+通话 71s,均分钟级):
|
||||
|
||||
```bash
|
||||
sudo docker compose -f docker-compose.server.yml exec backend python -m app.sync.cli first-reply-facts
|
||||
sudo docker compose -f docker-compose.server.yml exec backend python -m app.sync.cli employee-metrics
|
||||
```
|
||||
|
||||
- `first-reply-facts`:跨日精确首响事实表(本地实测 11,020 会话 → 6.8 万行)。
|
||||
- `employee-metrics`:员工指标宽表 + 通话日小表(本地实测 12,371 宽表行 +
|
||||
1,163 通话行);**必须在 first-reply-facts 之后跑**——fact 表是后续评分/
|
||||
下钻的数据源,须先填充(宽表自身从 message 表经同一算法重算串类列,
|
||||
不读 fact 表,先跑宽表不会出 0,但 fact 缺失会阻塞后续能力)。
|
||||
- 两命令均为按日 delete+insert 幂等重建,复跑结果一致,中断重跑无副作用。
|
||||
|
||||
## 8. 本地收尾(部署验证通过后)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -170,3 +170,74 @@
|
||||
| 6 | 群消息占总消息量 64%(微信侧 69%) | Task 3/6(friend 过滤必须,佐证 spec 口径) |
|
||||
| 7 | call_number↔talker_id 命中率 voice_call 95.0% / wa_voice_call 99.3% | Task 5(call_followup_count 高置信,不降级) |
|
||||
| 8 | staff 41 行无幽灵账号;`stat_daily_staff` 仅 40 名员工有行 | Task 5/6(known_user_ids 过滤零损耗) |
|
||||
|
||||
---
|
||||
|
||||
# 批次A回填实测与对账(Task 8,2026-09-09)
|
||||
|
||||
- **环境**:本地 Docker MySQL(`yunke-mysql-1`),数据快照同上(stat/msg 覆盖 20251024~20260907)。
|
||||
- **方式**:Task 7 真机全量回填后本任务只读对账抽查(未重跑回填);对账前全库同源校验复验一次,确认库状态即 Task 7 收尾状态。
|
||||
|
||||
## 1. 回填实测汇总(Task 7 真机数字,Task 8 复验)
|
||||
|
||||
| 项目 | 实测 |
|
||||
|---|---|
|
||||
| `first-reply-facts` | 11,020 会话 → **68,476** fact 行,**72s**(冷) |
|
||||
| `employee-metrics` | **12,371** 宽表行(day×channel×user)+ **1,163** 通话行(**314** 天并集),**71s** 冷 / 36s 热 |
|
||||
| 幂等复跑 | 两命令复跑结果一致(delete+insert 语义,无重复无漂移) |
|
||||
| 全库同源 | `SUM(employee_metric_daily.threads_answered)` = **68,476** ≡ fact 行数(Task 8 复验通过) |
|
||||
| 孤儿检查 | 宽表/通话表 user_id 不在 staff = **0**;fact 账号不在对应渠道账号表 = **0**(Task 8 复验通过) |
|
||||
| 通话日并集修复 | `employee_call_daily` 覆盖 **314** 天(修复前 306):8 个消息空窗日找回 27 通话 / 13 接通 |
|
||||
|
||||
## 2. 对账抽查(2 员工 × 3 日期 × 三组)
|
||||
|
||||
员工取自本文回测名单:**王云青** `369D257EEC9A4357935FDE19DDD99CAA`(#1,高负载+群运营,混合口径差异最大场景;1 微信 + 2 WA 账号)、**田瑞志** `9C0B195A2C234C829FF507E011B4B460`(#2,WA 为主;1 微信 + 2 WA 账号)。日期 20260815 / 20260801 / 20260601(近/中/远)。跨渠道员工按 channel 分别对账(①②组逐渠道逐日须一致)。
|
||||
|
||||
判定标准:**①组(消息计数列 ↔ msg_daily_stats friend 聚合)与 ②组(串类列 ↔ fact 行数)必须逐日一致;③组(宽表串类 cust_threads ↔ 日切 stat_daily_staff.cust_threads)允许差异,差值=跨夜串归属效应。**
|
||||
|
||||
### 王云青
|
||||
|
||||
| 渠道 | 日期 | ① mds sent/recv | ① 宽表 sent/recv | ① 判定 | ② fact 行 | ② 宽表 answered | ② 判定 | ③ 宽表 threads | ③ 日切 threads | ③ 差值 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| wechat | 20260601 | 186 / 177 | 186 / 177 | ✅ | 76 | 76 | ✅ | 76 | 121 | 45 |
|
||||
| wechat | 20260801 | 0 / 38 | 0 / 38 | ✅ | 0 | 0 | ✅ | 0 | 16 | 16 |
|
||||
| wechat | 20260815 | 0 / 23 | 0 / 23 | ✅ | 0 | 0 | ✅ | 0 | 17 | 17 |
|
||||
| whatsapp | 20260601 | (无行) | 0 / 0 | ✅ | (无行) | 0 | ✅ | 0 | (无行) | — |
|
||||
| whatsapp | 20260801 | 0 / 1 | 0 / 1 | ✅ | 1 | 1 | ✅ | 1 | 1 | 0 |
|
||||
| whatsapp | 20260815 | 0 / 4 | 0 / 4 | ✅ | 1 | 1 | ✅ | 1 | 2 | 1 |
|
||||
|
||||
### 田瑞志
|
||||
|
||||
| 渠道 | 日期 | ① mds sent/recv | ① 宽表 sent/recv | ① 判定 | ② fact 行 | ② 宽表 answered | ② 判定 | ③ 宽表 threads | ③ 日切 threads | ③ 差值 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| wechat | 三日期均 | (无行) | 0 / 0 | ✅ | (无行) | 0 | ✅ | 0 | (无行) | — |
|
||||
| whatsapp | 20260601 | 1 / 1 | 1 / 1 | ✅ | 1 | 1 | ✅ | 1 | 1 | 0 |
|
||||
| whatsapp | 20260801 | (无行) | 0 / 0 | ✅ | (无行) | 0 | ✅ | 0 | 2 | 2 |
|
||||
| whatsapp | 20260815 | (无行) | 0 / 0 | ✅ | (无行) | 0 | ✅ | 0 | (无行) | — |
|
||||
|
||||
注:「无行」= 源表该 (渠道, 日) 无该员工数据,宽表记 0/0——一致。20260601 无 WA 数据未换日期:田瑞志 whatsapp 该日恰好有 1 条收发(对账有效),王云青 whatsapp 全期仅 369 条消息、无行属真实分布。
|
||||
|
||||
**结论:①②组 12/12 逐日全部一致(含渠道拆分);③组宽表全部 ≤ 日切值。**
|
||||
|
||||
## 3. 跨夜串差值观察(③组差异解释)
|
||||
|
||||
- **归属语义**:宽表串按「串末条客户消息所在上海自然日」归属(`first_reply_facts.compute_conversation`);日切表按「当日有客户消息的 peer 数」计。串末条必在归属日当天 ⇒ 结构上保证 ③组逐日 宽表 ≤ 日切,差值=归属到其他日终了的跨夜/多日串。
|
||||
- **差值形态**:王云青 wechat 三日差 45/16/17——该员工深夜消息多(20260801/15 发送 0、仅收 38/23 条,客户消息跨零点后串归属顺延);whatsapp 差 0/1。田瑞志 whatsapp 20260801 差 2(2 个 peer 当日来消息但串末条在次日)。
|
||||
- **串未丢失验证**:王云青 wechat 全期 宽表 12,055 threads(11,930 answered,314 天)vs 日切 20,981(14,647 answered,312 天)——串模型把多日串折叠为 1(比值 57.4%),且抽查 20260814~31 逐日,日切 17 线程的 20260815 对应串终落在真实结束日(0819=8、0821=10、0828=9),无凭空丢失。
|
||||
- 另:宽表 `cust_threads ≥ threads_answered`(如王云青 wechat 20260602 = 54/52)——未闭合尾串计入分母不产 fact 样本,与算法注释一致。
|
||||
|
||||
## 4. 批次A验收清单(Task 8 收尾核对)
|
||||
|
||||
| # | 项 | 状态 | 依据 |
|
||||
|---|---|---|---|
|
||||
| 1 | 6 张表本地存在,migration 幂等 + downgrade 干净 | ✅ | Task 2(迁移 `b7c8d9e0f1a2`,alembic head 实测一致;表实测在库:first_reply_fact / employee_metric_daily / employee_call_daily / employee_score_config / employee_score_snapshot / management_followup) |
|
||||
| 2 | first-reply-facts 全量回填成功且复跑幂等 | ✅ | Task 7(本文 §1) |
|
||||
| 3 | employee-metrics 全量回填成功且复跑幂等 | ✅ | Task 7(本文 §1) |
|
||||
| 4 | 跨夜串判别测试通过 | ✅ | Task 3(test_first_reply_facts.py 恰 12 例,Task 8 复跑全绿;含跨夜/多日串/同毫秒并列) |
|
||||
| 5 | friend-only 口径判别测试通过 | ✅ | Task 6(test_all_friend_pairs_only_friend + 宽表 friend 过滤判别例) |
|
||||
| 6 | 同源判别测试通过 + 全库 SUM 验证 | ✅ | Task 3/6;全库 68,476 ≡ 68,476(本文 §1 复验) |
|
||||
| 7 | 幽灵员工过滤测试通过 | ✅ | Task 5/6;孤儿=0(本文 §1 复验) |
|
||||
| 8 | 对账抽查 ①②一致、③差异可解释 | ✅ | 本文 §2/§3 |
|
||||
| 9 | 全量测试套件绿 | ✅ | **653 passed in 62s**(Task 8 本机实跑,2026-09-09) |
|
||||
| 10 | 核实文档完整 | ✅ | 本文补齐 |
|
||||
| 11 | 写钩子零改动 | ✅ | 批次A全部 17 个 commits(27f91e5→0fce21a)逐一核查,无一触碰 storage/summary.py、storage/repos.py(repos.py 最近一次改动 d428953 属 customer_account_link 工作线,先于批次A) |
|
||||
|
||||
@@ -1627,6 +1627,7 @@ cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_employee_metric
|
||||
```python
|
||||
import math # (文件头补充)
|
||||
from bisect import bisect_right
|
||||
from itertools import accumulate
|
||||
|
||||
from app.analytics.lifecycle import classify
|
||||
from app.db.models import Customer, EmployeeMetricDaily, MsgDailyStats
|
||||
@@ -1657,8 +1658,11 @@ async def _lifecycle_daily(
|
||||
) -> dict[int, dict[str, dict[str, int]]]:
|
||||
"""逐日 {day: {user: {state: count}}}——customer 当前归属 + 互动日 sweep。
|
||||
|
||||
数据源:msg_daily_stats friend 行全历史(互动日/条数)。16k 客户 × 窗口
|
||||
天数 bisect,夜间全量约秒级;4h tick 只算当日+3 天。
|
||||
数据源:msg_daily_stats friend 行全历史(互动日/条数)。hist 为 ≤as_of
|
||||
的历史消息数(时点语义:与 last 同一 bisect 位取按日累积数组 cum[idx],
|
||||
as_of 之后的消息不虚增历史日的沉睡门槛——Task6 评审修正)。16k 客户 ×
|
||||
窗口天数 bisect+classify,夜间全量实测见批次A核实文档;4h tick 只算
|
||||
当日+3 天。
|
||||
"""
|
||||
rows = (
|
||||
await session.execute(
|
||||
@@ -1671,12 +1675,13 @@ async def _lifecycle_daily(
|
||||
)
|
||||
)
|
||||
).all()
|
||||
days_map: dict[str, set[int]] = {}
|
||||
cnt: dict[str, int] = {}
|
||||
# peer → {day: 消息条数};days_list 与累积数组 cum 对齐:
|
||||
# cum[i] = 该客户 ≤days_list[i] 的历史消息数(hist 时点语义)
|
||||
day_msgs: dict[str, dict[int, int]] = {}
|
||||
for peer, day, snd, rcv in rows:
|
||||
days_map.setdefault(peer, set()).add(day)
|
||||
cnt[peer] = cnt.get(peer, 0) + int(snd or 0) + int(rcv or 0)
|
||||
interaction = {p: sorted(ds) for p, ds in days_map.items()}
|
||||
dm = day_msgs.setdefault(peer, {})
|
||||
dm[day] = dm.get(day, 0) + int(snd or 0) + int(rcv or 0)
|
||||
interaction = {p: sorted(dm) for p, dm in day_msgs.items()}
|
||||
|
||||
custs = (
|
||||
await session.execute(
|
||||
@@ -1699,14 +1704,14 @@ async def _lifecycle_daily(
|
||||
if uid is None:
|
||||
continue
|
||||
first = days_list[0]
|
||||
hist = cnt.get(peer, 0)
|
||||
cum = list(accumulate(day_msgs[peer][d] for d in days_list))
|
||||
for d in days:
|
||||
idx = bisect_right(days_list, d) - 1
|
||||
if idx < 0:
|
||||
continue
|
||||
last = days_list[idx]
|
||||
prev = days_list[idx - 1] if idx > 0 else None
|
||||
state = classify(first, last, prev, d, hist)
|
||||
state = classify(first, last, prev, d, cum[idx])
|
||||
if state:
|
||||
out[d].setdefault(uid, {}).setdefault(state, 0)
|
||||
out[d][uid][state] += 1
|
||||
@@ -1832,6 +1837,7 @@ async def recompute_metric_days(
|
||||
)
|
||||
)
|
||||
written += 1
|
||||
await session.flush() # 防御性:防 autoflush=False 配置下 ORM 对象堆积(Task6 评审修正)
|
||||
return written
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
# 云客经营管理后台·批次B(评分/异常/管理API)实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在批次A数据底座之上交付只读管理 API——期聚合查询、五维试算评分、确定性异常规则、指标元数据、6 个 `/api/management/*` 端点,OpenAPI 可验证;前端(批次C)紧随其后。
|
||||
|
||||
**Architecture:** 新建 `app/management/` 业务层(metric_definitions / employee_metrics 期聚合 / scoring / anomalies),`app/api/routers/management.py` 只做参数校验与编排(照 analytics 惯例:SessionDep + Annotated 参数 + dict 返回)。**本期只做 6 个只读端点**(dashboard / employees / employee detail / metrics sections / drilldown 白名单 / metric-definitions);reviews 与 followups 写端点属批次E。依据:[v2 计划](./2026-09-08-yunke-management-console-implementation-v2.md) §1(决策记录)/§6(API 设计)/§8(Task 3-5);批次A已交付全部数据表与回填。
|
||||
|
||||
**Tech Stack:** 同批次A(FastAPI + SQLAlchemy async;测试内存 SQLite;生产 MySQL 8.4)。
|
||||
|
||||
---
|
||||
|
||||
## 执行者必读:批次A交付物与既定口径
|
||||
|
||||
1. **数据表就绪**(迁移 b7c8d9e0f1a2,本地已回填):`first_reply_fact`(68,476 行,PK 含 reply_msg_id)、`employee_metric_daily`(12,371 行,metric_version='v1',串类列与 fact 同源)、`employee_call_daily`(1,163 行)、`employee_score_config`(空,本批次 B0 种子)。
|
||||
2. **复用模块**:`app/storage/first_reply_facts.py`(fact 表直接查询)、`app/analytics/lifecycle.py`(阈值常量)、`app/analytics/metrics.py` 的 `action_counts`(dashboard 行动入口聚合)、`app/analytics/health.py` 的 `data_as_of`。
|
||||
3. **周期口径**:start/end 为 yyyymmdd 上海自然日(与 analytics custom scope 同校验:缺一 422、日历非法 422、start>end 422、跨度 ≤366 天);上期=等长紧邻前窗;个人基线=紧邻当前窗之前的 28 天窗。
|
||||
4. **评分默认(全部标「试算」,config 里可调)**:维度权重 客户经营30/服务25/行动执行20(pending_data)/有效负载15/合规风险10(pending_data);可用维度归一化 ×100/70;样本门槛 min=30 串/soft=10 串(周期内);等级 A≥90/B≥80/C≥70/D≥60/E。
|
||||
5. **鉴权**:app 级 `require_auth` 全局生效,management 路由无需额外处理;`GET` only。
|
||||
6. **测试命令**:`cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_management_api.py -v`;提交中文 conventional;master 直提(项目惯例);仓库有并行会话活动——`git status` 后只 add 自己的文件。
|
||||
7. **前端不做**(批次C另出计划);`schemas_management.py` 留批次E写端点用(analytics 惯例 dict 返回,本期一致);错误响应沿用 analytics 的 `HTTPException(422/404, message)` 风格(v1 §12.4 的稳定 error_code 为有意偏差,前端按 HTTP 状态码分支)。
|
||||
|
||||
## 文件地图
|
||||
|
||||
| 文件 | 职责 | 动作 |
|
||||
|---|---|---|
|
||||
| `yunke-server/app/management/__init__.py` | 包标记(空) | 新建 |
|
||||
| `yunke-server/app/management/metric_definitions.py` | 指标元数据注册表 + 默认评分配置 DEFAULT_SCORE_CONFIG | 新建 |
|
||||
| `yunke-server/app/management/employee_metrics.py` | 期聚合查询(宽表 SUM/fact 窗口函数分位/期末生命周期/通话/distinct 活跃/四对比) | 新建 |
|
||||
| `yunke-server/app/management/scoring.py` | 标准化/维度分/归一化试算/样本保护/等级 | 新建 |
|
||||
| `yunke-server/app/management/anomalies.py` | 4 条确定性规则 → v1 §5.4 异常卡片 | 新建 |
|
||||
| `yunke-server/app/sync/cli.py` | `score-config` 种子子命令 | 修改 |
|
||||
| `yunke-server/app/api/routers/management.py` | 6 只读端点 | 新建 |
|
||||
| `yunke-server/app/api/main.py` | include_router | 修改 |
|
||||
| `yunke-server/tests/test_management_scoring.py` 等 4 个测试文件 | 判别测试 | 新建 |
|
||||
| `docs/superpowers/plans/2026-09-08-management-batch-a-verification.md` | 批次B冒烟数据追加 | 修改(B5) |
|
||||
|
||||
---
|
||||
|
||||
### Task B0: 指标元数据 + 默认评分配置 + 种子 CLI
|
||||
|
||||
**Files:**
|
||||
- Create: `yunke-server/app/management/__init__.py`(空文件)
|
||||
- Create: `yunke-server/app/management/metric_definitions.py`
|
||||
- Modify: `yunke-server/app/sync/cli.py`(`score-config` 子命令)
|
||||
- Test: `yunke-server/tests/test_management_scoring.py`(本任务先写配置/种子部分)
|
||||
|
||||
- [ ] **Step 1: 写测试(先失败)**
|
||||
|
||||
`tests/test_management_scoring.py`:
|
||||
|
||||
```python
|
||||
"""管理域评分配置/引擎判别测试(批次B)。"""
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from app.db.models import EmployeeScoreConfig
|
||||
from app.management.metric_definitions import (
|
||||
DEFAULT_SCORE_CONFIG,
|
||||
METRIC_DEFINITIONS,
|
||||
validate_config,
|
||||
)
|
||||
|
||||
|
||||
def test_config_weights_sum_and_structure():
|
||||
dims = DEFAULT_SCORE_CONFIG["dimensions"]
|
||||
assert sum(d["weight"] for d in dims.values()) == 100
|
||||
# 一期 pending_data 的维度不参与试算
|
||||
available = [d for d in dims.values() if d.get("status") != "pending_data"]
|
||||
assert sum(d["weight"] for d in available) == 70 # 30+25+15
|
||||
assert dims["action"]["status"] == "pending_data"
|
||||
assert dims["risk"]["status"] == "pending_data"
|
||||
|
||||
|
||||
def test_config_normalization_declared():
|
||||
assert DEFAULT_SCORE_CONFIG["normalization"] == "available_only_rescale"
|
||||
|
||||
|
||||
def test_validate_config_rejects_bad_weight_sum():
|
||||
bad = json.loads(json.dumps(DEFAULT_SCORE_CONFIG)) # deep copy
|
||||
bad["dimensions"]["service"]["weight"] = 26 # 总和 101
|
||||
problems = validate_config(bad)
|
||||
assert any("权重" in p for p in problems)
|
||||
|
||||
|
||||
def test_metric_definitions_cover_scored_metrics():
|
||||
# 每个参与评分的 metric_code 都有元数据(名称/单位/方向)
|
||||
for dim in DEFAULT_SCORE_CONFIG["dimensions"].values():
|
||||
for code in dim.get("metrics", {}):
|
||||
md = METRIC_DEFINITIONS[code]
|
||||
assert md["name"] and md["unit"] and md["direction"] in (1, -1)
|
||||
|
||||
|
||||
async def test_seed_and_idempotent(make_session):
|
||||
from app.sync.cli import _seed_score_config # 纯会话函数(见 Step 3)
|
||||
s = make_session
|
||||
v1 = await _seed_score_config(s)
|
||||
await s.commit()
|
||||
assert v1 == "v1.0"
|
||||
rows = (await s.execute(sa.select(EmployeeScoreConfig))).scalars().all()
|
||||
assert len(rows) == 1 and rows[0].status == "active"
|
||||
# 幂等:已存在同版本返回 None 不新建
|
||||
assert await _seed_score_config(s) is None
|
||||
await s.commit()
|
||||
assert len((await s.execute(sa.select(EmployeeScoreConfig))).scalars().all()) == 1
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**(ModuleNotFoundError: app.management)
|
||||
|
||||
- [ ] **Step 3: 实现 metric_definitions.py**
|
||||
|
||||
```python
|
||||
"""管理域指标元数据与默认评分配置(批次B,v2 §6/§10)。
|
||||
|
||||
METRIC_DEFINITIONS 是 /api/management/metric-definitions 的单一数据源:
|
||||
名称/单位/方向/口径说明(含与现有看板的三方口径差异标注)。
|
||||
DEFAULT_SCORE_CONFIG 为 v1.0 试算起点——所有阈值标「试算」,调整走
|
||||
新配置版本(employee_score_config),不静默改数。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# direction: +1 越大越好 / -1 越小越好;caliber 注明与看板差异
|
||||
METRIC_DEFINITIONS: dict[str, dict] = {
|
||||
"msg_sent": {"name": "发出消息(1:1)", "unit": "条", "direction": 1,
|
||||
"caliber": "仅私聊;现有看板为私聊+群聊混合"},
|
||||
"msg_recv": {"name": "收到消息(1:1)", "unit": "条", "direction": 1,
|
||||
"caliber": "仅私聊"},
|
||||
"cust_threads": {"name": "客户消息串", "unit": "串", "direction": 1,
|
||||
"caliber": "跨日精确,按串末条归属日;看板为日切口径(多日串重复计)"},
|
||||
"threads_answered": {"name": "被回串数", "unit": "串", "direction": 1,
|
||||
"caliber": "≡first_reply_fact行数(同源)"},
|
||||
"fr_avg_ms": {"name": "平均首响", "unit": "ms", "direction": -1,
|
||||
"caliber": "fr_sum_ms/threads_answered"},
|
||||
"fr_p50_ms": {"name": "首响中位数", "unit": "ms", "direction": -1,
|
||||
"caliber": "周期内 fact 精确分位(窗口函数)"},
|
||||
"fr_p90_ms": {"name": "首响P90", "unit": "ms", "direction": -1,
|
||||
"caliber": "周期内 fact 精确分位;日切口径会丢跨夜长尾"},
|
||||
"reply_2h_rate": {"name": "2h回复率", "unit": "0-1", "direction": 1,
|
||||
"formula": "count(fr_ms<=2h)/cust_threads",
|
||||
"caliber": "分子分母同源(fact/宽表同一算法)"},
|
||||
"reply_24h_rate": {"name": "24h回复率", "unit": "0-1", "direction": 1,
|
||||
"formula": "count(fr_ms<=24h)/cust_threads"},
|
||||
"active_friends": {"name": "活跃客户(周期)", "unit": "人", "direction": 1,
|
||||
"caliber": "期内distinct客户(msg_daily_stats直查),非宽表日值求和"},
|
||||
"active_rooms": {"name": "活跃群(日均)", "unit": "个", "direction": 1},
|
||||
"new_customer_count": {"name": "新客(期末)", "unit": "人", "direction": 1,
|
||||
"caliber": "期末单日持有状态,非期内求和"},
|
||||
"cooling_customer_count": {"name": "降温客户(期末)", "unit": "人", "direction": -1,
|
||||
"caliber": "距今天数口径(7-29天);频次基线口径二期"},
|
||||
"dormant_customer_count": {"name": "沉睡客户(期末)", "unit": "人", "direction": -1,
|
||||
"caliber": "≥30天且历史≥5条"},
|
||||
"reactivated_customer_count": {"name": "回流客户(期内)", "unit": "人", "direction": 1,
|
||||
"caliber": "当日回流且前次间隔>30天,期内求和"},
|
||||
"call_count": {"name": "通话数", "unit": "通", "direction": 1,
|
||||
"caliber": "语音通话(微信+WA),无消息渠道维度"},
|
||||
"answered_rate": {"name": "接通率", "unit": "0-1", "direction": 1,
|
||||
"formula": "answered_call/call_count", "caliber": "接通=duration>0"},
|
||||
"call_followup_rate": {"name": "通话后跟进率", "unit": "0-1", "direction": 1,
|
||||
"formula": "call_followup_count/answered_call",
|
||||
"caliber": "接通后24h内任一账号有1:1消息(含客户发来)"},
|
||||
"active_share": {"name": "活跃客户占比", "unit": "0-1", "direction": 1,
|
||||
"formula": "期末active_friends/(期末active+cooling+dormant)",
|
||||
"caliber": "试算口径:期末单日存量"},
|
||||
}
|
||||
|
||||
# 阈值全部为试算起点(v2 §0 决议:全默认+试算标记);type 三种:
|
||||
# target : floor..target 线性 0..100(方向已含在 target>floor)
|
||||
# reverse : ≤target_ms 100 分,≥floor_ms 0 分,中间线性降
|
||||
# band : 区间内 80..100,低于下限不评分(None),超上限不再加分
|
||||
DEFAULT_SCORE_CONFIG: dict = {
|
||||
"version": "v1.0",
|
||||
"name": "一期试算(3维可用)",
|
||||
"normalization": "available_only_rescale", # 可用维度 100/Σweight 再 ×100
|
||||
"trial": True,
|
||||
"sample_guards": {"min_threads": 30, "soft_threads": 10, "min_days": 5},
|
||||
"grade_bounds": {"A": 90, "B": 80, "C": 70, "D": 60}, # 低于 D 线为 E
|
||||
"dimensions": {
|
||||
"customer": {"weight": 30, "name": "客户经营", "metrics": {
|
||||
"active_share": {"type": "target", "floor": 0.20, "target": 0.60},
|
||||
}},
|
||||
"service": {"weight": 25, "name": "服务质量", "metrics": {
|
||||
"reply_2h_rate": {"type": "target", "floor": 0.40, "target": 0.85},
|
||||
"reply_24h_rate": {"type": "target", "floor": 0.75, "target": 0.97},
|
||||
"fr_p90_ms": {"type": "reverse", "target_ms": 1_800_000,
|
||||
"floor_ms": 14_400_000}, # 30min..4h
|
||||
}},
|
||||
"action": {"weight": 20, "name": "行动执行",
|
||||
"status": "pending_data"}, # 批次E 接 followups/任务
|
||||
"workload": {"weight": 15, "name": "有效负载", "metrics": {
|
||||
"active_friends_band": {"type": "band", "metric": "active_friends",
|
||||
"low": 5, "high": 80,
|
||||
"basis": "period_daily_avg"},
|
||||
"call_band": {"type": "band", "metric": "call_count",
|
||||
"low": 1, "high": 200, "basis": "period_total",
|
||||
"note": "通话低不扣分仅提示;band 得分下限 80"},
|
||||
}},
|
||||
"risk": {"weight": 10, "name": "合规风险",
|
||||
"status": "pending_data"}, # 批次E/三期接风险事件
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def validate_config(config: dict) -> list[str]:
|
||||
"""配置校验:权重总和/结构/引用的 metric_code 有元数据。返回问题清单。"""
|
||||
problems: list[str] = []
|
||||
dims = config.get("dimensions", {})
|
||||
total = sum(d.get("weight", 0) for d in dims.values())
|
||||
if total != 100:
|
||||
problems.append(f"维度权重总和须=100,当前 {total}")
|
||||
for key, dim in dims.items():
|
||||
if dim.get("status") == "pending_data":
|
||||
continue
|
||||
for code, spec in dim.get("metrics", {}).items():
|
||||
src = spec.get("metric", code)
|
||||
if src not in METRIC_DEFINITIONS:
|
||||
problems.append(f"{key}.{code} 引用未定义指标 {src}")
|
||||
return problems
|
||||
```
|
||||
|
||||
- [ ] **Step 4: cli.py 加 `score-config` 子命令**
|
||||
|
||||
parser(sensitive-scan 与 first-reply-facts 之间任意位置):
|
||||
|
||||
```python
|
||||
p_score = sub.add_parser(
|
||||
"score-config",
|
||||
help="Seed the default score config (idempotent).",
|
||||
description=(
|
||||
"Insert DEFAULT_SCORE_CONFIG (v1.0, trial) into "
|
||||
"employee_score_config as active. Skips if the version "
|
||||
"already exists. Batch B."
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
命令函数与分发(照 _cmd_daily_stats 惯例):
|
||||
|
||||
```python
|
||||
async def _seed_score_config(session) -> str | None:
|
||||
"""种子默认评分配置;已存在同版本返回 None(幂等)。纯会话函数可测。"""
|
||||
from app.db.models import EmployeeScoreConfig
|
||||
from app.management.metric_definitions import (
|
||||
DEFAULT_SCORE_CONFIG,
|
||||
validate_config,
|
||||
)
|
||||
|
||||
problems = validate_config(DEFAULT_SCORE_CONFIG)
|
||||
if problems:
|
||||
raise ValueError(f"score config invalid: {problems}")
|
||||
exists = (
|
||||
await session.execute(
|
||||
sa.select(EmployeeScoreConfig.id).where(
|
||||
EmployeeScoreConfig.version == DEFAULT_SCORE_CONFIG["version"]
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists is not None:
|
||||
return None
|
||||
session.add(
|
||||
EmployeeScoreConfig(
|
||||
version=DEFAULT_SCORE_CONFIG["version"],
|
||||
name=DEFAULT_SCORE_CONFIG["name"],
|
||||
status="active",
|
||||
config_json=DEFAULT_SCORE_CONFIG,
|
||||
created_by="seed-cli",
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
return DEFAULT_SCORE_CONFIG["version"]
|
||||
|
||||
|
||||
async def _cmd_score_config(args: argparse.Namespace) -> int:
|
||||
settings = get_settings()
|
||||
engine, session_maker = await _init_db(settings.db_url)
|
||||
try:
|
||||
async with session_maker() as session:
|
||||
version = await _seed_score_config(session)
|
||||
await session.commit()
|
||||
print(f"[score-config] {'seeded ' + version if version else 'already present, skipped'}")
|
||||
return 0
|
||||
finally:
|
||||
await engine.dispose()
|
||||
```
|
||||
|
||||
(cli.py 头部补 `import sqlalchemy as sa` 若无。)
|
||||
|
||||
- [ ] **Step 5: 测试通过 + 提交**
|
||||
|
||||
```bash
|
||||
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_management_scoring.py -v # 5 passed
|
||||
cd yunke-server && .venv/Scripts/python.exe -m app.sync.cli score-config # 真机种子一次
|
||||
git add yunke-server/app/management/ yunke-server/app/sync/cli.py yunke-server/tests/test_management_scoring.py
|
||||
git commit -m "feat(management): 指标元数据+试算评分配置v1.0+种子CLI——权重校验/幂等落库"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task B1: 期聚合查询服务
|
||||
|
||||
**Files:**
|
||||
- Create: `yunke-server/app/management/employee_metrics.py`
|
||||
- Test: `yunke-server/tests/test_management_period_metrics.py`
|
||||
|
||||
核心函数(完整实现):
|
||||
|
||||
```python
|
||||
"""管理域期聚合查询(批次B)——评分/异常/employees API 的单一数据源。
|
||||
|
||||
窗口:days=[start_day..end_day] 闭区间 yyyymmdd;上期=等长紧邻前窗;
|
||||
个人基线=紧邻当前窗之前的 28 天。所有查询走 (day, ...) 索引,不扫消息表
|
||||
(除 distinct 活跃客户走 msg_daily_stats friend 行)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from collections import namedtuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import (
|
||||
EmployeeCallDaily,
|
||||
EmployeeMetricDaily,
|
||||
FirstReplyFact,
|
||||
MsgDailyStats,
|
||||
Staff,
|
||||
)
|
||||
from app.storage.staff_summary import account_map
|
||||
from app.storage.summary import day_bounds_ms
|
||||
|
||||
_SH = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
PeriodMetrics = namedtuple("PeriodMetrics", [
|
||||
"user_id", "days_with_data", "msg_sent", "msg_recv", "cust_threads",
|
||||
"threads_answered", "fr_sum_ms", "fr_p50_ms", "fr_p90_ms",
|
||||
"reply_2h_count", "reply_24h_count",
|
||||
"active_friends", # 期内 distinct(直查,非日值求和)
|
||||
"active_rooms_avg", # 日均
|
||||
"new_customer", "cooling_customer", "dormant_customer", # 期末单日存量
|
||||
"reactivated_customer", # 期内求和
|
||||
"call_count", "call_seconds", "answered_call", "call_followup",
|
||||
])
|
||||
|
||||
|
||||
def _day_range(start: int, end: int) -> list[int]:
|
||||
"""闭区间日键列表(纯 Python,上海自然日语义由调用方保证)。"""
|
||||
def _d(k): return dt.date(k // 10000, k // 100 % 100, k % 100)
|
||||
def _k(d): return d.year * 10000 + d.month * 100 + d.day
|
||||
out, cur = [], _d(start)
|
||||
while cur <= _d(end):
|
||||
out.append(_k(cur))
|
||||
cur += dt.timedelta(days=1)
|
||||
return out
|
||||
|
||||
|
||||
def prev_window(start: int, end: int) -> tuple[int, int]:
|
||||
"""等长紧邻前窗。"""
|
||||
n = len(_day_range(start, end))
|
||||
def _d(k): return dt.date(k // 10000, k // 100 % 100, k % 100)
|
||||
def _k(d): return d.year * 10000 + d.month * 100 + d.day
|
||||
pe = _d(start) - dt.timedelta(days=1)
|
||||
ps = pe - dt.timedelta(days=n - 1)
|
||||
return _k(ps), _k(pe)
|
||||
|
||||
|
||||
def baseline_window(start: int) -> tuple[int, int]:
|
||||
"""个人基线:紧邻当前窗之前的 28 天。"""
|
||||
def _d(k): return dt.date(k // 10000, k // 100 % 100, k % 100)
|
||||
def _k(d): return d.year * 10000 + d.month * 100 + d.day
|
||||
be = _d(start) - dt.timedelta(days=1)
|
||||
bs = be - dt.timedelta(days=27)
|
||||
return _k(bs), _k(be)
|
||||
```
|
||||
|
||||
`period_metrics(session, start, end, channel, user_ids=None) -> dict[str, PeriodMetrics]` 实现要点(写全代码进计划):
|
||||
|
||||
1. **宽表 SUM**(`WHERE day BETWEEN s AND e AND channel=? AND metric_version='v1' AND user_id IN (...)` GROUP BY user_id):msg_sent/msg_recv/cust_threads/threads_answered/fr_sum_ms/active_rooms(SUM→avg=SUM/天数)、reactivated(SUM)、`days_with_data=COUNT(*)`;**期末存量**:另查 `day=(SELECT MAX(day)≤e 同 user/channel)` 的 new/cooling/dormant 行( correlated 或两段查询——用 `sa.select(...).where(day<=end).order_by(day.desc())` per user 取首行,员工 41 人循环可接受;或一次查询取 (user, MAX(day)) 再 join)。
|
||||
2. **fact 分位与回复计数**(单条 SQL,窗口函数,双方言):
|
||||
```python
|
||||
sub = (
|
||||
sa.select(
|
||||
FirstReplyFact.owner_account_id.label("owner"),
|
||||
FirstReplyFact.fr_ms.label("fr"),
|
||||
sa.func.row_number().over(
|
||||
partition_by=FirstReplyFact.owner_account_id, # 列表达式,非别名——MySQL 窗口规范不认 SELECT 别名
|
||||
order_by=FirstReplyFact.fr_ms,
|
||||
).label("rn"),
|
||||
sa.func.count().over(
|
||||
partition_by=FirstReplyFact.owner_account_id
|
||||
).label("n"),
|
||||
).where(FirstReplyFact.day.between(start, end),
|
||||
FirstReplyFact.channel == channel)
|
||||
.subquery()
|
||||
)
|
||||
# 每 owner:rn=ceil(0.5*n) 行的 fr 即 p50;rn=ceil(0.9*n) 即 p90;
|
||||
# 回复计数:SUM(fr<=2h)/SUM(fr<=24h) 另用条件聚合直查(不用窗口)。
|
||||
```
|
||||
owner→user 经 account_map(channel)。p50/p90 取最接近秩(`rn == (n+1)//2` 与 `rn == max(1, (9*n+9)//10)`——**用「最接近秩」公式并在测试里锁死**:ceil(q·n) 最近邻,与批次A `_percentile` 口径一致)。
|
||||
3. **reply 计数**:`SELECT owner, SUM(fr_ms<=7200000), SUM(fr_ms<=86400000) FROM first_reply_fact WHERE day BETWEEN ... GROUP BY owner`(MySQL 布尔求和;SQLite 同义 `SUM(CASE WHEN ...)`——用 case 写法双方言安全)。
|
||||
4. **distinct 活跃客户**:msg_daily_stats friend 行 in window → peer 集合按 account_map 归并到 user → len。
|
||||
5. **通话**:employee_call_daily SUM(无渠道维;channel 参数只影响消息侧——**通话四列在 channel 查询里也返回全渠道值,响应里标注**)。
|
||||
6. `team_median`:对每指标取全体 user 的中位数(scoring 层用)。
|
||||
7. **channel=all**:router 层把 "all" 翻译成 None 传入(照 analytics 惯例,哨兵字符串不进 SQL);channel=None 时两条渠道的 account_map 合并、宽表/fact 查询去渠道过滤、分位与活跃跨渠道合并到同一 user。
|
||||
|
||||
**测试**(seed 宽表/fact/通话行 + Staff/account,构造已知分位数集合断言 p50/p90 精确值、期末存量取 ≤e 最近行、distinct 活跃跨日去重、prev/baseline 窗口日期算术含月末)——完整测试代码进计划(约 6 个用例)。
|
||||
|
||||
- [ ] Steps: 测试先失败 → 实现 → 通过 → 全量回归 → 提交
|
||||
`feat(management): 期聚合查询——fact窗口函数精确分位/期末生命周期存量/distinct活跃/前窗基线窗口`
|
||||
|
||||
---
|
||||
|
||||
### Task B2: 评分引擎 scoring.py
|
||||
|
||||
**Files:**
|
||||
- Create: `yunke-server/app/management/scoring.py`
|
||||
- Test: 追加 `tests/test_management_scoring.py`
|
||||
|
||||
核心(完整代码进计划):
|
||||
|
||||
```python
|
||||
def normalize(spec: dict, value: float | None) -> float | None:
|
||||
"""单指标 0..100;样本不足/数据缺 → None(不评分,不默认 0/100)。"""
|
||||
t = spec["type"]
|
||||
if value is None:
|
||||
return None
|
||||
if t == "target":
|
||||
if value <= spec["floor"]:
|
||||
return 0.0
|
||||
if value >= spec["target"]:
|
||||
return 100.0
|
||||
return (value - spec["floor"]) / (spec["target"] - spec["floor"]) * 100
|
||||
if t == "reverse":
|
||||
if value <= spec["target_ms"]:
|
||||
return 100.0
|
||||
if value >= spec["floor_ms"]:
|
||||
return 0.0
|
||||
return (spec["floor_ms"] - value) / (spec["floor_ms"] - spec["target_ms"]) * 100
|
||||
if t == "band":
|
||||
if value < spec["low"]:
|
||||
return None # 负载不足→不评分(样本保护语义)
|
||||
return 100.0 if value <= spec["high"] else 80.0 # 超上限 80 不再加
|
||||
raise ValueError(t)
|
||||
|
||||
|
||||
def score_employee(config, current: PeriodMetrics, baseline: PeriodMetrics | None,
|
||||
team: dict[str, float]) -> dict:
|
||||
"""返回 {total_score, grade, score_status, trial, dimensions{...},
|
||||
metrics{code: {value, prev, baseline, median, sample_size,
|
||||
sample_status, score, formula_note}}}。"""
|
||||
```
|
||||
|
||||
规则(写死进测试):
|
||||
- 周期值派生:`reply_2h_rate = reply_2h_count/cust_threads`(threads=0 → None);`fr_p90_ms` 直取;`active_share` **分子=期末该渠道最近一行(≤end)的 active_friends,分母=同一行的 active_friends+cooling_customer+dormant_customer**(分母 0 → None;宽表无生命周期 active 存量列,active 以 active_friends 列充当);band 用 active_friends 日均与 call_count 总量。
|
||||
- 样本保护:`cust_threads < min_threads(30)` → 该 service/customer 指标 sample_status="insufficient",不评分;soft(10)~min 之间 "low_confidence"(评分但标注)。
|
||||
- 维度分 = 各指标得分的均值(等权);任一指标 None 时该指标剔除并按剩余归一;**全部 None → 维度 pending**。
|
||||
- 总分 = Σ(可用维度 weight×dim_score)/Σ(可用维度 weight)×100;可用=pending_data 以外且维度分非 None;全 None → score_status="insufficient_sample"(total None)。
|
||||
- grade 按 grade_bounds;insufficient → grade None + status。
|
||||
- `trial=True` 恒返(一期 3 维)。
|
||||
|
||||
**测试**(≥8 用例,完整代码进计划):target 边界(floor/target/中点 50)、reverse 两端与中间、band 三段、threads=0 → None 非 0、insufficient_sample 整员工、pending 维度不参与(100/70 归一后 A 线仍可达)、试算标记、全部指标有明细含四对比位。
|
||||
|
||||
- [ ] Steps: TDD → 提交 `feat(management): 评分引擎——三类标准化/维度归一/样本保护/等级/完整明细`
|
||||
|
||||
---
|
||||
|
||||
### Task B3: 异常规则 anomalies.py
|
||||
|
||||
**Files:**
|
||||
- Create: `yunke-server/app/management/anomalies.py`
|
||||
- Test: `yunke-server/tests/test_management_anomalies.py`
|
||||
|
||||
4 条确定性规则(输入=period_metrics 当前+基线+团队中位数;输出=v1 §5.4 卡片列表,完整代码进计划):
|
||||
|
||||
| rule_code | 触发 | severity | drilldown |
|
||||
|---|---|---|---|
|
||||
| reply_p90_worsened | p90 > 基线p90×1.5 且样本≥soft;×2.5 升 critical | warning/critical | metric_code=first_reply_p90 |
|
||||
| reply_rate_dropped | reply_2h_rate < 基线×0.7 且当前≥soft 样本 | warning | metric_code=unreplied |
|
||||
| activity_drop | 日均 msg_total < 基线日均×0.5 且基线≥min_days | warning | 员工详情 |
|
||||
| workload_overload | active_friends 日均 > 80(band 上限) | warning | 员工详情 |
|
||||
|
||||
卡片字段照 v1 §5.4 JSON(rule_code/severity/employee_user_id/title/current_value/baseline_value/unit/sample_size/**affected_count**/reason[纯数据事实]/drilldown/suggested_action——文案固定模板,不用 LLM)。`affected_count` 按规则定义:reply_p90_worsened 与 reply_rate_dropped=期内未回串数(cust_threads−threads_answered);activity_drop=当前期活跃客户数;workload_overload=active_friends 日均。
|
||||
|
||||
**测试**(≥6 用例):每规则触发/不触发边界、基线 None 不触发、severity 升级阈值、卡片结构字段齐全。
|
||||
|
||||
- [ ] Steps: TDD → 提交 `feat(management): 确定性异常规则×4——P90恶化/回复率骤降/活动骤降/过载`
|
||||
|
||||
---
|
||||
|
||||
### Task B4: 管理 API 6 端点
|
||||
|
||||
**Files:**
|
||||
- Create: `yunke-server/app/api/routers/management.py`
|
||||
- Modify: `yunke-server/app/api/main.py`(import + include_router(management.router))
|
||||
- Test: `yunke-server/tests/test_management_api.py`
|
||||
|
||||
路由骨架(完整代码进计划,照 analytics 惯例):
|
||||
|
||||
```python
|
||||
router = APIRouter(prefix="/api/management", tags=["management"])
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
AllChannelParam = Annotated[Literal["all", "wechat", "whatsapp"], Query()]
|
||||
YmdParam = Annotated[int, Query(ge=20000101, le=29991231)]
|
||||
```
|
||||
|
||||
| 端点 | 行为要点 |
|
||||
|---|---|
|
||||
| `GET /dashboard` | 参数 channel/start/end/compare(previous\|personal_baseline\|team_median,默认 previous)。组装:kpis(活跃客户/活跃员工/待回复[复用 analytics unreplied total]/通话量/生命周期期末分布)+ communication_trend(宽表按日 SUM msg+distinct friends 近 trend 天)+ service_trend(fr 日值 p50/p90 宽表列按日 + reply_2h_rate 派生)+ lifecycle(期末存量分布)+ dimension_summary(全员 3 维分布计数)+ anomalies(B3 全员)+ action_summary(复用 analytics action_counts)+ data_as_of(复用 health)。单次响应聚合,内部并发可用 asyncio.gather |
|
||||
| `GET /employees` | 参数 channel/start/end/view/keyword/status/sort_by/sort_order/page/page_size(≤100 默认 20)。行=staff 身份(name/is_on_job) + PeriodMetrics + 四对比 + score_employee 结果摘要(total/grade/sample_status/trial) + anomaly_count + anomaly_weight。默认排序=**anomaly_severity**(权重和 critical=2/warning=1 desc,同分再比 count desc——对应 v2 §6「按异常严重度排序」,非单纯计数);keyword 匹配 staff.name/user_id |
|
||||
| `GET /employees/{user_id}` | 404 校验(staff 无此 user_id);头部身份+账号清单(wechat/whatsapp account 表)+宽表总览+评分明细+异常+基线摘要 |
|
||||
| `GET /employees/{user_id}/metrics?section=` | section ∈ overview\|service\|customer\|workload\|calls(一期 5 个,v2 §6);每 section 返回该域 PeriodMetrics 明细+四对比+trend(宽表按日序列) |
|
||||
| `GET /employees/{user_id}/drilldown?metric_code=&start=&end=&channel=&page=&page_size=` | **白名单**:`first_reply_p90|first_reply_p50`→fact 逐串明细(含 thread_end/reply ts/fr_ms+跳聊天参数 owner/talker);`unreplied`→调 analytics unreplied(user 过滤);`cooling_customers|dormant_customers`→customer 表 owner 归属+lifecycle 判定列清单;`call_followup`→phone_call 行清单。白名单外 422 |
|
||||
| `GET /metric-definitions` | METRIC_DEFINITIONS + DEFAULT_SCORE_CONFIG 摘要(权重/试算标记/样本门槛)+ 三方口径差异说明 |
|
||||
|
||||
**API 测试**(完整代码进计划,make_client fixture,≥12 用例):参数默认值/422(custom 缺参、跨度>366、metric_code 白名单外、page_size>100)/404(员工不存在)/响应结构关键字段(data_as_of、metric_version、sample_status、trial 标记)/employees 分页排序/dashboard 各段齐全/渠道过滤生效。
|
||||
|
||||
- [ ] Steps: TDD → main.py 注册 → OpenAPI 冒烟(`/docs` 可见 6 端点)→ 全量回归 → 提交
|
||||
`feat(management): 只读管理API六端点——dashboard/employees/detail/sections/drilldown白名单/指标定义`
|
||||
|
||||
---
|
||||
|
||||
### Task B5: 真机冒烟 + 性能初测 + 记录
|
||||
|
||||
- [ ] 本地起后端(先 `docker stop yunke-backend-1 yunke-frontend-1` 若在跑;`cd yunke-server && .venv/Scripts/python.exe -m uvicorn app.api.main:app --port 8000` 后台);
|
||||
- [ ] curl 六端点(带 token,从 .env API_TOKEN;无 token 模式则直接)真机各一次,记录响应体积与耗时(目标:dashboard p95≤1.5s、employees≤1.5s——v2 §9 性能线);
|
||||
- [ ] 抽查:某回测员工(王云青 369D257E…)30 天窗 employees 行——P90/回复率/维度分人工合理性检查(对照核实文档对账数字);
|
||||
- [ ] 结果(耗时表+抽查截图级数字)追加到批次A核实文档「批次B冒烟」小节;
|
||||
- [ ] 提交 `docs(management): 批次B真机冒烟——六端点耗时/回测员工抽查/记录`
|
||||
|
||||
---
|
||||
|
||||
## 批次B验收清单
|
||||
|
||||
- [ ] `score-config` 种子幂等,配置过校验,v1.0 active;
|
||||
- [ ] 期聚合:p50/p90 精确分位(窗口函数)与期末存量口径有判别测试;
|
||||
- [ ] 评分:三类标准化边界/样本保护/100-70 归一/试算标记全锁测试;
|
||||
- [ ] 异常:4 规则触发边界 + 卡片结构完整;
|
||||
- [ ] 6 端点:OpenAPI 可见、参数校验 422/404 齐全、白名单生效、dict 返回含 data_as_of/metric_version/sample_status;
|
||||
- [ ] 全量测试绿(653 + 新增 ~30);
|
||||
- [ ] 真机冒烟耗时达标并记录;
|
||||
- [ ] 未动批次A任何存储层代码(宽表/fact 生成零改动)。
|
||||
|
||||
## 批次C-F
|
||||
|
||||
批次C:前端(api/management.ts + useManagementFilters + components/management/* + ManagementDashboardView + 路由/导航「经营管理」分组)——批次B合入后出计划,消费真实 API 形态。D:员工总览/详情页。E:followups+快照(写端点+schemas)。F:调度接入(analytics_tick 第三步/full_sync 13-14 步/scheduler 镜像重建)+回测试运行。
|
||||
@@ -5,6 +5,10 @@
|
||||
``owner_user_id`` column) and ``q`` (substring match on ``room_name``,
|
||||
``%``/``_`` escaped as literals — the 通讯录群 tab search box), ordered by
|
||||
``last_chat_time desc`` (nulls last) so recently-active groups surface first.
|
||||
微信群统计扩展(2026-09-09,云客「微信群统计」同款):``msg_start``/``msg_end``
|
||||
统计时段(窗内输出沟通消息数/新增/退出成员数聚合)、``last_start``/
|
||||
``last_end`` 最新消息时间段、``group_remark`` 群备注子串;行补员工微信/
|
||||
员工信息列(wechat_account/staff 关联)。
|
||||
* ``GET /api/groups/{room_id}/members`` -> ``list[GroupMemberOut]`` — the
|
||||
members of a single group (small set, no pagination needed).
|
||||
"""
|
||||
@@ -19,7 +23,15 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_session
|
||||
from app.api.schemas import GroupMemberOut, GroupOut, Page
|
||||
from app.db.models import ChatGroup, GroupMember
|
||||
from app.db.models import (
|
||||
ChatGroup,
|
||||
ChatGroupMemberStats,
|
||||
GroupMember,
|
||||
MsgDailyStats,
|
||||
Staff,
|
||||
WechatAccount,
|
||||
)
|
||||
from app.storage.summary import day_key
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["groups"])
|
||||
|
||||
@@ -38,6 +50,11 @@ async def list_groups(
|
||||
user_id: Annotated[str | None, Query()] = None,
|
||||
room_id: Annotated[str | None, Query()] = None,
|
||||
q: Annotated[str | None, Query()] = None,
|
||||
group_remark: Annotated[str | None, Query()] = None,
|
||||
last_start: Annotated[int | None, Query(ge=0)] = None,
|
||||
last_end: Annotated[int | None, Query(ge=0)] = None,
|
||||
msg_start: Annotated[int | None, Query(ge=0)] = None,
|
||||
msg_end: Annotated[int | None, Query(ge=0)] = None,
|
||||
) -> Page[GroupOut]:
|
||||
"""List chat groups with optional ``channel`` / ``owner_account_id`` /
|
||||
``user_id`` / ``room_id`` / ``q`` filters.
|
||||
@@ -47,6 +64,13 @@ async def list_groups(
|
||||
filter on ``GET /api/customers``. ``room_id`` is an exact-match lookup
|
||||
(composite PK: the same room may return one row per owner; chat page
|
||||
group-name resolution). ``q`` is a substring match on ``room_name``.
|
||||
|
||||
微信群统计参数(2026-09-09):``last_start``/``last_end`` 过滤
|
||||
``last_chat_time`` 范围(最新消息时间段);``msg_start``/``msg_end``
|
||||
统计时段——窗内聚合三列(``window_msg_count`` 沟通消息数=
|
||||
msg_daily_stats 按天求和、``window_added``/``window_lost`` 成员进出=
|
||||
chat_group_member_stats 按天求和,上线日起有数据);``group_remark``
|
||||
群备注子串。员工微信/员工信息列经 wechat_account/staff 关联补齐。
|
||||
"""
|
||||
conditions = []
|
||||
if channel is not None:
|
||||
@@ -57,10 +81,17 @@ async def list_groups(
|
||||
# LIKE 通配符转义: %/_ 按字面匹配(镜像 customers/messages 的 q 处理)
|
||||
escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
conditions.append(ChatGroup.room_name.like(f"%{escaped}%", escape="\\"))
|
||||
if group_remark:
|
||||
escaped_r = group_remark.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
conditions.append(ChatGroup.group_remark.like(f"%{escaped_r}%", escape="\\"))
|
||||
if room_id is not None:
|
||||
conditions.append(ChatGroup.room_id == room_id)
|
||||
if user_id is not None:
|
||||
conditions.append(ChatGroup.owner_user_id == user_id)
|
||||
if last_start is not None:
|
||||
conditions.append(ChatGroup.last_chat_time >= last_start)
|
||||
if last_end is not None:
|
||||
conditions.append(ChatGroup.last_chat_time <= last_end)
|
||||
|
||||
total_stmt = sa.select(sa.func.count()).select_from(ChatGroup)
|
||||
if conditions:
|
||||
@@ -79,7 +110,84 @@ async def list_groups(
|
||||
.offset((page - 1) * page_size)
|
||||
)
|
||||
rows = (await session.execute(list_stmt)).scalars().all()
|
||||
items = [GroupOut.model_validate(row) for row in rows]
|
||||
|
||||
# ---- 员工微信/员工信息列(wechat_account/staff 关联,本页行集) ----
|
||||
acc_map: dict[str, tuple[str | None, str | None, str | None]] = {}
|
||||
owners = {r.owner_account_id for r in rows if r.owner_account_id}
|
||||
if owners:
|
||||
for wx, nick, alias, raw in (await session.execute(
|
||||
sa.select(
|
||||
WechatAccount.wx_id, WechatAccount.nick_name,
|
||||
WechatAccount.alias, WechatAccount.raw_json,
|
||||
).where(WechatAccount.wx_id.in_(owners))
|
||||
)).all():
|
||||
avatar = None
|
||||
if isinstance(raw, dict):
|
||||
h = raw.get("headImageUrl")
|
||||
avatar = h if isinstance(h, str) else None
|
||||
acc_map[wx] = (nick, alias, avatar)
|
||||
staff_map: dict[str, tuple[str | None, str | None]] = {}
|
||||
uids = {r.owner_user_id for r in rows if r.owner_user_id}
|
||||
if uids:
|
||||
for uid, name, phone in (await session.execute(
|
||||
sa.select(Staff.user_id, Staff.name, Staff.phone)
|
||||
.where(Staff.user_id.in_(uids))
|
||||
)).all():
|
||||
staff_map[uid] = (name, phone)
|
||||
|
||||
# ---- 统计时段窗内聚合(msg_daily_stats 沟通消息数 + member_stats 进出) ----
|
||||
msg_cnt: dict[tuple[str, str], int] = {}
|
||||
added_map: dict[tuple[str, str], int] = {}
|
||||
lost_map: dict[tuple[str, str], int] = {}
|
||||
if msg_start is not None and msg_end is not None and rows:
|
||||
d0, d1 = day_key(msg_start), day_key(msg_end)
|
||||
pairs = [(r.owner_account_id, r.room_id) for r in rows]
|
||||
owners_in = {p[0] for p in pairs}
|
||||
rooms_in = {p[1] for p in pairs}
|
||||
for owner, room, cnt in (await session.execute(
|
||||
sa.select(
|
||||
MsgDailyStats.owner_account_id, MsgDailyStats.peer_id,
|
||||
sa.func.coalesce(sa.func.sum(
|
||||
MsgDailyStats.send_cnt + MsgDailyStats.recv_cnt), 0),
|
||||
).where(
|
||||
MsgDailyStats.channel == (channel or "wechat"),
|
||||
MsgDailyStats.peer_kind == "room",
|
||||
MsgDailyStats.owner_account_id.in_(owners_in),
|
||||
MsgDailyStats.peer_id.in_(rooms_in),
|
||||
MsgDailyStats.day >= d0, MsgDailyStats.day <= d1,
|
||||
).group_by(MsgDailyStats.owner_account_id, MsgDailyStats.peer_id)
|
||||
)).all():
|
||||
msg_cnt[(owner, room)] = int(cnt)
|
||||
for owner, room, a, l in (await session.execute(
|
||||
sa.select(
|
||||
ChatGroupMemberStats.owner_account_id, ChatGroupMemberStats.room_id,
|
||||
sa.func.coalesce(sa.func.sum(ChatGroupMemberStats.added), 0),
|
||||
sa.func.coalesce(sa.func.sum(ChatGroupMemberStats.lost), 0),
|
||||
).where(
|
||||
ChatGroupMemberStats.channel == (channel or "wechat"),
|
||||
ChatGroupMemberStats.owner_account_id.in_(owners_in),
|
||||
ChatGroupMemberStats.room_id.in_(rooms_in),
|
||||
ChatGroupMemberStats.day >= d0, ChatGroupMemberStats.day <= d1,
|
||||
).group_by(ChatGroupMemberStats.owner_account_id, ChatGroupMemberStats.room_id)
|
||||
)).all():
|
||||
added_map[(owner, room)] = int(a)
|
||||
lost_map[(owner, room)] = int(l)
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
out = GroupOut.model_validate(r)
|
||||
acc = acc_map.get(r.owner_account_id or "")
|
||||
if acc:
|
||||
out.staff_nickname, out.staff_wx_alias, out.staff_avatar = acc
|
||||
st = staff_map.get(r.owner_user_id or "")
|
||||
if st:
|
||||
out.staff_name, out.staff_phone = st
|
||||
if msg_start is not None and msg_end is not None:
|
||||
key = (r.owner_account_id or "", r.room_id)
|
||||
out.window_msg_count = msg_cnt.get(key, 0)
|
||||
out.window_added = added_map.get(key, 0)
|
||||
out.window_lost = lost_map.get(key, 0)
|
||||
items.append(out)
|
||||
return Page(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
|
||||
@@ -199,6 +199,17 @@ class GroupOut(BaseModel):
|
||||
owner_avatar: str | None = None
|
||||
friend_count: int | None = None
|
||||
create_time: int | None = None # 建群时间 ms epoch
|
||||
# 微信群统计页(2026-09-09,云客 getChatroomList 同款)
|
||||
group_remark: str | None = None # 群备注
|
||||
staff_nickname: str | None = None # 员工微信·昵称(wechat_account.nick_name)
|
||||
staff_wx_alias: str | None = None # 员工微信·微信号(wechat_account.alias)
|
||||
staff_avatar: str | None = None # 员工微信·头像
|
||||
staff_name: str | None = None # 员工信息·姓名(staff.name)
|
||||
staff_phone: str | None = None # 员工信息·手机(staff.phone)
|
||||
# 统计时段窗内聚合(仅请求带 msg_start/msg_end 时非 None):
|
||||
window_msg_count: int | None = None # 沟通消息数(窗内,msg_daily_stats)
|
||||
window_added: int | None = None # 新增成员数(窗内,按天表求和)
|
||||
window_lost: int | None = None # 退出成员数(窗内)
|
||||
raw_json: dict[str, Any] | None = None
|
||||
|
||||
|
||||
|
||||
@@ -344,6 +344,9 @@ class ChatGroup(Base):
|
||||
owner_avatar: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
friend_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 群成员中员工好友数
|
||||
create_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 建群时间 ms epoch
|
||||
# 群备注(微信群统计页「群备注」列;getChatroomList.remark /
|
||||
# friend_list/es 的 friendWxRemark,2026-09-09)
|
||||
group_remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
@@ -362,6 +365,34 @@ class ChatGroup(Base):
|
||||
)
|
||||
|
||||
|
||||
class ChatGroupMemberStats(Base):
|
||||
"""群成员进出按天统计(chat_group_member_stats,2026-09-09)。
|
||||
|
||||
云客「微信群统计」的新增/退出成员数是 ``getChatroomList`` 按
|
||||
``startTime~endTime`` 任意窗实时算的;本地无成员进出事件流,改为每夜
|
||||
抓一次「当日窗」的 addMembers/lossMembers 落一行,页面任意日窗=按天
|
||||
求和。**上线日起积累**(历史日期无数据;chat_group.add_members/
|
||||
loss_members 旧列保留作最近一次抓取窗的快照)。
|
||||
"""
|
||||
|
||||
__tablename__ = "chat_group_member_stats"
|
||||
|
||||
channel: Mapped[str] = mapped_column(String(16), primary_key=True)
|
||||
owner_account_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
room_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
# yyyymmdd(上海自然日,同 msg_daily_stats 口径)
|
||||
day: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
added: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default=sa.text("0")
|
||||
)
|
||||
lost: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default=sa.text("0")
|
||||
)
|
||||
|
||||
fetched_at: Mapped[dt.datetime] = _fetched_at()
|
||||
updated_at: Mapped[dt.datetime] = _updated_at()
|
||||
|
||||
|
||||
class GroupMember(Base):
|
||||
"""群成员 — composite PK (channel, room_id, member_id).
|
||||
|
||||
@@ -1090,7 +1121,8 @@ class EmployeeMetricDaily(Base):
|
||||
reactivated_customer_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default=sa.text("0")
|
||||
)
|
||||
# fact 是否已覆盖当日(宽表生成前置条件;False=串类列可能滞后)
|
||||
# 预留完整性标记:宽表生成当前恒写 True(串类列与 fact 同一算法直算,
|
||||
# 不读 fact 表)——真实语义(如校验 fact 覆盖/同步完整)由批次B定义
|
||||
data_complete: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default=sa.text("0")
|
||||
)
|
||||
@@ -1102,8 +1134,9 @@ class EmployeeMetricDaily(Base):
|
||||
class EmployeeCallDaily(Base):
|
||||
"""员工×日通话小表(v2 §1.2 #9)——通话无消息渠道维度,独立于宽表。
|
||||
|
||||
phone_call.channel 是通话类型(phone_call/voice_call)非消息渠道,
|
||||
塞进 (day,channel,user) 宽表会在 channel=all 聚合时双计。接通 =
|
||||
phone_call.channel 是通话类型(实测值 voice_call/wa_voice_call,
|
||||
无蜂窝 phone_call 行)非消息渠道,塞进 (day,channel,user) 宽表会在
|
||||
channel=all 聚合时双计。接通 =
|
||||
call_duration > 0;followup = 通话后 24h 内该员工任一账号对该客户
|
||||
有消息(Task 0 已核实:1:1 通话可匹配率 100%,@chatroom 群通话除外)。
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""管理域确定性异常规则(批次B Task B3,v1 §5.4 异常卡片)。
|
||||
|
||||
4 条规则,输入=当前+个人基线 PeriodMetrics,输出异常卡片 dict 列表。
|
||||
全部确定性——不使用 LLM 推断,reason/title/suggested_action 均为固定
|
||||
中文模板填数,reason 只描述数据事实(百分比/样本数/未回串数)。
|
||||
|
||||
规则表(触发比较均为严格 > / <):
|
||||
- reply_p90_worsened : cur.fr_p90_ms > base×1.5 且 cur.cust_threads≥
|
||||
soft_threads;>×2.5 升 critical
|
||||
- reply_rate_dropped : derive(reply_2h_rate) cur < base 同式×0.7 且
|
||||
cur.cust_threads≥soft_threads(率由 scoring.
|
||||
derive_metrics 派生,与评分同一口径)
|
||||
- activity_drop : cur 日均 (msg_sent+msg_recv)/n_days < base 日均
|
||||
(…/28)×0.5 且 base.days_with_data≥min_days
|
||||
(基线太稀疏不可比;基线窗口恒 28 天=baseline_window
|
||||
语义,当前窗口天数由 n_days 传入)
|
||||
- workload_overload : cur.active_friends(周期 distinct) > workload 维
|
||||
active_friends_band.high(与评分 band 同源阈值)
|
||||
|
||||
无基线不判:baseline=None 直接返回 [](新员工无个人对比位即不出卡,
|
||||
含与基线无关的 overload——新入职负载爬坡期误报多,不判);某侧值
|
||||
None/不可算(无串样本的率、无 fact 的分位、0ms 退化基线)→ 对应规则
|
||||
不触发。
|
||||
|
||||
纯函数(无 session/IO):B4 dashboard/employees 对全员逐个调用。
|
||||
阈值不硬编码:soft_threads/min_days 取 config.sample_guards,过载上限取
|
||||
config.dimensions.workload.metrics.active_friends_band.high;乘数
|
||||
(1.5/2.5/0.7/0.5)是规则语义本身,定义为模块常量而非可调参数。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.management.employee_metrics import PeriodMetrics
|
||||
from app.management.scoring import derive_metrics
|
||||
|
||||
# 规则乘数(语义常量,非可调参数——调整即换规则版本)
|
||||
P90_WARN_MULTIPLIER = 1.5
|
||||
P90_CRITICAL_MULTIPLIER = 2.5
|
||||
RATE_DROP_MULTIPLIER = 0.7
|
||||
ACTIVITY_DROP_MULTIPLIER = 0.5
|
||||
BASELINE_WINDOW_DAYS = 28 # 个人基线窗口恒 28 天(employee_metrics.baseline_window)
|
||||
|
||||
# ---- 固定文案模板(.format 填数,reason 只含数据事实) ----
|
||||
TITLE_P90 = "回复 P90 较个人基线上升 {pct}%"
|
||||
TITLE_RATE = "2h 回复率较个人基线下降 {pct}%"
|
||||
TITLE_ACTIVITY = "日均消息量较个人基线下降 {pct}%"
|
||||
TITLE_OVERLOAD = "活跃客户 {current} 人超过负载上限 {limit} 人"
|
||||
|
||||
REASON_P90 = (
|
||||
"近 {n_days} 天首响 P90 为 {cur}(基线前 28 天为 {base}),上升 {pct}%;"
|
||||
"期内 {threads} 串客户消息中 {unreplied} 串未回"
|
||||
)
|
||||
REASON_RATE = (
|
||||
"近 {n_days} 天 2h 回复率为 {cur:.0%}({cur_cnt}/{cur_thr} 串),"
|
||||
"基线(前 28 天)为 {base:.0%}({base_cnt}/{base_thr} 串);"
|
||||
"期内 {unreplied} 串未回"
|
||||
)
|
||||
REASON_ACTIVITY = (
|
||||
"近 {n_days} 天日均消息 {cur:.1f} 条/天,基线(前 28 天)日均 {base:.1f} 条/天,"
|
||||
"下降 {pct}%;当前活跃客户 {friends} 人"
|
||||
)
|
||||
REASON_OVERLOAD = (
|
||||
"近 {n_days} 天活跃客户 {current} 人(周期 distinct 口径),"
|
||||
"超过配置上限 {limit} 人"
|
||||
)
|
||||
|
||||
ACTION_P90 = "查看超时未回客户明细,与员工确认近期负载与接待安排"
|
||||
ACTION_RATE = "查看未回串明细,排查回复流程与时段覆盖问题"
|
||||
ACTION_ACTIVITY = "与员工确认在岗状态与客户维护情况,并核对账号数据接入是否正常"
|
||||
ACTION_OVERLOAD = "评估客户分配与负载均衡,考虑分流部分客户或安排协助"
|
||||
|
||||
|
||||
def _fmt_ms(ms: float) -> str:
|
||||
# 与 scoring._fmt_ms 同式(模块私有不跨文件引用)
|
||||
minutes = ms / 60_000
|
||||
if minutes >= 60 and minutes % 60 == 0:
|
||||
return f"{minutes // 60:g}h"
|
||||
return f"{minutes:g}min"
|
||||
|
||||
|
||||
def _pct_up(cur: float, base: float) -> int:
|
||||
"""较基线上升百分比(四舍五入取整;触发后调用,base>0 已保证)。"""
|
||||
return int(round((cur - base) / base * 100))
|
||||
|
||||
|
||||
def _pct_down(cur: float, base: float) -> int:
|
||||
"""较基线下降百分比(触发后调用,base>0 已由触发条件保证)。"""
|
||||
return int(round((base - cur) / base * 100))
|
||||
|
||||
|
||||
def detect_anomalies(
|
||||
user_id: str,
|
||||
current: PeriodMetrics,
|
||||
baseline: PeriodMetrics | None,
|
||||
*,
|
||||
n_days: int,
|
||||
config: dict,
|
||||
) -> list[dict]:
|
||||
"""单员工确定性异常检测(纯函数,卡片结构见 v1 §5.4/模块 docstring)。
|
||||
|
||||
输出按规则表序:p90 恶化 → 回复率骤降 → 活动骤降 → 过载
|
||||
(排序/聚合由 B4 调用方按 severity/affected_count 处理)。
|
||||
"""
|
||||
if baseline is None:
|
||||
return [] # 无基线不判(新员工)
|
||||
guards = config.get("sample_guards", {})
|
||||
soft = guards.get("soft_threads", 10)
|
||||
min_days = guards.get("min_days", 5)
|
||||
band_high = (
|
||||
config.get("dimensions", {})
|
||||
.get("workload", {})
|
||||
.get("metrics", {})
|
||||
.get("active_friends_band", {})
|
||||
.get("high")
|
||||
)
|
||||
cards: list[dict] = []
|
||||
unreplied = current.cust_threads - current.threads_answered
|
||||
threads_ok = current.cust_threads >= soft
|
||||
|
||||
# ---- 1. reply_p90_worsened:P90 较个人基线恶化 ×1.5/×2.5 ----
|
||||
if (
|
||||
threads_ok
|
||||
and current.fr_p90_ms is not None
|
||||
and baseline.fr_p90_ms is not None
|
||||
and baseline.fr_p90_ms > 0 # 0ms 基线为退化数据,不可比
|
||||
):
|
||||
base_p90 = baseline.fr_p90_ms
|
||||
if current.fr_p90_ms > base_p90 * P90_CRITICAL_MULTIPLIER:
|
||||
severity = "critical"
|
||||
elif current.fr_p90_ms > base_p90 * P90_WARN_MULTIPLIER:
|
||||
severity = "warning"
|
||||
else:
|
||||
severity = None
|
||||
if severity is not None:
|
||||
pct = _pct_up(current.fr_p90_ms, base_p90)
|
||||
cards.append({
|
||||
"rule_code": "reply_p90_worsened",
|
||||
"severity": severity,
|
||||
"employee_user_id": user_id,
|
||||
"title": TITLE_P90.format(pct=pct),
|
||||
"current_value": current.fr_p90_ms,
|
||||
"baseline_value": base_p90,
|
||||
"unit": "ms",
|
||||
"sample_size": current.cust_threads,
|
||||
"affected_count": unreplied,
|
||||
"reason": REASON_P90.format(
|
||||
n_days=n_days, cur=_fmt_ms(current.fr_p90_ms),
|
||||
base=_fmt_ms(base_p90), pct=pct,
|
||||
threads=current.cust_threads, unreplied=unreplied,
|
||||
),
|
||||
"drilldown": {"type": "metric", "query": {
|
||||
"metric_code": "first_reply_p90", "user_id": user_id}},
|
||||
"suggested_action": ACTION_P90,
|
||||
})
|
||||
|
||||
# ---- 2. reply_rate_dropped:2h 回复率较基线降 30%+ ----
|
||||
if threads_ok:
|
||||
cur_rate = derive_metrics(current)["reply_2h_rate"]
|
||||
base_rate = derive_metrics(baseline)["reply_2h_rate"]
|
||||
if (
|
||||
cur_rate is not None and base_rate is not None
|
||||
and cur_rate < base_rate * RATE_DROP_MULTIPLIER
|
||||
):
|
||||
cards.append({
|
||||
"rule_code": "reply_rate_dropped",
|
||||
"severity": "warning",
|
||||
"employee_user_id": user_id,
|
||||
"title": TITLE_RATE.format(
|
||||
pct=_pct_down(cur_rate, base_rate)),
|
||||
"current_value": cur_rate,
|
||||
"baseline_value": base_rate,
|
||||
"unit": "0-1",
|
||||
"sample_size": current.cust_threads,
|
||||
"affected_count": unreplied,
|
||||
"reason": REASON_RATE.format(
|
||||
n_days=n_days, cur=cur_rate, cur_cnt=current.reply_2h_count,
|
||||
cur_thr=current.cust_threads, base=base_rate,
|
||||
base_cnt=baseline.reply_2h_count,
|
||||
base_thr=baseline.cust_threads, unreplied=unreplied,
|
||||
),
|
||||
"drilldown": {"type": "metric", "query": {
|
||||
"metric_code": "unreplied", "user_id": user_id}},
|
||||
"suggested_action": ACTION_RATE,
|
||||
})
|
||||
|
||||
# ---- 3. activity_drop:日均消息量较基线腰斩 ----
|
||||
if n_days > 0 and baseline.days_with_data >= min_days:
|
||||
cur_daily = (current.msg_sent + current.msg_recv) / n_days
|
||||
base_daily = (
|
||||
baseline.msg_sent + baseline.msg_recv
|
||||
) / BASELINE_WINDOW_DAYS
|
||||
if cur_daily < base_daily * ACTIVITY_DROP_MULTIPLIER:
|
||||
cards.append({
|
||||
"rule_code": "activity_drop",
|
||||
"severity": "warning",
|
||||
"employee_user_id": user_id,
|
||||
"title": TITLE_ACTIVITY.format(
|
||||
pct=_pct_down(cur_daily, base_daily)),
|
||||
"current_value": cur_daily,
|
||||
"baseline_value": base_daily,
|
||||
"unit": "条",
|
||||
"sample_size": current.active_friends,
|
||||
"affected_count": current.active_friends,
|
||||
"reason": REASON_ACTIVITY.format(
|
||||
n_days=n_days, cur=cur_daily, base=base_daily,
|
||||
pct=_pct_down(cur_daily, base_daily),
|
||||
friends=current.active_friends,
|
||||
),
|
||||
"drilldown": {"type": "employee",
|
||||
"query": {"user_id": user_id}},
|
||||
"suggested_action": ACTION_ACTIVITY,
|
||||
})
|
||||
|
||||
# ---- 4. workload_overload:活跃客户超 band 上限 ----
|
||||
if band_high is not None and current.active_friends > band_high:
|
||||
cards.append({
|
||||
"rule_code": "workload_overload",
|
||||
"severity": "warning",
|
||||
"employee_user_id": user_id,
|
||||
"title": TITLE_OVERLOAD.format(
|
||||
current=current.active_friends, limit=band_high),
|
||||
"current_value": current.active_friends,
|
||||
"baseline_value": None, # 与个人基线无关,阈值在 band 配置
|
||||
"unit": "人",
|
||||
"sample_size": current.active_friends,
|
||||
"affected_count": current.active_friends,
|
||||
"reason": REASON_OVERLOAD.format(
|
||||
n_days=n_days, current=current.active_friends,
|
||||
limit=band_high,
|
||||
),
|
||||
"drilldown": {"type": "employee", "query": {"user_id": user_id}},
|
||||
"suggested_action": ACTION_OVERLOAD,
|
||||
})
|
||||
|
||||
return cards
|
||||
@@ -0,0 +1,341 @@
|
||||
"""管理域期聚合查询(批次B)——评分/异常/employees API 的单一数据源。
|
||||
|
||||
窗口:days=[start_day..end_day] 闭区间 yyyymmdd;上期=等长紧邻前窗;
|
||||
个人基线=紧邻当前窗之前的 28 天。所有查询走 (day, ...) 索引,不扫消息表
|
||||
(除 distinct 活跃客户走 msg_daily_stats friend 行)。
|
||||
|
||||
口径要点:
|
||||
- 分位/回复计数:first_reply_fact 窗内行拉回 Python 按员工合并排序取
|
||||
最近邻秩 ceil(q·n)(整除算术 (n+1)//2 / (9n+9)//10——SQLite 无原生
|
||||
ceil,MySQL `/` 返回浮点,Python 侧取秩零方言风险)。合并按员工而非
|
||||
owner:一员工多账号(wx1+wx2→u1,channel=None 时人人如此)的样本必须
|
||||
跨账号归并,逐 owner 取分位再映射会互相覆盖。fact 全库全期 ≤6.8万行,
|
||||
Python 侧聚合可行。
|
||||
- 期末存量(new/cooling/dormant/end_active_friends):状态快照取
|
||||
「每 (user, channel) 最新 ≤end 行」——channel=None 时两渠道各取各的
|
||||
最新行再相加(直接取全渠道最大 day 的那一行会丢另一渠道的存量)。
|
||||
end_active_friends 与 new/cooling/dormant 同源同行(该渠道最新行的
|
||||
active_friends 列),供评分 active_share 用——区别于 active_friends
|
||||
字段的周期 distinct 口径。
|
||||
- active_rooms_avg = SUM(active_rooms)/全窗天数(含无数据日摊薄),
|
||||
非有数据天数——「日均负载」语义。
|
||||
- days_with_data = COUNT(DISTINCT day):单渠道时与 COUNT(*) 等价
|
||||
(PK 含 day+channel,每日至多一行),channel=None 双渠道同日也只计 1 天。
|
||||
- 通话四列恒全渠道(employee_call_daily 无渠道维),channel 参数不过滤。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import statistics
|
||||
from collections import namedtuple
|
||||
from collections.abc import Iterable
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import (
|
||||
EmployeeCallDaily,
|
||||
EmployeeMetricDaily,
|
||||
FirstReplyFact,
|
||||
MsgDailyStats,
|
||||
Staff,
|
||||
)
|
||||
from app.storage.employee_metrics import METRIC_VERSION
|
||||
from app.storage.staff_summary import account_map
|
||||
|
||||
PeriodMetrics = namedtuple("PeriodMetrics", [
|
||||
"user_id", "days_with_data", "msg_sent", "msg_recv", "cust_threads",
|
||||
"threads_answered", "fr_sum_ms", "fr_p50_ms", "fr_p90_ms",
|
||||
"reply_2h_count", "reply_24h_count",
|
||||
"active_friends", # 期内 distinct(直查,非日值求和)
|
||||
"active_rooms_avg", # 日均(全窗天数摊薄)
|
||||
"new_customer", "cooling_customer", "dormant_customer", # 期末单日存量
|
||||
"end_active_friends", # 期末行 active_friends 跨渠道 SUM(active_share 分子)
|
||||
"reactivated_customer", # 期内求和
|
||||
"call_count", "call_seconds", "answered_call", "call_followup",
|
||||
])
|
||||
|
||||
|
||||
def _to_date(k: int) -> dt.date:
|
||||
return dt.date(k // 10000, k // 100 % 100, k % 100)
|
||||
|
||||
|
||||
def _to_key(d: dt.date) -> int:
|
||||
return d.year * 10000 + d.month * 100 + d.day
|
||||
|
||||
|
||||
def day_range(start: int, end: int) -> list[int]:
|
||||
"""闭区间日键列表。"""
|
||||
out, cur = [], _to_date(start)
|
||||
while cur <= _to_date(end):
|
||||
out.append(_to_key(cur))
|
||||
cur += dt.timedelta(days=1)
|
||||
return out
|
||||
|
||||
|
||||
def prev_window(start: int, end: int) -> tuple[int, int]:
|
||||
"""等长紧邻前窗。"""
|
||||
n = len(day_range(start, end))
|
||||
pe = _to_date(start) - dt.timedelta(days=1)
|
||||
ps = pe - dt.timedelta(days=n - 1)
|
||||
return _to_key(ps), _to_key(pe)
|
||||
|
||||
|
||||
def baseline_window(start: int) -> tuple[int, int]:
|
||||
"""个人基线:紧邻当前窗之前的 28 天。"""
|
||||
be = _to_date(start) - dt.timedelta(days=1)
|
||||
bs = be - dt.timedelta(days=27)
|
||||
return _to_key(bs), _to_key(be)
|
||||
|
||||
|
||||
def _percentile(vals: list[int], q_num: int, q_den: int) -> int | None:
|
||||
"""最近邻秩分位 ceil(q·n) 的样本值,q=q_num/q_den(1/2、9/10)。
|
||||
|
||||
整数算术 rank=(q_num*n + q_den - 1)//q_den 对正整数 n 恒落在 [1,n]
|
||||
(与 math.ceil(q·n) 等价,与批次A _percentile 同口径);空列表→None
|
||||
(与 0 严格区分)。
|
||||
"""
|
||||
if not vals:
|
||||
return None
|
||||
s = sorted(vals)
|
||||
n = len(s)
|
||||
rank = max(1, min(n, (q_num * n + q_den - 1) // q_den))
|
||||
return s[rank - 1]
|
||||
|
||||
|
||||
# 累加器初值:全员(staff)都出结果行,无数据员工为零值而非缺行——
|
||||
# employees API 全员列表语义。
|
||||
_ZERO: dict[str, object] = {
|
||||
"days_with_data": 0, "msg_sent": 0, "msg_recv": 0, "cust_threads": 0,
|
||||
"threads_answered": 0, "fr_sum_ms": 0,
|
||||
"fr_p50_ms": None, "fr_p90_ms": None,
|
||||
"reply_2h_count": 0, "reply_24h_count": 0,
|
||||
"active_friends": 0, "active_rooms_sum": 0,
|
||||
"new_customer": 0, "cooling_customer": 0, "dormant_customer": 0,
|
||||
"end_active_friends": 0,
|
||||
"reactivated_customer": 0,
|
||||
"call_count": 0, "call_seconds": 0, "answered_call": 0, "call_followup": 0,
|
||||
}
|
||||
|
||||
|
||||
async def period_metrics(
|
||||
session: AsyncSession,
|
||||
start_day: int,
|
||||
end_day: int,
|
||||
channel: str | None,
|
||||
user_ids: Iterable[str] | None = None,
|
||||
) -> dict[str, PeriodMetrics]:
|
||||
"""窗口 [start_day, end_day] × channel(None=全渠道)的员工期指标。
|
||||
|
||||
user_ids 传入时与 staff 表求交(幽灵过滤);返回含全部目标员工的
|
||||
PeriodMetrics(无数据员工为零值行)。channel=None 时两渠道
|
||||
account_map 合并(wx_id 与 WA uid 命名空间不撞,无冲突)。
|
||||
"""
|
||||
staff_ids = {
|
||||
u for u in (await session.execute(sa.select(Staff.user_id))).scalars() if u
|
||||
}
|
||||
target = staff_ids if user_ids is None else staff_ids & set(user_ids)
|
||||
if not target:
|
||||
return {}
|
||||
|
||||
if channel is None:
|
||||
amap: dict[str, str] = {}
|
||||
for ch in ("wechat", "whatsapp"):
|
||||
amap.update(await account_map(session, ch))
|
||||
else:
|
||||
amap = await account_map(session, channel)
|
||||
|
||||
n_days = len(day_range(start_day, end_day))
|
||||
acc: dict[str, dict] = {uid: dict(_ZERO) for uid in target}
|
||||
|
||||
# ---- 宽表 SUM:消息计数/串类/日均负载/复活 ----
|
||||
wide_conds = [
|
||||
EmployeeMetricDaily.day.between(start_day, end_day),
|
||||
EmployeeMetricDaily.metric_version == METRIC_VERSION,
|
||||
EmployeeMetricDaily.user_id.in_(target),
|
||||
]
|
||||
if channel is not None:
|
||||
wide_conds.append(EmployeeMetricDaily.channel == channel)
|
||||
wide = (await session.execute(
|
||||
sa.select(
|
||||
EmployeeMetricDaily.user_id.label("uid"),
|
||||
sa.func.count(sa.distinct(EmployeeMetricDaily.day)).label("nd"),
|
||||
sa.func.sum(EmployeeMetricDaily.msg_sent).label("sent"),
|
||||
sa.func.sum(EmployeeMetricDaily.msg_recv).label("recv"),
|
||||
sa.func.sum(EmployeeMetricDaily.cust_threads).label("thr"),
|
||||
sa.func.sum(EmployeeMetricDaily.threads_answered).label("ans"),
|
||||
sa.func.sum(EmployeeMetricDaily.fr_sum_ms).label("frs"),
|
||||
sa.func.sum(EmployeeMetricDaily.active_rooms).label("rooms"),
|
||||
sa.func.sum(
|
||||
EmployeeMetricDaily.reactivated_customer_count
|
||||
).label("react"),
|
||||
).where(*wide_conds).group_by(EmployeeMetricDaily.user_id)
|
||||
)).all()
|
||||
for uid, nd, sent, recv, thr, ans, frs, rooms, react in wide:
|
||||
a = acc[uid]
|
||||
a["days_with_data"] = int(nd or 0)
|
||||
a["msg_sent"] = int(sent or 0)
|
||||
a["msg_recv"] = int(recv or 0)
|
||||
a["cust_threads"] = int(thr or 0)
|
||||
a["threads_answered"] = int(ans or 0)
|
||||
a["fr_sum_ms"] = int(frs or 0)
|
||||
a["active_rooms_sum"] = int(rooms or 0)
|
||||
a["reactivated_customer"] = int(react or 0)
|
||||
|
||||
# ---- 期末存量:每 (user, channel) 最新 ≤end 行,跨渠道相加 ----
|
||||
stock_conds = [
|
||||
EmployeeMetricDaily.day <= end_day,
|
||||
EmployeeMetricDaily.metric_version == METRIC_VERSION,
|
||||
EmployeeMetricDaily.user_id.in_(target),
|
||||
]
|
||||
if channel is not None:
|
||||
stock_conds.append(EmployeeMetricDaily.channel == channel)
|
||||
latest = (await session.execute(
|
||||
sa.select(
|
||||
EmployeeMetricDaily.user_id.label("uid"),
|
||||
EmployeeMetricDaily.channel.label("ch"),
|
||||
sa.func.max(EmployeeMetricDaily.day).label("d"),
|
||||
).where(*stock_conds).group_by(
|
||||
EmployeeMetricDaily.user_id, EmployeeMetricDaily.channel
|
||||
)
|
||||
)).all()
|
||||
if latest:
|
||||
# (user, channel, day) 含 metric_version 唯一(主键)——OR 点查
|
||||
# 精确取回各渠道最新行,再按 user 跨渠道 SUM
|
||||
stocks = (await session.execute(
|
||||
sa.select(
|
||||
EmployeeMetricDaily.user_id.label("uid"),
|
||||
sa.func.sum(EmployeeMetricDaily.new_customer_count).label("nw"),
|
||||
sa.func.sum(
|
||||
EmployeeMetricDaily.cooling_customer_count
|
||||
).label("cool"),
|
||||
sa.func.sum(
|
||||
EmployeeMetricDaily.dormant_customer_count
|
||||
).label("dorm"),
|
||||
sa.func.sum(EmployeeMetricDaily.active_friends).label("af"),
|
||||
).where(sa.or_(*(
|
||||
sa.and_(
|
||||
EmployeeMetricDaily.user_id == uid,
|
||||
EmployeeMetricDaily.channel == ch,
|
||||
EmployeeMetricDaily.day == d,
|
||||
EmployeeMetricDaily.metric_version == METRIC_VERSION,
|
||||
)
|
||||
for uid, ch, d in latest
|
||||
))).group_by(EmployeeMetricDaily.user_id)
|
||||
)).all()
|
||||
for uid, nw, cool, dorm, af in stocks:
|
||||
a = acc[uid]
|
||||
a["new_customer"] = int(nw or 0)
|
||||
a["cooling_customer"] = int(cool or 0)
|
||||
a["dormant_customer"] = int(dorm or 0)
|
||||
a["end_active_friends"] = int(af or 0)
|
||||
|
||||
# ---- fact 分位 + 2h/24h 回复计数(同一过滤,Python 侧一次聚合) ----
|
||||
fr_conds = [FirstReplyFact.day.between(start_day, end_day)]
|
||||
if channel is not None:
|
||||
fr_conds.append(FirstReplyFact.channel == channel)
|
||||
fr_rows = (await session.execute(
|
||||
sa.select(
|
||||
FirstReplyFact.owner_account_id.label("owner"),
|
||||
FirstReplyFact.fr_ms.label("fr"),
|
||||
).where(*fr_conds)
|
||||
)).all()
|
||||
fr_by_user: dict[str, list[int]] = {}
|
||||
for owner, fr in fr_rows:
|
||||
uid = amap.get(owner or "")
|
||||
if uid is not None and uid in target:
|
||||
fr_by_user.setdefault(uid, []).append(int(fr))
|
||||
for uid, frs in fr_by_user.items():
|
||||
acc[uid]["fr_p50_ms"] = _percentile(frs, 1, 2)
|
||||
acc[uid]["fr_p90_ms"] = _percentile(frs, 9, 10)
|
||||
# 2h/24h 计数与分位同源同窗——回复率分子分母口径一致
|
||||
acc[uid]["reply_2h_count"] = sum(1 for fr in frs if fr <= 7_200_000)
|
||||
acc[uid]["reply_24h_count"] = sum(1 for fr in frs if fr <= 86_400_000)
|
||||
|
||||
# ---- distinct 活跃客户:账号归并到员工后按 peer 去重 ----
|
||||
af_conds = [
|
||||
MsgDailyStats.day.between(start_day, end_day),
|
||||
MsgDailyStats.peer_kind == "friend",
|
||||
]
|
||||
if channel is not None:
|
||||
af_conds.append(MsgDailyStats.channel == channel)
|
||||
pairs = (await session.execute(
|
||||
sa.select(MsgDailyStats.owner_account_id, MsgDailyStats.peer_id)
|
||||
.distinct().where(*af_conds)
|
||||
)).all()
|
||||
active: dict[str, set[str]] = {}
|
||||
for owner, peer in pairs:
|
||||
uid = amap.get(owner or "")
|
||||
if uid is None or uid not in target:
|
||||
continue
|
||||
# (uid, peer) 粒度:同一员工多账号(wx1+wx2)的共享客户只计 1
|
||||
# ——与宽表日值口径一致(storage 侧账号归并后 add(peer))
|
||||
active.setdefault(uid, set()).add(peer or "")
|
||||
for uid, pset in active.items():
|
||||
acc[uid]["active_friends"] = len(pset)
|
||||
|
||||
# ---- 通话:恒全渠道(表无渠道维,channel 参数不过滤) ----
|
||||
calls = (await session.execute(
|
||||
sa.select(
|
||||
EmployeeCallDaily.user_id.label("uid"),
|
||||
sa.func.sum(EmployeeCallDaily.call_count).label("cc"),
|
||||
sa.func.sum(EmployeeCallDaily.call_seconds).label("cs"),
|
||||
sa.func.sum(EmployeeCallDaily.answered_call_count).label("ca"),
|
||||
sa.func.sum(EmployeeCallDaily.call_followup_count).label("cf"),
|
||||
).where(
|
||||
EmployeeCallDaily.day.between(start_day, end_day),
|
||||
EmployeeCallDaily.user_id.in_(target),
|
||||
).group_by(EmployeeCallDaily.user_id)
|
||||
)).all()
|
||||
for uid, cc, cs, ca, cf in calls:
|
||||
a = acc[uid]
|
||||
a["call_count"] = int(cc or 0)
|
||||
a["call_seconds"] = int(cs or 0)
|
||||
a["answered_call"] = int(ca or 0)
|
||||
a["call_followup"] = int(cf or 0)
|
||||
|
||||
return {
|
||||
uid: PeriodMetrics(
|
||||
user_id=uid,
|
||||
days_with_data=a["days_with_data"],
|
||||
msg_sent=a["msg_sent"],
|
||||
msg_recv=a["msg_recv"],
|
||||
cust_threads=a["cust_threads"],
|
||||
threads_answered=a["threads_answered"],
|
||||
fr_sum_ms=a["fr_sum_ms"],
|
||||
fr_p50_ms=a["fr_p50_ms"],
|
||||
fr_p90_ms=a["fr_p90_ms"],
|
||||
reply_2h_count=a["reply_2h_count"],
|
||||
reply_24h_count=a["reply_24h_count"],
|
||||
active_friends=a["active_friends"],
|
||||
active_rooms_avg=(a["active_rooms_sum"] / n_days) if n_days else 0.0,
|
||||
new_customer=a["new_customer"],
|
||||
cooling_customer=a["cooling_customer"],
|
||||
dormant_customer=a["dormant_customer"],
|
||||
end_active_friends=a["end_active_friends"],
|
||||
reactivated_customer=a["reactivated_customer"],
|
||||
call_count=a["call_count"],
|
||||
call_seconds=a["call_seconds"],
|
||||
answered_call=a["answered_call"],
|
||||
call_followup=a["call_followup"],
|
||||
)
|
||||
for uid, a in acc.items()
|
||||
}
|
||||
|
||||
|
||||
def team_medians(metrics: dict[str, PeriodMetrics]) -> dict[str, float | None]:
|
||||
"""每数值字段的全员中位数(纯函数,评分归一用)。
|
||||
|
||||
None(无样本的分位等)剔除后取中位——statistics.median:奇数取中位
|
||||
样本、偶数取中间两数均值;全员 None → None(调用方按无基线处理)。
|
||||
"""
|
||||
out: dict[str, float | None] = {}
|
||||
for field in PeriodMetrics._fields:
|
||||
if field == "user_id":
|
||||
continue
|
||||
vals = [
|
||||
v for v in (getattr(m, field) for m in metrics.values())
|
||||
if v is not None
|
||||
]
|
||||
out[field] = statistics.median(vals) if vals else None
|
||||
return out
|
||||
@@ -0,0 +1,195 @@
|
||||
"""管理域指标元数据与默认评分配置(批次B,v2 §6/§10)。
|
||||
|
||||
METRIC_DEFINITIONS 是 /api/management/metric-definitions 的单一数据源:
|
||||
名称/单位/方向/口径说明(含与现有看板的三方口径差异标注)。
|
||||
DEFAULT_SCORE_CONFIG 为 v1.0 试算起点——所有阈值标「试算」,调整走
|
||||
新配置版本(employee_score_config),不静默改数。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
# direction: +1 越大越好 / -1 越小越好;caliber 注明与看板差异
|
||||
METRIC_DEFINITIONS: dict[str, dict] = {
|
||||
"msg_sent": {"name": "发出消息(1:1)", "unit": "条", "direction": 1,
|
||||
"caliber": "仅私聊;现有看板为私聊+群聊混合"},
|
||||
"msg_recv": {"name": "收到消息(1:1)", "unit": "条", "direction": 1,
|
||||
"caliber": "仅私聊"},
|
||||
"cust_threads": {"name": "客户消息串", "unit": "串", "direction": 1,
|
||||
"caliber": "跨日精确,按串末条归属日;看板为日切口径(多日串重复计)"},
|
||||
"threads_answered": {"name": "被回串数", "unit": "串", "direction": 1,
|
||||
"caliber": "≡first_reply_fact行数(同源)"},
|
||||
"fr_avg_ms": {"name": "平均首响", "unit": "ms", "direction": -1,
|
||||
"caliber": "fr_sum_ms/threads_answered"},
|
||||
"fr_p50_ms": {"name": "首响中位数", "unit": "ms", "direction": -1,
|
||||
"caliber": "周期内 fact 精确分位(窗口函数)"},
|
||||
"fr_p90_ms": {"name": "首响P90", "unit": "ms", "direction": -1,
|
||||
"caliber": "周期内 fact 精确分位;日切口径会丢跨夜长尾"},
|
||||
"reply_2h_rate": {"name": "2h回复率", "unit": "0-1", "direction": 1,
|
||||
"formula": "count(fr_ms<=2h)/cust_threads",
|
||||
"caliber": "分子分母同源(fact/宽表同一算法)"},
|
||||
"reply_24h_rate": {"name": "24h回复率", "unit": "0-1", "direction": 1,
|
||||
"formula": "count(fr_ms<=24h)/cust_threads"},
|
||||
"active_friends": {"name": "活跃客户(周期)", "unit": "人", "direction": 1,
|
||||
"caliber": "期内distinct客户(msg_daily_stats直查),非宽表日值求和"},
|
||||
"active_rooms": {"name": "活跃群(日均)", "unit": "个", "direction": 1},
|
||||
"new_customer_count": {"name": "新客(期末)", "unit": "人", "direction": 1,
|
||||
"caliber": "期末单日持有状态,非期内求和"},
|
||||
"cooling_customer_count": {"name": "降温客户(期末)", "unit": "人", "direction": -1,
|
||||
"caliber": "距今天数口径(7-29天);频次基线口径二期"},
|
||||
"dormant_customer_count": {"name": "沉睡客户(期末)", "unit": "人", "direction": -1,
|
||||
"caliber": "≥30天且历史≥5条"},
|
||||
"reactivated_customer_count": {"name": "回流客户(期内)", "unit": "人", "direction": 1,
|
||||
"caliber": "当日回流且前次间隔>30天,期内求和"},
|
||||
"call_count": {"name": "通话数", "unit": "通", "direction": 1,
|
||||
"caliber": "语音通话(微信+WA),无消息渠道维度"},
|
||||
"answered_rate": {"name": "接通率", "unit": "0-1", "direction": 1,
|
||||
"formula": "answered_call/call_count", "caliber": "接通=duration>0"},
|
||||
"call_followup_rate": {"name": "通话后跟进率", "unit": "0-1", "direction": 1,
|
||||
"formula": "call_followup_count/answered_call",
|
||||
"caliber": "接通后24h内任一账号有1:1消息(含客户发来)"},
|
||||
"active_share": {"name": "活跃客户占比", "unit": "0-1", "direction": 1,
|
||||
"formula": "期末active_friends/(期末active+cooling+dormant)",
|
||||
"caliber": "试算口径:期末单日存量;宽表无生命周期active存量列,active以active_friends列充当"},
|
||||
}
|
||||
|
||||
# 阈值全部为试算起点(v2 §0 决议:全默认+试算标记);type 三种:
|
||||
# target : floor..target 线性 0..100(方向已含在 target>floor)
|
||||
# reverse : ≤target_ms 100 分,≥floor_ms 0 分,中间线性降
|
||||
# band : 区间内 100,超上限 80(不再加分),低于下限 None(负载
|
||||
# 不足→不评分)——得分取值范围 80..100
|
||||
DEFAULT_SCORE_CONFIG: dict = {
|
||||
"version": "v1.0",
|
||||
"name": "一期试算(3维可用)",
|
||||
"normalization": "available_only_rescale", # 可用维度 100/Σweight 再 ×100
|
||||
"trial": True,
|
||||
"sample_guards": {"min_threads": 30, "soft_threads": 10, "min_days": 5},
|
||||
"grade_bounds": {"A": 90, "B": 80, "C": 70, "D": 60}, # 低于 D 线为 E
|
||||
"dimensions": {
|
||||
"customer": {"weight": 30, "name": "客户经营", "metrics": {
|
||||
"active_share": {"type": "target", "floor": 0.20, "target": 0.60},
|
||||
}},
|
||||
"service": {"weight": 25, "name": "服务质量", "metrics": {
|
||||
"reply_2h_rate": {"type": "target", "floor": 0.40, "target": 0.85},
|
||||
"reply_24h_rate": {"type": "target", "floor": 0.75, "target": 0.97},
|
||||
"fr_p90_ms": {"type": "reverse", "target_ms": 1_800_000,
|
||||
"floor_ms": 14_400_000}, # 30min..4h
|
||||
}},
|
||||
"action": {"weight": 20, "name": "行动执行",
|
||||
"status": "pending_data"}, # 批次E 接 followups/任务
|
||||
"workload": {"weight": 15, "name": "有效负载", "metrics": {
|
||||
# basis=period_distinct:用周期 distinct active_friends(B1 口径),
|
||||
# 比单日期末值更抗单日噪声(2026-09-09 B2 决议)
|
||||
"active_friends_band": {"type": "band", "metric": "active_friends",
|
||||
"low": 5, "high": 80,
|
||||
"basis": "period_distinct"},
|
||||
"call_band": {"type": "band", "metric": "call_count",
|
||||
"low": 1, "high": 200, "basis": "period_total",
|
||||
"note": "通话低不扣分仅提示;band 得分下限 80"},
|
||||
}},
|
||||
"risk": {"weight": 10, "name": "合规风险",
|
||||
"status": "pending_data"}, # 批次E/三期接风险事件
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def validate_config(config: dict) -> list[str]:
|
||||
"""配置校验:权重总和/结构/引用的 metric_code 有元数据/阈值形状/
|
||||
grade_bounds 形状/指标 code 跨维度唯一。
|
||||
|
||||
阈值形状(随评分引擎落地的 B0 评审预埋要求):
|
||||
- type 须为 target/reverse/band 三者之一
|
||||
- target : floor < target
|
||||
- reverse: target_ms < floor_ms(100 分线在 0 分线之前)
|
||||
- band : low < high
|
||||
B4 预埋(配置将自 DB 加载,写入侧不再重跑种子校验):
|
||||
- grade_bounds 须含 A/B/C/D 且严格降序
|
||||
- 指标 code 跨维度不得重复:metrics_detail 为扁平 dict,
|
||||
同名 code 会相互覆盖(后写胜出)
|
||||
"""
|
||||
problems: list[str] = []
|
||||
dims = config.get("dimensions", {})
|
||||
total = sum(d.get("weight", 0) for d in dims.values())
|
||||
if total != 100:
|
||||
problems.append(f"维度权重总和须=100,当前 {total}")
|
||||
gb = config.get("grade_bounds")
|
||||
if not isinstance(gb, dict) or not all(
|
||||
isinstance(gb.get(k), int) for k in ("A", "B", "C", "D")
|
||||
):
|
||||
problems.append("grade_bounds 须含 A/B/C/D 且严格降序")
|
||||
elif not (gb["A"] > gb["B"] > gb["C"] > gb["D"]):
|
||||
problems.append(
|
||||
f"grade_bounds 须严格降序 A>B>C>D"
|
||||
f"(当前 A={gb['A']} B={gb['B']} C={gb['C']} D={gb['D']})"
|
||||
)
|
||||
seen_codes: dict[str, str] = {}
|
||||
for key, dim in dims.items():
|
||||
if dim.get("status") == "pending_data":
|
||||
continue
|
||||
for code, spec in dim.get("metrics", {}).items():
|
||||
if code in seen_codes:
|
||||
problems.append(f"指标 code 重复定义于多维度: {code}")
|
||||
seen_codes[code] = key
|
||||
src = spec.get("metric", code)
|
||||
if src not in METRIC_DEFINITIONS:
|
||||
problems.append(f"{key}.{code} 引用未定义指标 {src}")
|
||||
mtype = spec.get("type")
|
||||
if mtype not in ("target", "reverse", "band"):
|
||||
problems.append(
|
||||
f"{key}.{code} 类型非法:{mtype!r}(须 target/reverse/band)"
|
||||
)
|
||||
elif mtype == "target" and not spec.get("floor", 0) < spec.get("target", 0):
|
||||
problems.append(
|
||||
f"{key}.{code} target 型须 floor<target"
|
||||
f"(当前 floor={spec.get('floor')} target={spec.get('target')})"
|
||||
)
|
||||
elif mtype == "reverse" and not (
|
||||
spec.get("target_ms", 0) < spec.get("floor_ms", 0)
|
||||
):
|
||||
problems.append(
|
||||
f"{key}.{code} reverse 型须 target_ms<floor_ms"
|
||||
f"(当前 target_ms={spec.get('target_ms')}"
|
||||
f" floor_ms={spec.get('floor_ms')})"
|
||||
)
|
||||
elif mtype == "band" and not spec.get("low", 0) < spec.get("high", 0):
|
||||
problems.append(
|
||||
f"{key}.{code} band 型须 low<high"
|
||||
f"(当前 low={spec.get('low')} high={spec.get('high')})"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
async def _seed_score_config(session) -> str | None:
|
||||
"""种子默认评分配置;已存在同版本返回 None(幂等)。纯会话函数可测。
|
||||
|
||||
同 ensure_starter_words 先例:域包提供纯会话种子函数,CLI 只做接线。
|
||||
config_json 用 deepcopy 冻结快照——JSON 列须整列重赋值,深拷贝防止
|
||||
长驻 API 进程未来的原地修改波及模块常量。
|
||||
"""
|
||||
from app.db.models import EmployeeScoreConfig
|
||||
|
||||
problems = validate_config(DEFAULT_SCORE_CONFIG)
|
||||
if problems:
|
||||
raise ValueError(f"score config invalid: {problems}")
|
||||
exists = (
|
||||
await session.execute(
|
||||
sa.select(EmployeeScoreConfig.id).where(
|
||||
EmployeeScoreConfig.version == DEFAULT_SCORE_CONFIG["version"]
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists is not None:
|
||||
return None
|
||||
session.add(
|
||||
EmployeeScoreConfig(
|
||||
version=DEFAULT_SCORE_CONFIG["version"],
|
||||
name=DEFAULT_SCORE_CONFIG["name"],
|
||||
status="active",
|
||||
config_json=copy.deepcopy(DEFAULT_SCORE_CONFIG),
|
||||
created_by="seed-cli",
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
return DEFAULT_SCORE_CONFIG["version"]
|
||||
@@ -0,0 +1,230 @@
|
||||
"""管理域评分引擎(批次B)——试算版(v2 §0 决议:全默认+试算标记)。
|
||||
|
||||
三类标准化(v1 §10.2):
|
||||
- target : floor..target 线性 0..100(≤floor 得 0,≥target 得 100)
|
||||
- reverse : ≤target_ms 100 分,≥floor_ms 0 分,中间线性降
|
||||
- band : 区间内 100,超上限 80(不再加),低于下限 None(负载不足→不评分)
|
||||
样本保护(v1 §10.3):指标样本量=串类 cust_threads/band 类 active_friends;
|
||||
<soft_threads(10) insufficient 不评分;soft(10)~min_threads(30) 低置信照评。
|
||||
维度分=指标均分(等权,None 剔除按剩余归一);总分=可用维度加权重归一
|
||||
(满分 Σweight×100/Σweight,一期可用 70 → 100/70 放大回百分制)——
|
||||
action/risk pending_data 不参与,不默认 0/100。
|
||||
|
||||
band 基数=周期 distinct active_friends(basis=period_distinct,B2 决议:
|
||||
比单日期末值更抗噪声且与 B1 口径一致)。
|
||||
|
||||
team 中位数键=派生指标名(spec 的 metric 字段,无则 code 本身):
|
||||
fr_p90_ms/active_friends/call_count 可直接用 team_medians 输出,
|
||||
reply_*_rate/active_share 需调用方对派生值取中位后传入。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from app.management.employee_metrics import PeriodMetrics
|
||||
from app.management.metric_definitions import METRIC_DEFINITIONS
|
||||
|
||||
_GRADE_ORDER = ("A", "B", "C", "D") # 低于 D 线为 E
|
||||
|
||||
|
||||
def normalize(spec: dict, value: float | None) -> float | None:
|
||||
"""三类标准化(v1 §10.2):value None→None;band 低于下限→None。
|
||||
|
||||
- target : (v-floor)/(target-floor) 线性 0..100,两端截断
|
||||
- reverse : (floor_ms-v)/(floor_ms-target_ms) 线性降 100..0,两端截断
|
||||
- band : [low,high] 内 100,>high 80(不再加),<low None(不评分)
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
mtype = spec["type"]
|
||||
if mtype == "target":
|
||||
floor, target = spec["floor"], spec["target"]
|
||||
if value <= floor:
|
||||
return 0.0
|
||||
if value >= target:
|
||||
return 100.0
|
||||
return (value - floor) / (target - floor) * 100.0
|
||||
if mtype == "reverse":
|
||||
target_ms, floor_ms = spec["target_ms"], spec["floor_ms"]
|
||||
if value <= target_ms:
|
||||
return 100.0
|
||||
if value >= floor_ms:
|
||||
return 0.0
|
||||
return (floor_ms - value) / (floor_ms - target_ms) * 100.0
|
||||
if mtype == "band":
|
||||
low, high = spec["low"], spec["high"]
|
||||
if value < low:
|
||||
return None
|
||||
if value > high:
|
||||
return 80.0
|
||||
return 100.0
|
||||
raise ValueError(f"未知指标类型 {mtype!r}(须 target/reverse/band)")
|
||||
|
||||
|
||||
def derive_metrics(pm: PeriodMetrics) -> dict[str, float | None]:
|
||||
"""从 PeriodMetrics 派生可评分指标值(键=指标名,None=无值≠0)。
|
||||
|
||||
公有供 anomalies(B3)/dashboard(B4) 复用——评分与异常对比同一派生口径。
|
||||
"""
|
||||
out: dict[str, float | None] = {
|
||||
"fr_p90_ms": pm.fr_p90_ms,
|
||||
"active_friends": pm.active_friends, # band 用,周期 distinct
|
||||
"call_count": pm.call_count, # band 用,period_total
|
||||
}
|
||||
if pm.cust_threads:
|
||||
out["reply_2h_rate"] = pm.reply_2h_count / pm.cust_threads
|
||||
out["reply_24h_rate"] = pm.reply_24h_count / pm.cust_threads
|
||||
else:
|
||||
# 无串样本→无率(严格区分 0%),分子分母同源
|
||||
out["reply_2h_rate"] = None
|
||||
out["reply_24h_rate"] = None
|
||||
denom = pm.end_active_friends + pm.cooling_customer + pm.dormant_customer
|
||||
out["active_share"] = (
|
||||
pm.end_active_friends / denom if denom > 0 else None
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _fmt_ms(ms: float) -> str:
|
||||
minutes = ms / 60_000
|
||||
if minutes >= 60 and minutes % 60 == 0:
|
||||
return f"{minutes // 60:g}h"
|
||||
return f"{minutes:g}min"
|
||||
|
||||
|
||||
def _formula_note(spec: dict) -> str:
|
||||
"""指标计分公式的中文说明(B4 透出,阈值全部来自 config)。"""
|
||||
mtype = spec["type"]
|
||||
if mtype == "target":
|
||||
return (
|
||||
f"floor {spec['floor']}..target {spec['target']} 线性 0..100 分"
|
||||
"(≤floor 0,≥target 100)"
|
||||
)
|
||||
if mtype == "reverse":
|
||||
return (
|
||||
f"≤{_fmt_ms(spec['target_ms'])} 100 分,"
|
||||
f"≥{_fmt_ms(spec['floor_ms'])} 0 分,中间线性降"
|
||||
)
|
||||
return (
|
||||
f"{spec['low']}..{spec['high']} 区间内 100 分,超上限 80 分,"
|
||||
f"低于 {spec['low']} 不评分(负载不足);basis={spec.get('basis', '-')}"
|
||||
)
|
||||
|
||||
|
||||
def score_employee(
|
||||
config: dict,
|
||||
current: PeriodMetrics,
|
||||
baseline: PeriodMetrics | None = None,
|
||||
team: dict[str, float] | None = None,
|
||||
prev: PeriodMetrics | None = None,
|
||||
) -> dict:
|
||||
"""单员工试算评分(纯函数,B4 端点直接透出)。
|
||||
|
||||
prev/baseline/team 为四对比位槽位:prev/baseline 按同口径派生后对比,
|
||||
team 为指标级中位数(键=派生指标名,可 None)。返回结构见模块 docstring
|
||||
——metrics_detail 键=配置里的 metric_code(band 型如 active_friends_band)。
|
||||
"""
|
||||
cur_vals = derive_metrics(current)
|
||||
prev_vals = derive_metrics(prev) if prev is not None else None
|
||||
base_vals = derive_metrics(baseline) if baseline is not None else None
|
||||
guards = config.get("sample_guards", {})
|
||||
soft = guards.get("soft_threads", 10)
|
||||
minimum = guards.get("min_threads", 30)
|
||||
|
||||
dimensions: dict[str, dict] = {}
|
||||
metrics_detail: dict[str, dict] = {}
|
||||
|
||||
for dim_key, dim in config.get("dimensions", {}).items():
|
||||
entry: dict = {
|
||||
"name": dim.get("name", dim_key),
|
||||
"weight": dim.get("weight", 0),
|
||||
"score": None,
|
||||
"status": "pending",
|
||||
}
|
||||
if dim.get("status") == "pending_data" or not dim.get("metrics"):
|
||||
# 一期 action/risk:数据未接入——不参与归一,不带 metrics
|
||||
dimensions[dim_key] = entry
|
||||
continue
|
||||
|
||||
dim_scores: list[float] = []
|
||||
any_insufficient = False # 有值但样本不足→维度 insufficient
|
||||
dim_metrics: dict[str, dict] = {}
|
||||
for code, spec in dim["metrics"].items():
|
||||
src = spec.get("metric", code)
|
||||
value = cur_vals.get(src)
|
||||
# 样本量:串类=cust_threads,band 类=active_friends
|
||||
sample_size = (
|
||||
current.active_friends
|
||||
if spec.get("type") == "band" else current.cust_threads
|
||||
)
|
||||
if sample_size >= minimum:
|
||||
sample_status = "ok"
|
||||
elif sample_size >= soft:
|
||||
sample_status = "low_confidence"
|
||||
else:
|
||||
sample_status = "insufficient"
|
||||
score: float | None = None
|
||||
if value is not None:
|
||||
if sample_status == "insufficient":
|
||||
any_insufficient = True # 有可评值但不评分
|
||||
else:
|
||||
score = normalize(spec, value)
|
||||
if score is not None:
|
||||
dim_scores.append(score)
|
||||
dim_metrics[code] = {
|
||||
"value": value,
|
||||
"score": score,
|
||||
"sample_size": sample_size,
|
||||
"sample_status": sample_status,
|
||||
"formula_note": _formula_note(spec),
|
||||
}
|
||||
metrics_detail[code] = {
|
||||
"value": value,
|
||||
"prev": prev_vals.get(src) if prev_vals else None,
|
||||
"baseline": base_vals.get(src) if base_vals else None,
|
||||
"median": team.get(src) if team else None,
|
||||
"sample_size": sample_size,
|
||||
"sample_status": sample_status,
|
||||
"score": score,
|
||||
"unit": METRIC_DEFINITIONS.get(src, {}).get("unit", ""),
|
||||
"name": METRIC_DEFINITIONS.get(src, {}).get("name", code),
|
||||
}
|
||||
|
||||
if dim_scores:
|
||||
entry["score"] = sum(dim_scores) / len(dim_scores)
|
||||
entry["status"] = "scored"
|
||||
elif any_insufficient:
|
||||
entry["status"] = "insufficient"
|
||||
else:
|
||||
entry["status"] = "pending" # 全部无值(期末无存量/无串样本等)
|
||||
entry["metrics"] = dim_metrics
|
||||
dimensions[dim_key] = entry
|
||||
|
||||
# 可用维度加权重归一:Σ(w×s)/Σw(满分=Σw×100/Σw=100),四舍五入取整
|
||||
available = [
|
||||
(d["weight"], d["score"]) for d in dimensions.values()
|
||||
if d["score"] is not None
|
||||
]
|
||||
total: int | None = None
|
||||
if available:
|
||||
wsum = sum(w for w, _ in available)
|
||||
total = int(math.floor(
|
||||
sum(w * s for w, s in available) / wsum + 0.5
|
||||
))
|
||||
grade: str | None = None
|
||||
if total is not None:
|
||||
grade = "E"
|
||||
bounds = config.get("grade_bounds", {})
|
||||
for g in _GRADE_ORDER: # A..D 阈值递减,首中即停
|
||||
if total >= bounds[g]:
|
||||
grade = g
|
||||
break
|
||||
|
||||
return {
|
||||
"total_score": total,
|
||||
"grade": grade,
|
||||
"score_status": "scored" if total is not None else "insufficient_sample",
|
||||
"trial": bool(config.get("trial")),
|
||||
"dimensions": dimensions,
|
||||
"metrics_detail": metrics_detail,
|
||||
}
|
||||
@@ -901,7 +901,7 @@ def _normalize_chat_group(record: dict[str, Any]) -> dict[str, Any]:
|
||||
``'YYYY-MM-DD HH:mm:ss'`` 字符串,另兼容 ms-int/数字串,统一归一为
|
||||
ms-epoch int,复用 ``_parse_add_time`` 的双形态解析)
|
||||
"""
|
||||
return {
|
||||
rec = {
|
||||
"room_id": record.get("friendWxId"),
|
||||
"channel": "wechat",
|
||||
"room_name": record.get("friendWxNickname"),
|
||||
@@ -929,6 +929,12 @@ def _normalize_chat_group(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"version": record.get("version"),
|
||||
"raw_json": record,
|
||||
}
|
||||
# 群备注(2026-09-09 微信群统计):friend_list/es 是唯一备注源
|
||||
# (getChatroomList 行无备注键);**有值才写**——空值不放键,upsert 不
|
||||
# 更新该列,不会被无备注的抓取轮次清空
|
||||
if record.get("friendWxRemark"):
|
||||
rec["group_remark"] = record["friendWxRemark"]
|
||||
return rec
|
||||
|
||||
|
||||
async def scrape_wechat_groups(
|
||||
|
||||
@@ -15,6 +15,7 @@ import logging
|
||||
import math
|
||||
from bisect import bisect_right
|
||||
from collections.abc import Iterable
|
||||
from itertools import accumulate
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -170,8 +171,10 @@ async def _lifecycle_daily(
|
||||
) -> dict[int, dict[str, dict[str, int]]]:
|
||||
"""逐日 {day: {user: {state: count}}}——customer 当前归属 + 互动日 sweep。
|
||||
|
||||
数据源:msg_daily_stats friend 行全历史(互动日/条数)。16k 客户 × 窗口
|
||||
天数 bisect,夜间全量约秒级;4h tick 只算当日+3 天。
|
||||
数据源:msg_daily_stats friend 行全历史(互动日/条数)。hist 为 ≤as_of
|
||||
的历史消息数(时点语义:与 last 同一 bisect 位取按日累积数组 cum[idx],
|
||||
as_of 之后的消息不虚增历史日的沉睡门槛)。16k 客户 × 窗口天数
|
||||
bisect+classify,夜间全量实测见批次A核实文档;4h tick 只算当日+3 天。
|
||||
"""
|
||||
rows = (
|
||||
await session.execute(
|
||||
@@ -184,12 +187,13 @@ async def _lifecycle_daily(
|
||||
)
|
||||
)
|
||||
).all()
|
||||
days_map: dict[str, set[int]] = {}
|
||||
cnt: dict[str, int] = {}
|
||||
# peer → {day: 消息条数};days_list 与累积数组 cum 对齐:
|
||||
# cum[i] = 该客户 ≤days_list[i] 的历史消息数(hist 时点语义)
|
||||
day_msgs: dict[str, dict[int, int]] = {}
|
||||
for peer, day, snd, rcv in rows:
|
||||
days_map.setdefault(peer, set()).add(day)
|
||||
cnt[peer] = cnt.get(peer, 0) + int(snd or 0) + int(rcv or 0)
|
||||
interaction = {p: sorted(ds) for p, ds in days_map.items()}
|
||||
dm = day_msgs.setdefault(peer, {})
|
||||
dm[day] = dm.get(day, 0) + int(snd or 0) + int(rcv or 0)
|
||||
interaction = {p: sorted(dm) for p, dm in day_msgs.items()}
|
||||
|
||||
custs = (
|
||||
await session.execute(
|
||||
@@ -212,14 +216,15 @@ async def _lifecycle_daily(
|
||||
if uid is None:
|
||||
continue
|
||||
first = days_list[0]
|
||||
hist = cnt.get(peer, 0)
|
||||
cum = list(accumulate(day_msgs[peer][d] for d in days_list))
|
||||
for d in days:
|
||||
idx = bisect_right(days_list, d) - 1
|
||||
if idx < 0:
|
||||
continue
|
||||
last = days_list[idx]
|
||||
prev = days_list[idx - 1] if idx > 0 else None
|
||||
state = classify(first, last, prev, d, hist)
|
||||
hist_asof = cum[idx]
|
||||
state = classify(first, last, prev, d, hist_asof)
|
||||
if state:
|
||||
out[d].setdefault(uid, {}).setdefault(state, 0)
|
||||
out[d][uid][state] += 1
|
||||
@@ -345,4 +350,7 @@ async def recompute_metric_days(
|
||||
)
|
||||
)
|
||||
written += 1
|
||||
# 防御性 flush:防 autoflush=False 类配置下逐日 add 的 ORM 对象
|
||||
# 攒到事务尾(夜间全量约 52k 行;与 Task3 周期性 flush 同教训)
|
||||
await session.flush()
|
||||
return written
|
||||
|
||||
@@ -62,6 +62,7 @@ from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.db.models import (
|
||||
ChatGroup,
|
||||
ChatGroupMemberStats,
|
||||
Customer,
|
||||
CustomerAccountLink,
|
||||
Department,
|
||||
@@ -82,6 +83,7 @@ _logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"upsert_chat_group",
|
||||
"upsert_chat_group_member_stats",
|
||||
"upsert_customer",
|
||||
"upsert_customers",
|
||||
"prune_customer_links",
|
||||
@@ -631,6 +633,18 @@ async def upsert_chat_group(session: AsyncSession, rec: dict[str, Any]) -> None:
|
||||
await _upsert_one(session, GroupMember, member_rec)
|
||||
|
||||
|
||||
async def upsert_chat_group_member_stats(
|
||||
session: AsyncSession, rec: dict[str, Any]
|
||||
) -> None:
|
||||
"""Idempotently upsert one :class:`ChatGroupMemberStats` day row.
|
||||
|
||||
PK = (channel, owner_account_id, room_id, day)。每夜「当日窗」抓取的
|
||||
addMembers/lossMembers 落一行;同日重跑刷新(幂等)。页面任意日窗的
|
||||
新增/退出 = 按天求和(2026-09-09 微信群统计对齐)。
|
||||
"""
|
||||
await _upsert_one(session, ChatGroupMemberStats, rec)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PhoneCall (voice_call) upsert (M2, Task 2.4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -15,6 +15,9 @@ Three invocations (plus analytics backfills, see below)::
|
||||
python -m app.sync.cli daily-stats
|
||||
python -m app.sync.cli staff-daily-stats
|
||||
python -m app.sync.cli sensitive-scan --full
|
||||
python -m app.sync.cli first-reply-facts
|
||||
python -m app.sync.cli employee-metrics
|
||||
python -m app.sync.cli score-config
|
||||
|
||||
Auth + DB
|
||||
---------
|
||||
@@ -49,6 +52,8 @@ import signal
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import models # noqa: F401 (register tables on Base.metadata)
|
||||
from app.db.base import Base
|
||||
@@ -136,6 +141,43 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
help="Full backfill scan (first deploy / new-word history).",
|
||||
)
|
||||
|
||||
# ``first-reply-facts`` ----------------------------------------------
|
||||
p_fr = sub.add_parser(
|
||||
"first-reply-facts",
|
||||
help="Rebuild first_reply_fact (cross-day exact threads).",
|
||||
description=(
|
||||
"Full rebuild of the first-reply fact table from the message "
|
||||
"table (cross-day exact thread algorithm — overnight samples "
|
||||
"not lost, unlike msg_daily_stats day-cut). Deploy backfill + "
|
||||
"nightly self-heal step. Idempotent."
|
||||
),
|
||||
)
|
||||
|
||||
# ``employee-metrics`` ------------------------------------------------
|
||||
p_emp = sub.add_parser(
|
||||
"employee-metrics",
|
||||
help="Rebuild employee_metric_daily + employee_call_daily.",
|
||||
description=(
|
||||
"Full rebuild of the management wide table (per-day x channel "
|
||||
"x staff, 1:1 caliber) and the call daily table. Must run "
|
||||
"AFTER first-reply-facts (the fact table feeds Batch-B "
|
||||
"scoring and drilldown; both recompute threads from messages "
|
||||
"via one shared algorithm). Deploy backfill + nightly "
|
||||
"self-heal. Idempotent."
|
||||
),
|
||||
)
|
||||
|
||||
# ``score-config`` ----------------------------------------------------
|
||||
p_score = sub.add_parser(
|
||||
"score-config",
|
||||
help="Seed the default score config (idempotent).",
|
||||
description=(
|
||||
"Insert DEFAULT_SCORE_CONFIG (v1.0, trial) into "
|
||||
"employee_score_config as active. Skips if the version "
|
||||
"already exists. Batch B."
|
||||
),
|
||||
)
|
||||
|
||||
# ``scheduler`` -----------------------------------------------------------
|
||||
p_sched = sub.add_parser(
|
||||
"scheduler",
|
||||
@@ -374,6 +416,77 @@ async def _cmd_sensitive_scan(args: argparse.Namespace) -> int:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _cmd_first_reply_facts(args: argparse.Namespace) -> int:
|
||||
"""全量重建 first_reply_fact(跨日精确串;部署回填/夜间自愈,幂等)。"""
|
||||
settings = get_settings()
|
||||
engine, session_maker = await _init_db(settings.db_url)
|
||||
try:
|
||||
from app.storage.first_reply_facts import recompute_all
|
||||
|
||||
async with session_maker() as session:
|
||||
pairs = await recompute_all(session)
|
||||
await session.commit()
|
||||
print(f"[first-reply-facts] rebuilt {pairs} conversations")
|
||||
return 0
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _cmd_employee_metrics(args: argparse.Namespace) -> int:
|
||||
"""全量重建 employee_metric_daily + employee_call_daily(幂等)。"""
|
||||
settings = get_settings()
|
||||
engine, session_maker = await _init_db(settings.db_url)
|
||||
try:
|
||||
from app.db.models import MsgDailyStats, PhoneCall
|
||||
from app.storage.employee_metrics import (
|
||||
recompute_call_days,
|
||||
recompute_metric_days,
|
||||
)
|
||||
from app.storage.summary import day_key
|
||||
|
||||
async with session_maker() as session:
|
||||
msg_days = (
|
||||
await session.execute(sa.select(MsgDailyStats.day).distinct())
|
||||
).scalars().all()
|
||||
# 宽表全走消息派生源,只算消息日。
|
||||
rows = await recompute_metric_days(session, list(msg_days))
|
||||
# 通话日 = 消息日 ∪ 通话日:消息空窗日(节假日/同步缺口)的通话
|
||||
# 也要进小表。start_call_time→上海日必须在 Python 侧走 day_key
|
||||
# ——MySQL 会话时区是 UTC,SQL 日期函数会切错日。
|
||||
call_ts = (
|
||||
await session.execute(
|
||||
sa.select(PhoneCall.start_call_time)
|
||||
.where(PhoneCall.start_call_time.is_not(None))
|
||||
.distinct()
|
||||
)
|
||||
).scalars().all()
|
||||
call_days = sorted(set(msg_days) | {day_key(ts) for ts in call_ts})
|
||||
calls = await recompute_call_days(session, call_days)
|
||||
await session.commit()
|
||||
print(
|
||||
f"[employee-metrics] rebuilt {rows} wide rows, {calls} call rows "
|
||||
f"({len(call_days)} days)"
|
||||
)
|
||||
return 0
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _cmd_score_config(args: argparse.Namespace) -> int:
|
||||
settings = get_settings()
|
||||
engine, session_maker = await _init_db(settings.db_url)
|
||||
try:
|
||||
from app.management.metric_definitions import _seed_score_config
|
||||
|
||||
async with session_maker() as session:
|
||||
version = await _seed_score_config(session)
|
||||
await session.commit()
|
||||
print(f"[score-config] {'seeded ' + version if version else 'already present, skipped'}")
|
||||
return 0
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""CLI entry point. Returns a process exit code."""
|
||||
# Configure logging so the scheduler's JSON line + the orchestrator's
|
||||
@@ -398,6 +511,12 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return asyncio.run(_cmd_staff_daily_stats(args))
|
||||
if args.command == "sensitive-scan":
|
||||
return asyncio.run(_cmd_sensitive_scan(args))
|
||||
if args.command == "first-reply-facts":
|
||||
return asyncio.run(_cmd_first_reply_facts(args))
|
||||
if args.command == "employee-metrics":
|
||||
return asyncio.run(_cmd_employee_metrics(args))
|
||||
if args.command == "score-config":
|
||||
return asyncio.run(_cmd_score_config(args))
|
||||
except KeyboardInterrupt:
|
||||
# So `python -m app.sync.cli scheduler` exits cleanly on Ctrl-C.
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""chat_group 补 group_remark 列 + chat_group_member_stats 按天进出表
|
||||
|
||||
Revision ID: c9d0e1f2a3b4
|
||||
Revises: b7c8d9e0f1a2
|
||||
Create Date: 2026-09-09
|
||||
|
||||
内容
|
||||
----
|
||||
微信群列表对齐云客「微信群统计」页(2026-09-09 真机探明,数据源切
|
||||
``getChatroomList``):
|
||||
|
||||
* ``chat_group.group_remark`` —— 群备注列(getChatroomList.remark /
|
||||
friend_list/es 的 friendWxRemark,此前只存 raw_json);
|
||||
* ``chat_group_member_stats`` —— 群成员进出**按天**统计表。云客的新增/
|
||||
退出成员数是按任意时间窗实时算的,本地无成员进出事件流,改为每夜抓
|
||||
一次「当日窗」的 addMembers/lossMembers 落行,页面任意日窗=按天求和;
|
||||
上线日起积累,历史日期无数据。
|
||||
|
||||
幂等:列/表存在即跳过。SQLite/MySQL 通用。
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c9d0e1f2a3b4'
|
||||
down_revision: str | Sequence[str] | None = 'b7c8d9e0f1a2'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_TABLE = 'chat_group_member_stats'
|
||||
_COL = 'group_remark'
|
||||
|
||||
|
||||
def _has_table(table: str) -> bool:
|
||||
insp = sa.inspect(op.get_bind())
|
||||
return table in insp.get_table_names()
|
||||
|
||||
|
||||
def _has_column(table: str, column: str) -> bool:
|
||||
insp = sa.inspect(op.get_bind())
|
||||
return column in {c["name"] for c in insp.get_columns(table)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
if not _has_column("chat_group", _COL):
|
||||
op.add_column("chat_group", sa.Column(_COL, sa.String(255), nullable=True))
|
||||
# 回填:群备注从存量 raw_json.friendWxRemark 提取(friend_list/es 来源;
|
||||
# getChatroomList 行无备注键)。幂等:只补 NULL 行。MySQL 方言
|
||||
# (JSON_UNQUOTE),SQLite(测试库,不走生产迁移)跳过。
|
||||
if op.get_bind().dialect.name == "mysql":
|
||||
op.execute(
|
||||
"UPDATE chat_group SET group_remark = JSON_UNQUOTE(JSON_EXTRACT(raw_json, '$.friendWxRemark')) "
|
||||
"WHERE group_remark IS NULL "
|
||||
"AND JSON_UNQUOTE(JSON_EXTRACT(raw_json, '$.friendWxRemark')) IS NOT NULL "
|
||||
"AND JSON_UNQUOTE(JSON_EXTRACT(raw_json, '$.friendWxRemark')) != ''"
|
||||
)
|
||||
if _has_table(_TABLE):
|
||||
return # 已建过,幂等跳过
|
||||
op.create_table(
|
||||
_TABLE,
|
||||
sa.Column('channel', sa.String(16), primary_key=True),
|
||||
sa.Column('owner_account_id', sa.String(128), primary_key=True),
|
||||
sa.Column('room_id', sa.String(128), primary_key=True),
|
||||
sa.Column('day', sa.Integer(), primary_key=True),
|
||||
sa.Column('added', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('lost', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('fetched_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
if _has_table(_TABLE):
|
||||
op.drop_table(_TABLE)
|
||||
if _has_column("chat_group", _COL):
|
||||
op.drop_column("chat_group", _COL)
|
||||
@@ -26,6 +26,12 @@ echo "$(date) --- 5/10 WeChat groups ---"
|
||||
$PY scripts/scrape_via_page.py --entity groups --max-employees 100
|
||||
echo "$(date) --- 5b/10 WA groups ---"
|
||||
$PY scripts/scrape_via_page.py --entity wa-groups
|
||||
# 5c: 微信群统计(getChatroomList,云客「微信群统计」同款)——全窗刷档案列
|
||||
# (真成员数/群备注/员工信息)+ 昨日窗落成员进出日行(页面窗内新增/退出)
|
||||
echo "$(date) --- 5c/10 WeChat chatroom stats ---"
|
||||
$PY scripts/scrape_via_page.py --entity chatroom-stats
|
||||
YDAY=$(date -d "yesterday" +%F)
|
||||
$PY scripts/scrape_via_page.py --entity chatroom-stats --day "$YDAY"
|
||||
echo "$(date) --- 6/10 Voice calls (WeChat + WA) ---"
|
||||
$PY scripts/scrape_via_page.py --entity voice-calls
|
||||
# 语音重扫会把 record_media_hash 覆盖回 None —— 必须紧跟录音重链接
|
||||
|
||||
@@ -1631,6 +1631,163 @@ async def scrape_wa_groups(cookie_str: str, max_pages: int = 200):
|
||||
return total_fetched, total_upserted
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 微信群统计 getChatroomList(2026-09-09 真机探明,云客同款数据源)
|
||||
#
|
||||
# 云客「沟通→微信号管理→微信群统计」页(/cms/wechat/wechatgroup)数据源:
|
||||
# POST /wechatweb/chatroom/getChatroomList(form-urlencoded)
|
||||
# departmentId + startTime/endTime('YYYY-MM-DD HH:MM:SS' 时间窗) + 分页;
|
||||
# 信封 data.overviews[] + totalCount(1,854 行,员工×群维度)。
|
||||
# 行含:真 memberCount(群成员数量——friend_list/es 的 friendNum 其实是
|
||||
# 员工好友数,旧映射存反了)、remark(群备注)、员工微信(wechatId=微信号
|
||||
# alias/wechatNickName/头像)、员工信息(userName/userPhone)、以及**窗内**
|
||||
# addMembers/lossMembers(新增/退出成员数,任意窗实时算)。
|
||||
#
|
||||
# 本地无成员进出事件流 → add/loss 只能按天积累:每夜抓「当日窗」落
|
||||
# chat_group_member_stats 日行,页面任意日窗=按天求和(历史无数据)。
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CHATROOM_LIST_PATH = "/wechatweb/chatroom/getChatroomList"
|
||||
|
||||
|
||||
def _normalize_chatroom_stat_row(row: dict, owner_wxid: str) -> dict:
|
||||
"""getChatroomList 行 → ChatGroup 补列记录。
|
||||
|
||||
只放该端点有的字段——未出现的列不参与 upsert 的 UPDATE 子句,
|
||||
friend_list/es 抓的群主信息等旧值得以保留。
|
||||
"""
|
||||
rec = {
|
||||
"room_id": row.get("chatRoomId"),
|
||||
"channel": "wechat",
|
||||
"owner_account_id": owner_wxid,
|
||||
"room_name": row.get("chatRoomName"),
|
||||
# 真群成员数(修正:旧源 friendNum 是「员工好友数」被误存此列)
|
||||
"member_count": row.get("memberCount"),
|
||||
"head_image_url": row.get("headImageUrl"),
|
||||
"owner_account_nickname": row.get("wechatNickName"),
|
||||
"owner_user_id": row.get("userId"),
|
||||
"owner_user_phone": row.get("userPhone"),
|
||||
"message_count": row.get("messageCount"),
|
||||
"own_message_count": row.get("ownMessageCount"),
|
||||
"add_members": row.get("addMembers"), # 本窗快照(日窗=当日进出)
|
||||
"loss_members": row.get("lossMembers"),
|
||||
"last_chat_time": row.get("lastChatTime"),
|
||||
"update_time": row.get("updateTime"),
|
||||
"raw_json": row,
|
||||
}
|
||||
# 群备注:getChatroomList 行通常无 remark 键(云客页面也显示 -);有才写,
|
||||
# 无则不放该键——upsert 不更新未出现的列,friend_list/es 来源的
|
||||
# friendWxRemark 备注得以保留
|
||||
if row.get("remark"):
|
||||
rec["group_remark"] = row["remark"]
|
||||
return rec
|
||||
|
||||
|
||||
async def _chatroom_rows(page, dept: str, start_str: str, end_str: str, max_pages: int = 100):
|
||||
"""翻完 getChatroomList 一个窗,返回全部行(list)。"""
|
||||
rows_all: list[dict] = []
|
||||
page_num = 1
|
||||
while page_num <= max_pages:
|
||||
body = await _browser_fetch(
|
||||
page, CHATROOM_LIST_PATH,
|
||||
form_body={
|
||||
"departmentId": dept, "startTime": start_str, "endTime": end_str,
|
||||
"pageNum": page_num, "pageSize": 50,
|
||||
},
|
||||
)
|
||||
data = body.get("data") or {}
|
||||
rows = data.get("overviews") or []
|
||||
rows_all.extend(rows)
|
||||
total = data.get("totalCount") or 0
|
||||
logger.info(f" chatroom-list page {page_num}: {len(rows)} rows (total {total})")
|
||||
if not rows or page_num * 50 >= (total or 0):
|
||||
break
|
||||
page_num += 1
|
||||
await asyncio.sleep(1.2)
|
||||
return rows_all
|
||||
|
||||
|
||||
async def _owner_alias_map(conn):
|
||||
"""wechat_account 的 alias/nick_name → wx_id 映射。
|
||||
|
||||
getChatroomList 行的 wechatId 是员工**微信号**(alias,如 Nancyjade)
|
||||
而非 wxId,owner 归属需经此解析;miss 时回退存原值(不影响其余列)。
|
||||
"""
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
result = await conn.execute(sa_text(
|
||||
"SELECT alias, nick_name, wx_id FROM wechat_account "
|
||||
"WHERE wx_id IS NOT NULL AND wx_id != ''"
|
||||
))
|
||||
mapping: dict[str, str] = {}
|
||||
for alias, nick, wx in result.fetchall():
|
||||
if alias:
|
||||
mapping[str(alias)] = wx
|
||||
if nick:
|
||||
mapping.setdefault(str(nick), wx)
|
||||
return mapping
|
||||
|
||||
|
||||
async def scrape_chatroom_stats(cookie_str: str, day: str | None = None):
|
||||
"""getChatroomList 抓取(云客微信群统计同款)。
|
||||
|
||||
* ``day=None``:全窗(2020 起)——chat_group 档案补列基线(真成员数/
|
||||
群备注/员工信息列;add/loss 存全窗快照);
|
||||
* ``day='YYYY-MM-DD'``:单日窗——落 chat_group_member_stats 当日行
|
||||
(页面任意日窗的新增/退出=按天求和)。
|
||||
"""
|
||||
import datetime as _dt
|
||||
|
||||
from app.db.base import async_engine
|
||||
from app.storage.repos import upsert_chat_group, upsert_chat_group_member_stats
|
||||
|
||||
dept = await _root_dept_id()
|
||||
async with async_engine.connect() as conn:
|
||||
alias_map = await _owner_alias_map(conn)
|
||||
|
||||
if day:
|
||||
start_str, end_str = f"{day} 00:00:00", f"{day} 23:59:59"
|
||||
day_key = int(day.replace("-", ""))
|
||||
else:
|
||||
start_str = "2020-01-01 00:00:00"
|
||||
end_str = f"{_dt.date.today()} 23:59:59"
|
||||
day_key = None
|
||||
|
||||
group_recs: list[dict] = []
|
||||
stats_recs: list[dict] = []
|
||||
async with PageScraper(cookie_str) as scraper:
|
||||
page = await _open_fetch_page(scraper)
|
||||
for row in await _chatroom_rows(page, dept, start_str, end_str):
|
||||
rid = row.get("chatRoomId")
|
||||
if not rid:
|
||||
continue
|
||||
owner_wxid = (
|
||||
alias_map.get(str(row.get("wechatId") or ""))
|
||||
or alias_map.get(str(row.get("wechatNickName") or ""))
|
||||
or str(row.get("wechatId") or "")
|
||||
)
|
||||
group_recs.append(_normalize_chatroom_stat_row(row, owner_wxid))
|
||||
if day_key is not None:
|
||||
stats_recs.append({
|
||||
"channel": "wechat",
|
||||
"owner_account_id": owner_wxid,
|
||||
"room_id": rid,
|
||||
"day": day_key,
|
||||
"added": row.get("addMembers") or 0,
|
||||
"lost": row.get("lossMembers") or 0,
|
||||
})
|
||||
|
||||
if group_recs:
|
||||
await _upsert_all(upsert_chat_group, group_recs)
|
||||
if stats_recs:
|
||||
await _upsert_all(upsert_chat_group_member_stats, stats_recs)
|
||||
logger.info(
|
||||
f"DONE: chatroom-stats day={day or '全窗'} groups={len(group_recs)} "
|
||||
f"stats_rows={len(stats_recs)}"
|
||||
)
|
||||
return len(group_recs), len(stats_recs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 员工朋友圈 Moments (browser fetch, per employee)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2301,6 +2458,7 @@ async def main():
|
||||
"wa-accounts", # WA员工账号 (waBusiness/userlist interception)
|
||||
"wa-friends", # WA客户 (waBusiness/friendlist interception)
|
||||
"wa-groups", # WA群 (browser fetch businessFriendList type=1)
|
||||
"chatroom-stats", # 微信群统计 (getChatroomList 带窗,云客同款)
|
||||
"voice-calls", # 语音通话记录 (voice-call-list + wavoicecalllist)
|
||||
"voice-clips", # 长语音 (browser fetch call/list per employee)
|
||||
"wa-messages", # WA消息 (browser fetch chatRecords per conversation)
|
||||
@@ -2315,6 +2473,7 @@ 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("--day", type=str, default=None, help="chatroom-stats:单日窗 YYYY-MM-DD(落按天进出表);缺省=全窗档案基线")
|
||||
args = parser.parse_args()
|
||||
|
||||
cookie = _load_cookie()
|
||||
@@ -2348,6 +2507,8 @@ async def _dispatch_entity(args, cookie: str) -> None:
|
||||
await scrape_wa_friends(cookie, args.max_pages)
|
||||
elif args.entity == "wa-groups":
|
||||
await scrape_wa_groups(cookie, args.max_pages)
|
||||
elif args.entity == "chatroom-stats":
|
||||
await scrape_chatroom_stats(cookie, args.day)
|
||||
elif args.entity == "voice-calls":
|
||||
await scrape_voice_calls(cookie, args.max_pages)
|
||||
elif args.entity == "voice-clips":
|
||||
|
||||
@@ -344,3 +344,79 @@ async def test_q_room_name_substring_filter(make_client):
|
||||
# 空串 -> 不过滤
|
||||
r3 = await client.get("/api/groups", params={"q": ""})
|
||||
assert r3.json()["total"] == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 微信群统计扩展(2026-09-09):统计时段窗聚合 + 员工列 + 群备注/最新消息筛选
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _seed_stat(session: AsyncSession) -> None:
|
||||
from app.db.models import ChatGroupMemberStats, MsgDailyStats, Staff, WechatAccount
|
||||
|
||||
session.add_all([
|
||||
WechatAccount(wx_id="wx_owner_1", nick_name="玉", alias="Nancyjade",
|
||||
user_id="u_wx_1", raw_json={"headImageUrl": "http://a/h.png"}),
|
||||
Staff(user_id="u_wx_1", name="聂玉婷", phone="18500000000"),
|
||||
ChatGroup(room_id="stat_room@chatroom", channel="wechat", room_name="统计群",
|
||||
owner_account_id="wx_owner_1", owner_user_id="u_wx_1",
|
||||
member_count=46, group_remark="VIP群", last_chat_time=5000),
|
||||
# 窗内两天日行(0907+0908):沟通 3+4=7 条
|
||||
MsgDailyStats(channel="wechat", owner_account_id="wx_owner_1", peer_kind="room",
|
||||
peer_id="stat_room@chatroom", day=20260907, send_cnt=1, recv_cnt=2),
|
||||
MsgDailyStats(channel="wechat", owner_account_id="wx_owner_1", peer_kind="room",
|
||||
peer_id="stat_room@chatroom", day=20260908, send_cnt=2, recv_cnt=2),
|
||||
# 窗外日行(0901)不应计入
|
||||
MsgDailyStats(channel="wechat", owner_account_id="wx_owner_1", peer_kind="room",
|
||||
peer_id="stat_room@chatroom", day=20260901, send_cnt=99, recv_cnt=99),
|
||||
# 窗内进出:0907 +2/-1
|
||||
ChatGroupMemberStats(channel="wechat", owner_account_id="wx_owner_1",
|
||||
room_id="stat_room@chatroom", day=20260907, added=2, lost=1),
|
||||
])
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_group_window_aggregates_and_staff_columns(make_client):
|
||||
"""msg_start/msg_end 窗内三聚合 + 员工微信/员工信息关联列。"""
|
||||
client = await make_client(_seed_stat)
|
||||
# 2026-09-07 ~ 2026-09-09(上海日 20260907..20260909,UTC ms)
|
||||
r = await client.get("/api/groups", params={
|
||||
"channel": "wechat", "room_id": "stat_room@chatroom",
|
||||
"msg_start": 1788758400000, "msg_end": 1789017600000,
|
||||
})
|
||||
assert r.status_code == 200
|
||||
it = r.json()["items"][0]
|
||||
assert it["window_msg_count"] == 7 # 窗内日行求和,0901 排除
|
||||
assert it["window_added"] == 2
|
||||
assert it["window_lost"] == 1
|
||||
assert it["group_remark"] == "VIP群"
|
||||
assert it["staff_nickname"] == "玉"
|
||||
assert it["staff_wx_alias"] == "Nancyjade"
|
||||
assert it["staff_avatar"] == "http://a/h.png"
|
||||
assert it["staff_name"] == "聂玉婷"
|
||||
assert it["staff_phone"] == "18500000000"
|
||||
|
||||
# 无窗请求 → 聚合三列为 None(不误显 0)
|
||||
r2 = await client.get("/api/groups", params={
|
||||
"channel": "wechat", "room_id": "stat_room@chatroom"})
|
||||
it2 = r2.json()["items"][0]
|
||||
assert it2["window_msg_count"] is None
|
||||
assert it2["window_added"] is None
|
||||
|
||||
|
||||
async def test_group_remark_and_last_window_filters(make_client):
|
||||
"""群备注子串 + 最新消息时间段过滤。"""
|
||||
client = await make_client(_seed_stat)
|
||||
r = await client.get("/api/groups", params={
|
||||
"channel": "wechat", "group_remark": "VIP"})
|
||||
assert [i["room_id"] for i in r.json()["items"]] == ["stat_room@chatroom"]
|
||||
r2 = await client.get("/api/groups", params={
|
||||
"channel": "wechat", "group_remark": "不存在"})
|
||||
assert r2.json()["total"] == 0
|
||||
|
||||
# last_chat_time=5000 → last_start=4000 命中,last_start=6000 排除
|
||||
r3 = await client.get("/api/groups", params={
|
||||
"channel": "wechat", "last_start": 4000, "last_end": 9999})
|
||||
assert "stat_room@chatroom" in [i["room_id"] for i in r3.json()["items"]]
|
||||
r4 = await client.get("/api/groups", params={
|
||||
"channel": "wechat", "last_start": 6000, "last_end": 9999})
|
||||
assert r4.json()["total"] == 0
|
||||
|
||||
@@ -123,6 +123,7 @@ async def test_metric_daily_friend_only_caliber(make_session):
|
||||
assert row.msg_sent == 3 and row.msg_recv == 2 # room 的 10 不进
|
||||
assert row.active_rooms == 1
|
||||
assert row.cust_threads == 1 and row.threads_answered == 0 # 未回复串
|
||||
assert row.first_reply_p50_ms is None and row.first_reply_p90_ms is None # 空样本→None(≠0)
|
||||
|
||||
|
||||
async def test_metric_daily_ghost_filtered(make_session):
|
||||
@@ -173,6 +174,35 @@ async def test_metric_daily_same_source_as_fact(make_session):
|
||||
sa.select(EmployeeMetricDaily))).scalars().all()) == 2
|
||||
|
||||
|
||||
async def test_metric_daily_same_source_as_fact_answered(make_session):
|
||||
"""同源判别正向路径(v2 §1.1 #5):被回串——threads_answered ≡ fact 行数,
|
||||
单样本 p50=p90=fr_ms(锁分位非 None 映射)。"""
|
||||
from app.storage.first_reply_facts import recompute_pairs
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
# 客户 10:00 提问,员工 10:25 回复 → fr=25min,串末条归属 09-01
|
||||
fr_ms = 25 * 60 * 1000
|
||||
s.add(Message(msg_id="an0", channel="wechat", owner_account_id="wx1",
|
||||
talker_id="pa", timestamp=_ts(2026, 9, 1, 10, 0),
|
||||
is_send=False, type="text", text="q"))
|
||||
s.add(Message(msg_id="an1", channel="wechat", owner_account_id="wx1",
|
||||
talker_id="pa", timestamp=_ts(2026, 9, 1, 10, 25),
|
||||
is_send=True, type="text", text="a"))
|
||||
await _seed_msg_rows(s, day=20260901, peer="pa", send=1, recv=1)
|
||||
await s.commit()
|
||||
await recompute_pairs(s, [("wechat", "wx1", "pa")])
|
||||
await s.commit()
|
||||
await recompute_metric_days(s, [20260901])
|
||||
await s.commit()
|
||||
n_facts = (await s.execute(
|
||||
sa.select(sa.func.count()).select_from(FirstReplyFact))).scalar_one()
|
||||
row = (await s.execute(sa.select(EmployeeMetricDaily))).scalar_one()
|
||||
assert row.cust_threads == 1
|
||||
assert row.threads_answered == 1 == n_facts
|
||||
assert row.fr_sum_ms == fr_ms
|
||||
assert row.first_reply_p50_ms == fr_ms == row.first_reply_p90_ms
|
||||
|
||||
|
||||
async def test_metric_daily_lifecycle_counts(make_session):
|
||||
"""生命周期列:cooling/dormant/reactivated 判定进宽表。"""
|
||||
s = make_session
|
||||
@@ -204,3 +234,36 @@ async def test_metric_daily_lifecycle_counts(make_session):
|
||||
await s.execute(sa.select(EmployeeMetricDaily))).scalars().all()}
|
||||
assert got[20261010].dormant_customer_count == 1 # 39 天静默+历史 8 条
|
||||
assert got[20261011].reactivated_customer_count == 1 # 当日回流且 gap>30
|
||||
|
||||
|
||||
async def test_metric_daily_lifecycle_hist_point_in_time(make_session):
|
||||
"""hist 时点语义判别:回填历史日 M,沉睡门槛只计 ≤M 的消息。
|
||||
|
||||
客户 pd:X=09-01 共 2 条 + Y=10-02 共 3 条(全历史 5 条):
|
||||
- M=10-01(X+30,<Y):gap≥30 达沉睡判定,但 hist_asof=2 < 5 → None
|
||||
(全历史口径 hist=5 会把 M 误判 dormant——M 之后的消息虚增);
|
||||
- D=11-01(Y+30):hist_asof=5 ≥ 5 → dormant。"""
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
s.add(Customer(friend_id="pd", channel="wechat", owner_account_id="wx1",
|
||||
delete_flag=0))
|
||||
for i, (day, h, is_send) in enumerate([
|
||||
(20260901, 10, False), (20260901, 11, True), # X 2 条
|
||||
(20261002, 10, False), (20261002, 11, True), (20261002, 12, False), # Y 3 条
|
||||
]):
|
||||
s.add(Message(msg_id=f"pd{i}", channel="wechat", owner_account_id="wx1",
|
||||
talker_id="pd",
|
||||
timestamp=_ts(day // 10000, day // 100 % 100, day % 100, h, 0),
|
||||
is_send=is_send, type="text", text="x"))
|
||||
await _seed_msg_rows(s, day=20260901, peer="pd", send=1, recv=1)
|
||||
await _seed_msg_rows(s, day=20261002, peer="pd", send=1, recv=2)
|
||||
# M 是无消息日:state=None 时纯生命周期源不出行,补一条 room 行撑出
|
||||
# 宽表行,让 dormant==0 有断言落点
|
||||
await _seed_msg_rows(s, day=20261001, kind="room", peer="r1", send=1, recv=0)
|
||||
await s.commit()
|
||||
await recompute_metric_days(s, [20261001, 20261101])
|
||||
await s.commit()
|
||||
got = {r.day: r for r in (
|
||||
await s.execute(sa.select(EmployeeMetricDaily))).scalars().all()}
|
||||
assert got[20261001].dormant_customer_count == 0 # hist_asof=2<5 → None
|
||||
assert got[20261101].dormant_customer_count == 1 # hist_asof=5 ≥ 5
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
"""管理域确定性异常规则判别测试(批次B Task B3,v1 §5.4 异常卡片)。"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.management.anomalies import detect_anomalies
|
||||
from app.management.employee_metrics import PeriodMetrics
|
||||
from app.management.metric_definitions import DEFAULT_SCORE_CONFIG
|
||||
|
||||
|
||||
def _pm(**overrides):
|
||||
"""全零/None 默认的 PeriodMetrics 构造器(22 字段,覆盖式改)。
|
||||
|
||||
与 test_management_scoring._pm 同式(测试间不共享 helper,复制保持独立)。
|
||||
"""
|
||||
base = {
|
||||
"user_id": "u1", "days_with_data": 0, "msg_sent": 0, "msg_recv": 0,
|
||||
"cust_threads": 0, "threads_answered": 0, "fr_sum_ms": 0,
|
||||
"fr_p50_ms": None, "fr_p90_ms": None,
|
||||
"reply_2h_count": 0, "reply_24h_count": 0,
|
||||
"active_friends": 0, "active_rooms_avg": 0.0,
|
||||
"new_customer": 0, "cooling_customer": 0, "dormant_customer": 0,
|
||||
"end_active_friends": 0, "reactivated_customer": 0,
|
||||
"call_count": 0, "call_seconds": 0, "answered_call": 0,
|
||||
"call_followup": 0,
|
||||
}
|
||||
base.update(overrides)
|
||||
return PeriodMetrics(**base)
|
||||
|
||||
|
||||
def _detect(cur, base, *, n_days=7, user_id="u1", config=None):
|
||||
return detect_anomalies(
|
||||
user_id, cur, base, n_days=n_days,
|
||||
config=config if config is not None else DEFAULT_SCORE_CONFIG)
|
||||
|
||||
|
||||
# ---- 1. reply_p90_worsened ----
|
||||
|
||||
def test_p90_worsened_warning_with_pct():
|
||||
"""×1.6 → warning;标题百分比=(160-100)/100=60%;affected=未回串数。"""
|
||||
cur = _pm(cust_threads=20, threads_answered=12, fr_p90_ms=9_600_000) # 160min
|
||||
base = _pm(cust_threads=40, threads_answered=40, fr_p90_ms=6_000_000) # 100min
|
||||
cards = _detect(cur, base)
|
||||
assert [c["rule_code"] for c in cards] == ["reply_p90_worsened"]
|
||||
c = cards[0]
|
||||
assert c["severity"] == "warning"
|
||||
assert c["title"] == "回复 P90 较个人基线上升 60%"
|
||||
assert c["current_value"] == 9_600_000
|
||||
assert c["baseline_value"] == 6_000_000
|
||||
assert c["unit"] == "ms"
|
||||
assert c["sample_size"] == 20
|
||||
assert c["affected_count"] == 8 # 20 串 - 12 已回 = 期内未回串数
|
||||
assert c["drilldown"] == {"type": "metric",
|
||||
"query": {"metric_code": "first_reply_p90",
|
||||
"user_id": "u1"}}
|
||||
|
||||
|
||||
def test_p90_worsened_critical_at_3x():
|
||||
cur = _pm(cust_threads=20, threads_answered=20, fr_p90_ms=18_000_000)
|
||||
base = _pm(cust_threads=40, threads_answered=40, fr_p90_ms=6_000_000)
|
||||
cards = _detect(cur, base)
|
||||
assert len(cards) == 1
|
||||
assert cards[0]["severity"] == "critical"
|
||||
assert "上升 200%" in cards[0]["title"]
|
||||
|
||||
|
||||
def test_p90_boundaries_strict_compare():
|
||||
"""×1.5 恰好 → 严格大于不成立不触发;×2.5 恰好 → warning 非 critical;
|
||||
threads 恰好 = soft(10) → ≥soft 触发。"""
|
||||
base = _pm(cust_threads=40, fr_p90_ms=6_000_000)
|
||||
assert _detect(_pm(cust_threads=20, fr_p90_ms=9_000_000), base) == []
|
||||
cards = _detect(_pm(cust_threads=20, fr_p90_ms=15_000_000), base)
|
||||
assert [c["severity"] for c in cards] == ["warning"]
|
||||
cards = _detect(_pm(cust_threads=10, fr_p90_ms=9_600_000), base)
|
||||
assert [c["rule_code"] for c in cards] == ["reply_p90_worsened"]
|
||||
|
||||
|
||||
def test_p90_below_soft_threads_not_fired():
|
||||
cur = _pm(cust_threads=5, fr_p90_ms=9_600_000) # <soft 10
|
||||
base = _pm(cust_threads=40, threads_answered=40, fr_p90_ms=6_000_000)
|
||||
assert _detect(cur, base) == []
|
||||
|
||||
|
||||
# ---- 2. 基线缺失 ----
|
||||
|
||||
def test_baseline_none_all_rules_silent():
|
||||
"""新员工无个人基线:即便 p90 恶化/活动骤降/过载形态俱在也不出卡。"""
|
||||
cur = _pm(cust_threads=20, threads_answered=5, fr_p90_ms=30_000_000,
|
||||
active_friends=85, msg_sent=10, msg_recv=10)
|
||||
assert _detect(cur, None) == []
|
||||
|
||||
|
||||
# ---- 3. reply_rate_dropped ----
|
||||
|
||||
def test_reply_rate_dropped_warning():
|
||||
"""0.8→0.5(0.5 < 0.7×0.8=0.56)→ warning;百分比 37.5%→38%。"""
|
||||
cur = _pm(cust_threads=20, reply_2h_count=10, threads_answered=15) # 0.5
|
||||
base = _pm(cust_threads=40, reply_2h_count=32, threads_answered=40) # 0.8
|
||||
cards = _detect(cur, base)
|
||||
assert [c["rule_code"] for c in cards] == ["reply_rate_dropped"]
|
||||
c = cards[0]
|
||||
assert c["severity"] == "warning"
|
||||
assert c["title"] == "2h 回复率较个人基线下降 38%"
|
||||
assert c["current_value"] == pytest.approx(0.5)
|
||||
assert c["baseline_value"] == pytest.approx(0.8)
|
||||
assert c["unit"] == "0-1"
|
||||
assert c["sample_size"] == 20
|
||||
assert c["affected_count"] == 5
|
||||
assert c["drilldown"] == {"type": "metric",
|
||||
"query": {"metric_code": "unreplied",
|
||||
"user_id": "u1"}}
|
||||
|
||||
|
||||
def test_reply_rate_small_drop_not_fired():
|
||||
"""0.8→0.6(0.6 ≥ 0.56)→ 不触发。"""
|
||||
cur = _pm(cust_threads=20, reply_2h_count=12) # 0.6
|
||||
base = _pm(cust_threads=40, reply_2h_count=32) # 0.8
|
||||
assert _detect(cur, base) == []
|
||||
|
||||
|
||||
def test_rate_baseline_no_threads_not_fired():
|
||||
"""基线无串样本 → 基线率 None → 不判(基线值 None 不触发)。"""
|
||||
cur = _pm(cust_threads=20, reply_2h_count=0)
|
||||
base = _pm(cust_threads=0) # derive → reply_2h_rate None
|
||||
assert _detect(cur, base) == []
|
||||
|
||||
|
||||
# ---- 4. activity_drop ----
|
||||
|
||||
def test_activity_drop_triggered():
|
||||
"""当前 7 天日均 10 < 基线 28 天日均 30×0.5=15 → warning。"""
|
||||
cur = _pm(msg_sent=40, msg_recv=30, active_friends=25) # 70/7=10 条/天
|
||||
base = _pm(msg_sent=600, msg_recv=240, days_with_data=20) # 840/28=30 条/天
|
||||
cards = _detect(cur, base)
|
||||
assert [c["rule_code"] for c in cards] == ["activity_drop"]
|
||||
c = cards[0]
|
||||
assert c["severity"] == "warning"
|
||||
assert c["title"] == "日均消息量较个人基线下降 67%" # 66.7→67
|
||||
assert c["current_value"] == pytest.approx(10.0)
|
||||
assert c["baseline_value"] == pytest.approx(30.0)
|
||||
assert c["unit"] == "条"
|
||||
assert c["sample_size"] == 25
|
||||
assert c["affected_count"] == 25
|
||||
assert c["drilldown"] == {"type": "employee", "query": {"user_id": "u1"}}
|
||||
|
||||
|
||||
def test_activity_drop_sparse_baseline_not_fired():
|
||||
"""基线 days_with_data=3 < min_days(5) → 基线不可比,不触发。"""
|
||||
base = _pm(msg_sent=600, msg_recv=240, days_with_data=3)
|
||||
cur = _pm(msg_sent=40, msg_recv=30)
|
||||
assert _detect(cur, base) == []
|
||||
|
||||
|
||||
# ---- 5. workload_overload ----
|
||||
|
||||
def test_workload_overload_triggered():
|
||||
cards = _detect(_pm(active_friends=85), _pm())
|
||||
assert [c["rule_code"] for c in cards] == ["workload_overload"]
|
||||
c = cards[0]
|
||||
assert c["severity"] == "warning"
|
||||
assert c["title"] == "活跃客户 85 人超过负载上限 80 人"
|
||||
assert c["current_value"] == 85
|
||||
assert c["baseline_value"] is None # 规则与个人基线无关,阈值在 band 配置
|
||||
assert c["unit"] == "人"
|
||||
assert c["sample_size"] == 85
|
||||
assert c["affected_count"] == 85
|
||||
assert c["drilldown"] == {"type": "employee", "query": {"user_id": "u1"}}
|
||||
|
||||
|
||||
def test_workload_overload_at_limit_not_fired():
|
||||
assert _detect(_pm(active_friends=80), _pm()) == [] # 严格大于上限
|
||||
|
||||
|
||||
# ---- 6. 配置驱动与组合 ----
|
||||
|
||||
def test_thresholds_read_from_config():
|
||||
"""soft_threads/band high 改配置即改判——阈值不硬编码。"""
|
||||
cfg = json.loads(json.dumps(DEFAULT_SCORE_CONFIG))
|
||||
cfg["sample_guards"]["soft_threads"] = 25
|
||||
cfg["dimensions"]["workload"]["metrics"]["active_friends_band"]["high"] = 90
|
||||
cur = _pm(cust_threads=20, fr_p90_ms=9_600_000, active_friends=85)
|
||||
base = _pm(cust_threads=40, fr_p90_ms=6_000_000)
|
||||
# threads 20 < soft25 → p90/rate 不评;85 ≤ 90 → 不 overload
|
||||
assert _detect(cur, base, config=cfg) == []
|
||||
|
||||
|
||||
def test_multiple_rules_stack_in_order():
|
||||
"""p90 ×5 critical + activity 骤降 + overload 90>80 同时触发,按规则表序输出;
|
||||
两侧回复率同为 0(0<0 不成立)→ rate 规则不触发。"""
|
||||
cur = _pm(cust_threads=30, threads_answered=10, fr_p90_ms=30_000_000,
|
||||
active_friends=90, msg_sent=10, msg_recv=10)
|
||||
base = _pm(cust_threads=60, threads_answered=60, fr_p90_ms=6_000_000,
|
||||
msg_sent=600, msg_recv=240, days_with_data=20)
|
||||
cards = _detect(cur, base)
|
||||
assert [c["rule_code"] for c in cards] == [
|
||||
"reply_p90_worsened", "activity_drop", "workload_overload"]
|
||||
assert cards[0]["severity"] == "critical"
|
||||
|
||||
|
||||
def test_card_structure_all_fields():
|
||||
"""单卡 12 字段齐全且类型正确(v1 §5.4)。"""
|
||||
cur = _pm(cust_threads=20, reply_2h_count=20, threads_answered=12,
|
||||
fr_p90_ms=9_600_000, active_friends=30,
|
||||
msg_sent=80, msg_recv=30) # 日均 110/7≈15.7 ≥ 15 → activity 不触发
|
||||
base = _pm(cust_threads=40, reply_2h_count=40, threads_answered=40,
|
||||
fr_p90_ms=6_000_000, active_friends=30,
|
||||
msg_sent=600, msg_recv=240, days_with_data=20)
|
||||
cards = _detect(cur, base, user_id="u9")
|
||||
assert len(cards) == 1
|
||||
c = cards[0]
|
||||
assert set(c) == {
|
||||
"rule_code", "severity", "employee_user_id", "title",
|
||||
"current_value", "baseline_value", "unit", "sample_size",
|
||||
"affected_count", "reason", "drilldown", "suggested_action",
|
||||
}
|
||||
assert isinstance(c["rule_code"], str)
|
||||
assert c["severity"] in ("warning", "critical")
|
||||
assert c["employee_user_id"] == "u9"
|
||||
assert isinstance(c["title"], str) and c["title"]
|
||||
assert isinstance(c["current_value"], (int, float))
|
||||
assert c["baseline_value"] is None or isinstance(
|
||||
c["baseline_value"], (int, float))
|
||||
assert isinstance(c["unit"], str)
|
||||
assert isinstance(c["sample_size"], int)
|
||||
assert isinstance(c["affected_count"], int)
|
||||
assert isinstance(c["reason"], str) and c["reason"]
|
||||
assert set(c["drilldown"]) == {"type", "query"}
|
||||
assert isinstance(c["drilldown"]["query"], dict)
|
||||
assert isinstance(c["suggested_action"], str) and c["suggested_action"]
|
||||
@@ -0,0 +1,300 @@
|
||||
"""管理域期聚合查询判别测试(批次B Task B1)。
|
||||
|
||||
判据(v2 §3 / B1 规格):
|
||||
- 分位=fact 表最近邻秩 ceil(q·n) 精确值(整除算术,无 ceil 方言坑),
|
||||
多账号/跨渠道样本按员工合并;
|
||||
- 宽表 SUM 按 (day∈窗, metric_version='v1', channel) 过滤;
|
||||
active_rooms_avg 摊到全窗天数(含无数据日);
|
||||
- 期末生命周期存量=每 (user, channel) 最新 ≤end 行,跨渠道相加;
|
||||
- distinct 活跃客户=窗内 (uid, peer) 去重(peer_kind='friend')——
|
||||
同一员工多账号的共享客户只计 1(与宽表日值口径一致);
|
||||
- 窗口工具:闭区间 day_range/等长前窗/28 天个人基线窗;
|
||||
- 幽灵员工(不在 staff)过滤;user_ids 交集;通话恒全渠道。
|
||||
"""
|
||||
import datetime as dt
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.db.models import (
|
||||
EmployeeCallDaily,
|
||||
EmployeeMetricDaily,
|
||||
FirstReplyFact,
|
||||
MsgDailyStats,
|
||||
Staff,
|
||||
WechatAccount,
|
||||
WhatsappAccount,
|
||||
)
|
||||
from app.management.employee_metrics import (
|
||||
PeriodMetrics,
|
||||
baseline_window,
|
||||
day_range,
|
||||
period_metrics,
|
||||
prev_window,
|
||||
team_medians,
|
||||
)
|
||||
|
||||
_SH = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def _ts(*args) -> int:
|
||||
return int(dt.datetime(*args, tzinfo=_SH).timestamp() * 1000)
|
||||
|
||||
|
||||
async def _seed_org(s, users=("u1", "u2")):
|
||||
for u in users:
|
||||
s.add(Staff(user_id=u, name=u, is_on_job=True))
|
||||
s.add(WechatAccount(wx_id="wx1", user_id="u1"))
|
||||
s.add(WechatAccount(wx_id="wx2", user_id="u2"))
|
||||
await s.commit()
|
||||
|
||||
|
||||
def _mday(day, *, channel="wechat", user_id="u1", metric_version="v1", **kw):
|
||||
cols = {
|
||||
"msg_sent": 0, "msg_recv": 0, "active_friends": 0, "active_rooms": 0,
|
||||
"cust_threads": 0, "threads_answered": 0, "fr_sum_ms": 0,
|
||||
"new_customer_count": 0, "cooling_customer_count": 0,
|
||||
"dormant_customer_count": 0, "reactivated_customer_count": 0,
|
||||
"data_complete": True,
|
||||
}
|
||||
cols.update(kw)
|
||||
return EmployeeMetricDaily(
|
||||
day=day, channel=channel, user_id=user_id,
|
||||
metric_version=metric_version, **cols
|
||||
)
|
||||
|
||||
|
||||
def _fact(fr_ms, *, channel="wechat", owner="wx1", peer="p1", day=20260901, i=0):
|
||||
end = _ts(2026, 9, 1, 10, 0) + i * 60_000
|
||||
return FirstReplyFact(
|
||||
channel=channel, owner_account_id=owner, peer_id=peer,
|
||||
thread_end_ts=end, reply_ts=end + fr_ms,
|
||||
reply_msg_id=f"{owner}-{peer}-m{i}", day=day, fr_ms=fr_ms,
|
||||
)
|
||||
|
||||
|
||||
async def test_percentile_exact(make_session):
|
||||
"""5 样本 p50=180s/p90=300s(ceil(2.5)=3、ceil(4.5)=5,1-based);
|
||||
2 样本 p50=10s/p90=20s;窗外行不参与。"""
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
for i, sec in enumerate([60, 120, 180, 240, 300]):
|
||||
s.add(_fact(sec * 1000, i=i))
|
||||
for i, sec in enumerate([10, 20]):
|
||||
s.add(_fact(sec * 1000, owner="wx2", peer="q1", i=i))
|
||||
# 窗外(0906>end)长尾:混入则 p90=999s
|
||||
s.add(_fact(999_000, i=9, day=20260906))
|
||||
await s.commit()
|
||||
m = await period_metrics(s, 20260901, 20260905, "wechat")
|
||||
assert m["u1"].fr_p50_ms == 180_000
|
||||
assert m["u1"].fr_p90_ms == 300_000
|
||||
assert m["u2"].fr_p50_ms == 10_000
|
||||
assert m["u2"].fr_p90_ms == 20_000
|
||||
|
||||
|
||||
async def test_period_sums_vs_daily_rows(make_session):
|
||||
"""宽表两天行 SUM 正确;days_with_data=2;active_rooms_avg 摊到全窗
|
||||
5 天(含无数据日);channel/metric_version/窗外行都不计。"""
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
s.add(_mday(20260901, msg_sent=3, msg_recv=5, cust_threads=2,
|
||||
threads_answered=1, fr_sum_ms=7_000, active_rooms=4,
|
||||
reactivated_customer_count=1))
|
||||
s.add(_mday(20260903, msg_sent=4, msg_recv=6, cust_threads=3,
|
||||
threads_answered=2, fr_sum_ms=11_000, active_rooms=6,
|
||||
reactivated_customer_count=2))
|
||||
# 反例三连:错渠道 / 错版本 / 窗外
|
||||
s.add(_mday(20260901, channel="whatsapp", msg_sent=100))
|
||||
s.add(_mday(20260901, metric_version="v2", msg_sent=200))
|
||||
s.add(_mday(20260906, msg_sent=400))
|
||||
await s.commit()
|
||||
m = await period_metrics(s, 20260901, 20260905, "wechat")
|
||||
u1 = m["u1"]
|
||||
assert u1.msg_sent == 7
|
||||
assert u1.msg_recv == 11
|
||||
assert u1.cust_threads == 5
|
||||
assert u1.threads_answered == 3
|
||||
assert u1.fr_sum_ms == 18_000
|
||||
assert u1.days_with_data == 2
|
||||
assert u1.active_rooms_avg == 2.0 # (4+6)/5 全窗天数,非 2 有数据日
|
||||
assert u1.reactivated_customer == 3
|
||||
|
||||
|
||||
async def test_end_of_period_stock(make_session):
|
||||
"""期末存量=每渠道最新 ≤end 行:窗 [0901,0903] 取 0901 行、
|
||||
[0901,0905] 取 0905 行;channel=None 跨渠道相加(4+2=6)。
|
||||
end_active_friends 同源同行:wechat 7 + whatsapp 3 → 10(评分
|
||||
active_share 分子,与周期 distinct 的 active_friends 字段不同口径)。"""
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
s.add(_mday(20260901, new_customer_count=1, cooling_customer_count=2,
|
||||
dormant_customer_count=3, active_friends=7))
|
||||
s.add(_mday(20260905, dormant_customer_count=4, active_friends=7))
|
||||
s.add(_mday(20260905, channel="whatsapp", dormant_customer_count=2,
|
||||
active_friends=3))
|
||||
await s.commit()
|
||||
|
||||
m1 = await period_metrics(s, 20260901, 20260903, "wechat")
|
||||
assert m1["u1"].new_customer == 1
|
||||
assert m1["u1"].cooling_customer == 2
|
||||
assert m1["u1"].dormant_customer == 3
|
||||
assert m1["u1"].end_active_friends == 7 # 渠道内最新行(0901)的值
|
||||
|
||||
m2 = await period_metrics(s, 20260901, 20260905, "wechat")
|
||||
assert m2["u1"].dormant_customer == 4 # wechat 渠道内最新行
|
||||
assert m2["u1"].new_customer == 0
|
||||
assert m2["u1"].end_active_friends == 7
|
||||
|
||||
m3 = await period_metrics(s, 20260901, 20260905, None)
|
||||
assert m3["u1"].dormant_customer == 6 # wechat 4 + whatsapp 2
|
||||
assert m3["u1"].end_active_friends == 10 # wechat 7 + whatsapp 3
|
||||
|
||||
|
||||
async def test_distinct_active_cross_day(make_session):
|
||||
"""同 peer 两天两行只计 1;room 行与幽灵 owner 不计。"""
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
for day, peer in [(20260901, "p1"), (20260902, "p1"), (20260902, "p2")]:
|
||||
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||
peer_kind="friend", peer_id=peer, day=day))
|
||||
# room 行不计入活跃客户
|
||||
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||
peer_kind="room", peer_id="r1", day=20260902))
|
||||
# 幽灵 owner(account_map 无映射)不计
|
||||
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx9",
|
||||
peer_kind="friend", peer_id="p9", day=20260902))
|
||||
await s.commit()
|
||||
m = await period_metrics(s, 20260901, 20260902, "wechat")
|
||||
assert m["u1"].active_friends == 2
|
||||
|
||||
|
||||
async def test_active_friends_shared_peer_across_accounts(make_session):
|
||||
"""多账号共享客户去重:u1 的 wx1/wx3 都会话 p1 → active_friends=1
|
||||
(账号归并到员工后按 peer 去重,非 (owner, peer) 双计)。"""
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
s.add(WechatAccount(wx_id="wx3", user_id="u1"))
|
||||
await s.commit()
|
||||
for owner in ("wx1", "wx3"):
|
||||
s.add(MsgDailyStats(channel="wechat", owner_account_id=owner,
|
||||
peer_kind="friend", peer_id="p1", day=20260901))
|
||||
await s.commit()
|
||||
m = await period_metrics(s, 20260901, 20260905, "wechat")
|
||||
assert m["u1"].active_friends == 1
|
||||
assert m["u2"].active_friends == 0
|
||||
|
||||
|
||||
def test_window_helpers():
|
||||
# day_range 闭区间 + 月末边界
|
||||
assert day_range(20260901, 20260903) == [20260901, 20260902, 20260903]
|
||||
assert day_range(20260831, 20260901) == [20260831, 20260901]
|
||||
# prev_window 等长紧邻前窗(7 天窗 → 7 天窗;单日 → 单日)
|
||||
assert prev_window(20260908, 20260914) == (20260901, 20260907)
|
||||
assert prev_window(20260901, 20260901) == (20260831, 20260831)
|
||||
# baseline_window:紧邻窗前 28 天([0915] → [0818, 0914])
|
||||
assert baseline_window(20260915) == (20260818, 20260914)
|
||||
|
||||
|
||||
async def test_ghost_and_channel_none(make_session):
|
||||
"""幽灵(不在 staff)宽表行/fact 行不计;channel=None 合并两渠道
|
||||
amap——u1(wx1+wa1)的宽表 SUM 与 fact 分位都跨渠道归并;
|
||||
无数据员工(u2)也以零值行出现。"""
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
s.add(WhatsappAccount(uid="wa1", user_id="u1"))
|
||||
# 账号存在但映射到非 staff 用户 → 幽灵
|
||||
s.add(WechatAccount(wx_id="wxg", user_id="ghost"))
|
||||
await s.commit()
|
||||
|
||||
s.add(_mday(20260901, msg_sent=3))
|
||||
s.add(_mday(20260901, channel="whatsapp", msg_sent=4))
|
||||
s.add(_mday(20260901, user_id="ghost", msg_sent=50))
|
||||
s.add(_fact(100_000, owner="wx1", i=0))
|
||||
s.add(_fact(300_000, channel="whatsapp", owner="wa1", peer="w1", i=1))
|
||||
s.add(_fact(50_000, owner="wxg", i=2))
|
||||
await s.commit()
|
||||
|
||||
m = await period_metrics(s, 20260901, 20260905, None)
|
||||
assert set(m) == {"u1", "u2"} # ghost 不在;零数据 u2 在
|
||||
u1 = m["u1"]
|
||||
assert u1.msg_sent == 7 # 两渠道行都归 u1
|
||||
assert u1.days_with_data == 1 # 双渠道同日只计 1 天(DISTINCT day)
|
||||
assert u1.fr_p50_ms == 100_000 # [100s,300s] 合并后 ceil(0.5*2)=1
|
||||
assert u1.fr_p90_ms == 300_000 # ceil(0.9*2)=2
|
||||
assert u1.reply_2h_count == 2
|
||||
assert u1.reply_24h_count == 2
|
||||
assert m["u2"].msg_sent == 0
|
||||
assert m["u2"].fr_p50_ms is None
|
||||
|
||||
|
||||
async def test_reply_counts(make_session):
|
||||
"""fr=90min/3h/25h → 2h_count=1、24h_count=2(2h 是 24h 的子集)。"""
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
s.add(_fact(5_400_000, i=0)) # 90min ≤2h
|
||||
s.add(_fact(10_800_000, i=1)) # 3h ∈(2h,24h]
|
||||
s.add(_fact(90_000_000, i=2)) # 25h >24h
|
||||
await s.commit()
|
||||
m = await period_metrics(s, 20260901, 20260905, "wechat")
|
||||
assert m["u1"].reply_2h_count == 1
|
||||
assert m["u1"].reply_24h_count == 2
|
||||
assert m["u2"].reply_2h_count == 0
|
||||
|
||||
|
||||
async def test_user_ids_filter_and_calls(make_session):
|
||||
"""user_ids 与 staff 求交(未知 id 剔除/空集→{});通话四列按窗 SUM
|
||||
且不受 channel 参数影响(无渠道维,恒全渠道)。"""
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
s.add(_mday(20260901, user_id="u1", msg_sent=1))
|
||||
s.add(_mday(20260901, user_id="u2", msg_sent=2))
|
||||
s.add(EmployeeCallDaily(day=20260901, user_id="u1", call_count=3,
|
||||
call_seconds=600, answered_call_count=2,
|
||||
call_followup_count=1))
|
||||
s.add(EmployeeCallDaily(day=20260902, user_id="u1", call_count=1,
|
||||
call_seconds=60, answered_call_count=1,
|
||||
call_followup_count=0))
|
||||
s.add(EmployeeCallDaily(day=20260901, user_id="u2", call_count=9))
|
||||
await s.commit()
|
||||
|
||||
m = await period_metrics(s, 20260901, 20260905, "wechat", user_ids=["u1", "nobody"])
|
||||
assert set(m) == {"u1"}
|
||||
assert m["u1"].msg_sent == 1
|
||||
u1 = m["u1"]
|
||||
assert u1.call_count == 4
|
||||
assert u1.call_seconds == 660
|
||||
assert u1.answered_call == 3
|
||||
assert u1.call_followup == 1
|
||||
|
||||
empty = await period_metrics(s, 20260901, 20260905, "wechat", user_ids=[])
|
||||
assert empty == {}
|
||||
|
||||
# channel="whatsapp" 时通话仍计入(恒全渠道)
|
||||
mwa = await period_metrics(s, 20260901, 20260905, "whatsapp")
|
||||
assert mwa["u1"].call_count == 4
|
||||
|
||||
|
||||
def test_team_medians():
|
||||
"""奇数样本取中位、偶数取均值;None 剔除;全 None → None;user_id 不参与。"""
|
||||
|
||||
def _pm(**kw):
|
||||
base = dict(zip(PeriodMetrics._fields, [None] * len(PeriodMetrics._fields)))
|
||||
base.update(user_id="x", days_with_data=0, msg_sent=0, msg_recv=0,
|
||||
cust_threads=0, threads_answered=0, fr_sum_ms=0,
|
||||
reply_2h_count=0, reply_24h_count=0, active_friends=0,
|
||||
active_rooms_avg=0.0, new_customer=0, cooling_customer=0,
|
||||
dormant_customer=0, reactivated_customer=0,
|
||||
call_count=0, call_seconds=0, answered_call=0,
|
||||
call_followup=0)
|
||||
base.update(kw)
|
||||
return PeriodMetrics(**base)
|
||||
|
||||
metrics = {
|
||||
"u1": _pm(user_id="u1", msg_sent=10, fr_p50_ms=None),
|
||||
"u2": _pm(user_id="u2", msg_sent=20, fr_p50_ms=100),
|
||||
"u3": _pm(user_id="u3", msg_sent=30, fr_p50_ms=200),
|
||||
}
|
||||
med = team_medians(metrics)
|
||||
assert med["msg_sent"] == 20
|
||||
assert med["fr_p50_ms"] == 150.0 # None 剔除后 [100,200] 均值
|
||||
assert "user_id" not in med
|
||||
assert team_medians({})["msg_sent"] is None
|
||||
assert team_medians({"u1": _pm()})["fr_p50_ms"] is None
|
||||
@@ -0,0 +1,305 @@
|
||||
"""管理域评分配置/引擎判别测试(批次B Task B2)。"""
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from app.db.models import EmployeeScoreConfig
|
||||
from app.management.employee_metrics import PeriodMetrics
|
||||
from app.management.metric_definitions import (
|
||||
DEFAULT_SCORE_CONFIG,
|
||||
METRIC_DEFINITIONS,
|
||||
validate_config,
|
||||
)
|
||||
from app.management.scoring import normalize, score_employee
|
||||
|
||||
|
||||
def test_config_weights_sum_and_structure():
|
||||
dims = DEFAULT_SCORE_CONFIG["dimensions"]
|
||||
assert sum(d["weight"] for d in dims.values()) == 100
|
||||
# 一期 pending_data 的维度不参与试算
|
||||
available = [d for d in dims.values() if d.get("status") != "pending_data"]
|
||||
assert sum(d["weight"] for d in available) == 70 # 30+25+15
|
||||
assert dims["action"]["status"] == "pending_data"
|
||||
assert dims["risk"]["status"] == "pending_data"
|
||||
|
||||
|
||||
def test_config_normalization_declared():
|
||||
assert DEFAULT_SCORE_CONFIG["normalization"] == "available_only_rescale"
|
||||
|
||||
|
||||
def test_validate_config_rejects_bad_weight_sum():
|
||||
bad = json.loads(json.dumps(DEFAULT_SCORE_CONFIG)) # deep copy
|
||||
bad["dimensions"]["service"]["weight"] = 26 # 总和 101
|
||||
problems = validate_config(bad)
|
||||
assert any("权重" in p for p in problems)
|
||||
|
||||
|
||||
def test_metric_definitions_cover_scored_metrics():
|
||||
# 每个参与评分的 metric_code 都有元数据(名称/单位/方向)
|
||||
for dim in DEFAULT_SCORE_CONFIG["dimensions"].values():
|
||||
for code in dim.get("metrics", {}):
|
||||
# band 型指标的 key 带 _band 后缀,spec 里有 metric 字段:
|
||||
spec = dim["metrics"][code]
|
||||
src = spec.get("metric", code)
|
||||
assert src in METRIC_DEFINITIONS
|
||||
m = METRIC_DEFINITIONS[src]
|
||||
assert m["name"] and m["unit"] and m["direction"] in (1, -1)
|
||||
|
||||
|
||||
async def test_seed_and_idempotent(make_session):
|
||||
from app.management.metric_definitions import _seed_score_config # 纯会话函数
|
||||
s = make_session
|
||||
v1 = await _seed_score_config(s)
|
||||
await s.commit()
|
||||
assert v1 == "v1.0"
|
||||
rows = (await s.execute(sa.select(EmployeeScoreConfig))).scalars().all()
|
||||
assert len(rows) == 1 and rows[0].status == "active"
|
||||
# 幂等:已存在同版本返回 None 不新建
|
||||
assert await _seed_score_config(s) is None
|
||||
await s.commit()
|
||||
assert len((await s.execute(sa.select(EmployeeScoreConfig))).scalars().all()) == 1
|
||||
|
||||
|
||||
# ---- 评分引擎(Task B2) ----
|
||||
|
||||
def _pm(**overrides):
|
||||
"""全零/None 默认的 PeriodMetrics 构造器(22 字段,覆盖式改)。"""
|
||||
base = {
|
||||
"user_id": "u1", "days_with_data": 0, "msg_sent": 0, "msg_recv": 0,
|
||||
"cust_threads": 0, "threads_answered": 0, "fr_sum_ms": 0,
|
||||
"fr_p50_ms": None, "fr_p90_ms": None,
|
||||
"reply_2h_count": 0, "reply_24h_count": 0,
|
||||
"active_friends": 0, "active_rooms_avg": 0.0,
|
||||
"new_customer": 0, "cooling_customer": 0, "dormant_customer": 0,
|
||||
"end_active_friends": 0, "reactivated_customer": 0,
|
||||
"call_count": 0, "call_seconds": 0, "answered_call": 0,
|
||||
"call_followup": 0,
|
||||
}
|
||||
base.update(overrides)
|
||||
return PeriodMetrics(**base)
|
||||
|
||||
|
||||
def _full_marks(**overrides):
|
||||
"""三可用维度全满分:率 1.0/fr 10min/占比 0.6/两 band 区间内,样本全 ok。"""
|
||||
full = dict(
|
||||
cust_threads=50, reply_2h_count=50, reply_24h_count=50,
|
||||
fr_p90_ms=600_000, # 10min ≤ 30min 满分线
|
||||
end_active_friends=9, cooling_customer=3, dormant_customer=3, # 9/15=0.6
|
||||
active_friends=50, # band [5,80] 内且样本 ok
|
||||
call_count=20, # band [1,200] 内
|
||||
)
|
||||
full.update(overrides)
|
||||
return _pm(**full)
|
||||
|
||||
|
||||
def test_normalize_target_three_segments():
|
||||
spec = {"type": "target", "floor": 0.40, "target": 0.85}
|
||||
assert normalize(spec, 0.20) == 0.0 # ≤floor
|
||||
assert normalize(spec, 0.40) == 0.0 # floor 处 0
|
||||
assert normalize(spec, 0.625) == 50.0 # 中点 50
|
||||
assert normalize(spec, 0.85) == 100.0 # target 处 100
|
||||
assert normalize(spec, 0.99) == 100.0 # ≥target 截断
|
||||
assert normalize(spec, None) is None # 无值≠0 分
|
||||
|
||||
|
||||
def test_normalize_reverse_ends_and_midpoint():
|
||||
spec = {"type": "reverse", "target_ms": 1_800_000, "floor_ms": 14_400_000}
|
||||
assert normalize(spec, 1_800_000) == 100.0 # 30min 满分
|
||||
assert normalize(spec, 600_000) == 100.0 # 更快也封顶 100
|
||||
assert normalize(spec, 8_100_000) == 50.0 # (30min+4h) 中点 50
|
||||
assert normalize(spec, 14_400_000) == 0.0 # 4h 0 分
|
||||
assert normalize(spec, 28_800_000) == 0.0 # 更慢截断 0
|
||||
assert normalize(spec, None) is None
|
||||
|
||||
|
||||
def test_normalize_band_three_segments():
|
||||
spec = {"type": "band", "metric": "active_friends", "low": 5, "high": 80,
|
||||
"basis": "period_distinct"}
|
||||
assert normalize(spec, 3) is None # <low 负载不足→不评分
|
||||
assert normalize(spec, 4.9) is None
|
||||
assert normalize(spec, 5) == 100.0 # 含下限
|
||||
assert normalize(spec, 80) == 100.0 # 含上限
|
||||
assert normalize(spec, 81) == 80.0 # 超上限 80 不再加
|
||||
assert normalize(spec, 500) == 80.0
|
||||
assert normalize(spec, None) is None
|
||||
|
||||
|
||||
def test_zero_threads_rates_none_not_zero():
|
||||
"""cust_threads=0 → 回复率无值(None),不是 0%;service 维度 pending。"""
|
||||
res = score_employee(DEFAULT_SCORE_CONFIG, _pm(cust_threads=0))
|
||||
for code in ("reply_2h_rate", "reply_24h_rate"):
|
||||
md = res["metrics_detail"][code]
|
||||
assert md["value"] is None
|
||||
assert md["score"] is None
|
||||
assert res["dimensions"]["service"]["status"] == "pending"
|
||||
|
||||
|
||||
def test_insufficient_sample_employee():
|
||||
"""cust_threads=5(<soft 10):service 维度 insufficient(率有值 0.0 但
|
||||
不评分);customer 期末无存量 pending;workload 样本 af=0 也不足——
|
||||
可用维度=0 → 整员工不评分。"""
|
||||
res = score_employee(DEFAULT_SCORE_CONFIG, _pm(cust_threads=5))
|
||||
assert res["dimensions"]["service"]["status"] == "insufficient"
|
||||
assert res["dimensions"]["service"]["score"] is None
|
||||
assert res["dimensions"]["customer"]["status"] == "pending"
|
||||
assert res["dimensions"]["workload"]["status"] == "insufficient"
|
||||
assert res["total_score"] is None
|
||||
assert res["grade"] is None
|
||||
assert res["score_status"] == "insufficient_sample"
|
||||
md = res["metrics_detail"]["reply_2h_rate"]
|
||||
assert md["value"] == 0.0 and md["score"] is None
|
||||
assert md["sample_size"] == 5 and md["sample_status"] == "insufficient"
|
||||
|
||||
|
||||
def test_low_confidence_still_scored():
|
||||
"""cust_threads=15 ∈ [soft10, min30):照评但逐指标 low_confidence。"""
|
||||
cur = _pm(cust_threads=15, reply_2h_count=15, reply_24h_count=15,
|
||||
fr_p90_ms=600_000)
|
||||
res = score_employee(DEFAULT_SCORE_CONFIG, cur)
|
||||
svc = res["dimensions"]["service"]
|
||||
assert svc["status"] == "scored" and svc["score"] == 100.0
|
||||
for code in ("reply_2h_rate", "reply_24h_rate", "fr_p90_ms"):
|
||||
assert svc["metrics"][code]["sample_status"] == "low_confidence"
|
||||
assert svc["metrics"][code]["score"] == 100.0
|
||||
# 仅 service 可用(25/25)→ 重归一后仍满分
|
||||
assert res["score_status"] == "scored"
|
||||
assert res["total_score"] == 100
|
||||
|
||||
|
||||
def test_full_marks_rescale_100_of_70():
|
||||
"""三可用维度(权重 30+25+15=70)全满分 → 100/70 重归一 = 100,A。"""
|
||||
res = score_employee(DEFAULT_SCORE_CONFIG, _full_marks())
|
||||
assert res["dimensions"]["customer"]["score"] == 100.0
|
||||
assert res["dimensions"]["service"]["score"] == 100.0
|
||||
assert res["dimensions"]["workload"]["score"] == 100.0
|
||||
assert res["total_score"] == 100
|
||||
assert res["grade"] == "A"
|
||||
assert res["score_status"] == "scored"
|
||||
# 部分:service 满分但 active_share=0(floor 下)→ (0+25+15)/70=57 → E
|
||||
res2 = score_employee(
|
||||
DEFAULT_SCORE_CONFIG,
|
||||
_full_marks(end_active_friends=1, cooling_customer=10,
|
||||
dormant_customer=10), # 1/21≈0.05 ≤ floor 0.20 → 0 分
|
||||
)
|
||||
assert res2["total_score"] == 57
|
||||
assert res2["grade"] == "E"
|
||||
|
||||
|
||||
def test_pending_dimensions_not_scored():
|
||||
"""action/risk 一期 pending_data:status=pending、无 metrics、不参与归一。"""
|
||||
res = score_employee(DEFAULT_SCORE_CONFIG, _full_marks())
|
||||
for key in ("action", "risk"):
|
||||
d = res["dimensions"][key]
|
||||
assert d["status"] == "pending"
|
||||
assert d["score"] is None
|
||||
assert "metrics" not in d
|
||||
scored_w = sum(d["weight"] for d in res["dimensions"].values()
|
||||
if d["status"] == "scored")
|
||||
assert scored_w == 70 # pending 的 30 权重不进分母
|
||||
|
||||
|
||||
def test_active_share_derivation():
|
||||
"""end_active 6/(6+cooling3+dormant1)=0.6 → 达 target 线 100 分。"""
|
||||
cur = _pm(cust_threads=50, end_active_friends=6,
|
||||
cooling_customer=3, dormant_customer=1)
|
||||
res = score_employee(DEFAULT_SCORE_CONFIG, cur)
|
||||
md = res["metrics_detail"]["active_share"]
|
||||
assert md["value"] == 0.6
|
||||
assert md["score"] == 100.0
|
||||
assert res["dimensions"]["customer"]["score"] == 100.0
|
||||
assert md["sample_status"] == "ok"
|
||||
|
||||
|
||||
def test_metrics_detail_four_compare_slots():
|
||||
"""value/prev/baseline/median 四对比位透出;band 明细的 median 按
|
||||
源指标名(active_friends)取 team;单位/名称来自元数据。"""
|
||||
cur = _pm(cust_threads=40, reply_2h_count=30, reply_24h_count=38,
|
||||
fr_p90_ms=600_000, end_active_friends=6, cooling_customer=3,
|
||||
dormant_customer=1, active_friends=40, call_count=10)
|
||||
prev = _pm(cust_threads=40, reply_2h_count=20, reply_24h_count=30,
|
||||
fr_p90_ms=3_600_000, end_active_friends=3, cooling_customer=6,
|
||||
dormant_customer=1, active_friends=20, call_count=5)
|
||||
base = _pm(cust_threads=40, reply_2h_count=10)
|
||||
team = {"reply_2h_rate": 0.7, "active_friends": 33.0}
|
||||
res = score_employee(DEFAULT_SCORE_CONFIG, cur, baseline=base,
|
||||
team=team, prev=prev)
|
||||
md = res["metrics_detail"]["reply_2h_rate"]
|
||||
assert md["value"] == 0.75
|
||||
assert md["prev"] == 0.5
|
||||
assert md["baseline"] == 0.25
|
||||
assert md["median"] == 0.7
|
||||
assert md["unit"] == "0-1"
|
||||
assert md["name"] == "2h回复率"
|
||||
# band 型明细键=配置 code,median 按源指标名查 team
|
||||
band = res["metrics_detail"]["active_friends_band"]
|
||||
assert band["median"] == 33.0
|
||||
assert band["unit"] == "人"
|
||||
assert res["metrics_detail"]["fr_p90_ms"]["prev"] == 3_600_000
|
||||
assert res["metrics_detail"]["active_share"]["prev"] == 0.3 # 3/(3+6+1)
|
||||
|
||||
|
||||
def test_trial_flag_from_config():
|
||||
"""试算标记透传:默认配置恒 True(v2 §0 全默认+试算)。"""
|
||||
res = score_employee(DEFAULT_SCORE_CONFIG, _pm())
|
||||
assert res["trial"] is True
|
||||
# 标记来自 config 而非写死——正式版翻 False 只需改配置
|
||||
no_trial = json.loads(json.dumps(DEFAULT_SCORE_CONFIG))
|
||||
no_trial["trial"] = False
|
||||
assert score_employee(no_trial, _pm())["trial"] is False
|
||||
|
||||
|
||||
def test_validate_config_threshold_shapes():
|
||||
"""阈值形状四查:type 合法/target floor<target/reverse 先后/band 区间。
|
||||
默认配置自身必须通过。"""
|
||||
assert validate_config(DEFAULT_SCORE_CONFIG) == []
|
||||
|
||||
def _bad(mutate):
|
||||
cfg = json.loads(json.dumps(DEFAULT_SCORE_CONFIG))
|
||||
mutate(cfg["dimensions"])
|
||||
return cfg
|
||||
|
||||
bad_type = _bad(lambda d: d["workload"]["metrics"]["call_band"]
|
||||
.update(type="linear"))
|
||||
assert any("call_band" in p and "类型非法" in p
|
||||
for p in validate_config(bad_type))
|
||||
|
||||
bad_target = _bad(lambda d: d["customer"]["metrics"]["active_share"]
|
||||
.update(floor=0.70)) # floor>target 0.60
|
||||
assert any("active_share" in p and "floor<target" in p
|
||||
for p in validate_config(bad_target))
|
||||
|
||||
bad_reverse = _bad(lambda d: d["service"]["metrics"]["fr_p90_ms"]
|
||||
.update(target_ms=20_000_000)) # > floor_ms 14.4M
|
||||
assert any("fr_p90_ms" in p and "target_ms<floor_ms" in p
|
||||
for p in validate_config(bad_reverse))
|
||||
|
||||
bad_band = _bad(lambda d: d["workload"]["metrics"]["call_band"]
|
||||
.update(low=300)) # low>high 200
|
||||
assert any("call_band" in p and "low<high" in p
|
||||
for p in validate_config(bad_band))
|
||||
|
||||
|
||||
def test_validate_config_grade_bounds_shape():
|
||||
"""grade_bounds 须含 A/B/C/D 且严格降序(B4 DB 配置加载预埋)。"""
|
||||
missing = json.loads(json.dumps(DEFAULT_SCORE_CONFIG)) # deep copy
|
||||
del missing["grade_bounds"]["A"]
|
||||
assert any("grade_bounds" in p for p in validate_config(missing))
|
||||
|
||||
non_desc = json.loads(json.dumps(DEFAULT_SCORE_CONFIG))
|
||||
non_desc["grade_bounds"].update(A=80, B=90) # A<B 非降序
|
||||
assert any("grade_bounds" in p and "严格降序" in p
|
||||
for p in validate_config(non_desc))
|
||||
|
||||
|
||||
def test_validate_config_cross_dimension_code_unique():
|
||||
"""同指标 code 出现于两维度:metrics_detail 扁平 dict 会碰撞,须报问题。"""
|
||||
dup = json.loads(json.dumps(DEFAULT_SCORE_CONFIG)) # deep copy
|
||||
# 把 customer 维的 active_share 整个抄进 service 维 → code 跨维度重复
|
||||
dup["dimensions"]["service"]["metrics"]["active_share"] = \
|
||||
dup["dimensions"]["customer"]["metrics"]["active_share"]
|
||||
assert any("active_share" in p and "重复" in p for p in validate_config(dup))
|
||||
|
||||
|
||||
def test_validate_config_default_config_clean():
|
||||
"""默认配置自身必须全绿(空问题列表)。"""
|
||||
assert validate_config(DEFAULT_SCORE_CONFIG) == []
|
||||
@@ -178,6 +178,14 @@ export interface GroupQuery {
|
||||
room_id?: string
|
||||
/** 群名称模糊搜索(通讯录群 tab 搜索框)。 */
|
||||
q?: string
|
||||
/** 群备注子串(微信群统计筛选)。 */
|
||||
group_remark?: string
|
||||
/** 最新消息时间段(ms,含端点)。 */
|
||||
last_start?: number
|
||||
last_end?: number
|
||||
/** 统计时段(ms,含端点)——窗内输出沟通消息数/新增/退出成员数聚合。 */
|
||||
msg_start?: number
|
||||
msg_end?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
@@ -191,6 +199,11 @@ export function getGroups(query: GroupQuery = {}): Promise<Page<Group>> {
|
||||
user_id: query.user_id,
|
||||
room_id: query.room_id,
|
||||
q: query.q,
|
||||
group_remark: query.group_remark,
|
||||
last_start: query.last_start,
|
||||
last_end: query.last_end,
|
||||
msg_start: query.msg_start,
|
||||
msg_end: query.msg_end,
|
||||
page: query.page ?? 1,
|
||||
page_size: query.page_size ?? 20,
|
||||
}),
|
||||
|
||||
@@ -113,6 +113,19 @@ export interface Group {
|
||||
friend_count?: number | null
|
||||
/** 建群时间 ms-epoch */
|
||||
create_time?: number | null
|
||||
/** 群备注(微信群统计页;getChatroomList.remark,2026-09-09) */
|
||||
group_remark?: string | null
|
||||
/** 员工微信列(getChatroomList 同款):昵称/微信号/头像 */
|
||||
staff_nickname?: string | null
|
||||
staff_wx_alias?: string | null
|
||||
staff_avatar?: string | null
|
||||
/** 员工信息列:姓名/手机(staff 关联) */
|
||||
staff_name?: string | null
|
||||
staff_phone?: string | null
|
||||
/** 统计时段窗内聚合(仅请求带 msg_start/msg_end 时非 null) */
|
||||
window_msg_count?: number | null
|
||||
window_added?: number | null
|
||||
window_lost?: number | null
|
||||
/**
|
||||
* Verbatim upstream chatroom record (camelCase keys: chatRoomId/chatRoomName,
|
||||
* memberCount/memberIds, headImageUrl, wechatId/wechatNickName, userName/
|
||||
|
||||
+135
-29
@@ -3,14 +3,13 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Download } from '@element-plus/icons-vue'
|
||||
import { getGroups } from '@/api'
|
||||
import { getAccounts, getGroups } from '@/api'
|
||||
import { downloadBlob } from '@/api/client'
|
||||
import type { Channel, Group } from '@/api/types'
|
||||
import type { Account, Channel, Group } from '@/api/types'
|
||||
import { useChannelStore } from '@/stores/channel'
|
||||
import { channelLabel, formatTime } from '@/utils/format'
|
||||
import { exportListToCsv } from '@/utils/csvExport'
|
||||
import { buildGroupSections, readField } from '@/utils/groupFields'
|
||||
import { fromTypeLabel } from '@/utils/customerFields'
|
||||
|
||||
/**
|
||||
* 群列表 — channel-tabbed view mirroring 云客「沟通」板块的群列表.
|
||||
@@ -84,10 +83,6 @@ function cellNum(row: Group, camelKey: string, fallback = 0): number {
|
||||
const v = cell(row, camelKey)
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : fallback
|
||||
}
|
||||
/** 数据来源列:fromType 代码→文案(复用 customerFields.fromTypeLabel)。 */
|
||||
function fromTypeText(row: Group): string {
|
||||
return fromTypeLabel(cell(row, 'fromType'))
|
||||
}
|
||||
function imageCell(row: Group, camelKey: string): string | undefined {
|
||||
const v = cell(row, camelKey)
|
||||
return typeof v === 'string' && v !== '' ? v : undefined
|
||||
@@ -108,7 +103,16 @@ async function loadTab(ch: TabKey): Promise<void> {
|
||||
channel: ch,
|
||||
page: st.page,
|
||||
page_size: st.pageSize,
|
||||
owner_account_id: ownerId.value ?? undefined,
|
||||
owner_account_id: ch === 'wechat' ? (staffOwner.value || ownerId.value) || undefined : ownerId.value ?? undefined,
|
||||
// 微信群统计筛选(云客「微信群统计」同款,2026-09-09)
|
||||
...(ch === 'wechat'
|
||||
? {
|
||||
q: gName.value.trim() || undefined,
|
||||
group_remark: gRemark.value.trim() || undefined,
|
||||
...(lastRange.value ? { last_start: dayStart(lastRange.value[0]), last_end: dayEnd(lastRange.value[1]) } : {}),
|
||||
...(statRange.value ? { msg_start: dayStart(statRange.value[0]), msg_end: dayEnd(statRange.value[1]) } : {}),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
st.items = res.items
|
||||
st.total = res.total
|
||||
@@ -120,6 +124,47 @@ async function loadTab(ch: TabKey): Promise<void> {
|
||||
st.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 微信群统计筛选(云客同款:统计时段/最新消息时间段/群名称/群备注/员工) ----
|
||||
function defaultStatRange(): [Date, Date] {
|
||||
// 云客默认近 7 天(截图 09-03→09-09)
|
||||
const end = new Date()
|
||||
const start = new Date(end.getTime() - 6 * 86_400_000)
|
||||
return [start, end]
|
||||
}
|
||||
const statRange = ref<[Date, Date] | null>(defaultStatRange())
|
||||
const lastRange = ref<[Date, Date] | null>(null)
|
||||
const gName = ref('')
|
||||
const gRemark = ref('')
|
||||
/** 员工筛选(下拉;入口 query 的 owner_account_id 作初值,选择后接管)。 */
|
||||
const staffOwner = ref<string>(ownerId.value ?? '')
|
||||
const staffOptions = ref<Account[]>([])
|
||||
|
||||
function dayStart(d: Date): number {
|
||||
const x = new Date(d)
|
||||
x.setHours(0, 0, 0, 0)
|
||||
return x.getTime()
|
||||
}
|
||||
function dayEnd(d: Date): number {
|
||||
const x = new Date(d)
|
||||
x.setHours(23, 59, 59, 999)
|
||||
return x.getTime()
|
||||
}
|
||||
|
||||
async function loadStaffOptions(): Promise<void> {
|
||||
try {
|
||||
const res = await getAccounts({ channel: 'wechat', page: 1, page_size: 100 })
|
||||
staffOptions.value = res.items
|
||||
if (ownerId.value && !staffOwner.value) staffOwner.value = ownerId.value
|
||||
} catch {
|
||||
// 静默降级:员工筛选为空
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch(): void {
|
||||
tabState.wechat.page = 1
|
||||
loadTab('wechat')
|
||||
}
|
||||
function handlePageChange(ch: TabKey, p: number): void {
|
||||
tabState[ch].page = p
|
||||
loadTab(ch)
|
||||
@@ -262,6 +307,7 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
loadStaffOptions()
|
||||
loadTab(activeTab.value)
|
||||
})
|
||||
</script>
|
||||
@@ -287,9 +333,52 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
微信群 — 7 列,对齐云客 wechatdetail/2/{wxId}.
|
||||
微信群统计 — 对齐云客「沟通→微信号管理→微信群统计」
|
||||
(/cms/wechat/wechatgroup,2026-09-09 真机对齐)。
|
||||
统计时段窗内:沟通消息数=本地日汇总;新增/退出成员数=按天进出表
|
||||
求和(上线日起积累,历史日期为 0)。
|
||||
============================================================ -->
|
||||
<template v-if="activeTab === 'wechat'">
|
||||
<div class="yk-groups__statfilters">
|
||||
<div class="yk-groups__filterrow">
|
||||
<span class="yk-groups__flabel">数据统计时段:</span>
|
||||
<el-date-picker
|
||||
v-model="statRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
range-separator="→"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
size="small"
|
||||
style="width: 260px"
|
||||
/>
|
||||
<span class="yk-groups__flabel" style="margin-left: 16px">最新消息时间段:</span>
|
||||
<el-date-picker
|
||||
v-model="lastRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
range-separator="→"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
size="small"
|
||||
style="width: 260px"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="yk-groups__filterrow">
|
||||
<el-input v-model="gName" placeholder="请输入群名称" size="small" clearable style="width: 180px" @keyup.enter="onSearch" />
|
||||
<el-input v-model="gRemark" placeholder="请输入群备注" size="small" clearable style="width: 180px" @keyup.enter="onSearch" />
|
||||
<el-select v-model="staffOwner" placeholder="员工微信昵称" size="small" clearable filterable style="width: 200px">
|
||||
<el-option
|
||||
v-for="a in staffOptions"
|
||||
:key="a.account_id"
|
||||
:label="a.nickname || a.user_name || a.account_id"
|
||||
:value="a.account_id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button type="primary" size="small" @click="onSearch">查 询</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="tabState.wechat.errorMsg"
|
||||
:title="tabState.wechat.errorMsg"
|
||||
@@ -309,40 +398,57 @@ onMounted(() => {
|
||||
empty-text="暂无微信群数据"
|
||||
@row-click="openDrawer"
|
||||
>
|
||||
<el-table-column label="群信息" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="yk-staffinfo__nick" :title="cellText(row, 'chatRoomName')">{{ cellText(row, 'chatRoomName') }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数据来源" min-width="110">
|
||||
<template #default="{ row }">{{ fromTypeText(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="群主信息" min-width="180">
|
||||
<el-table-column label="群信息" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="yk-group-owner">
|
||||
<el-avatar :size="36" :src="imageCell(row, 'ownerImage') || undefined" shape="circle">
|
||||
{{ (cellText(row, 'ownerName') || '?').slice(0, 1) }}
|
||||
<el-avatar :size="34" :src="row.head_image_url || undefined" shape="circle">
|
||||
{{ (row.room_name || '?').slice(0, 1) }}
|
||||
</el-avatar>
|
||||
<div class="yk-group-owner__meta">
|
||||
<div class="yk-staffinfo__nick">{{ cellText(row, 'ownerName') }}</div>
|
||||
<div class="yk-staffinfo__muted">{{ cellText(row, 'realName') }}</div>
|
||||
<div class="yk-staffinfo__nick">{{ row.room_name || '--' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="群成员数量" width="110" align="right">
|
||||
<template #default="{ row }">{{ cellNum(row, 'memberCount') }}</template>
|
||||
<el-table-column label="群备注" min-width="110">
|
||||
<template #default="{ row }">{{ row.group_remark || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="群成员好友数量" width="130" align="right">
|
||||
<template #default="{ row }">{{ cellNum(row, 'friendCount') }}</template>
|
||||
<el-table-column label="员工微信" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<div class="yk-group-owner">
|
||||
<el-avatar :size="34" :src="row.staff_avatar || undefined" shape="square">
|
||||
{{ (row.staff_nickname || '?').slice(0, 1) }}
|
||||
</el-avatar>
|
||||
<div class="yk-group-owner__meta">
|
||||
<div class="yk-staffinfo__nick">{{ row.staff_nickname || '--' }}</div>
|
||||
<div class="yk-staffinfo__muted">{{ row.staff_wx_alias || row.owner_account_id || '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="消息数量" width="100" align="right">
|
||||
<template #default="{ row }">{{ cellNum(row, 'messageCount') }}</template>
|
||||
<el-table-column label="员工信息" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="yk-staffinfo__nick">{{ row.staff_name || '--' }}</div>
|
||||
<div class="yk-staffinfo__muted">{{ row.staff_phone || '' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="群成员数量" width="105" align="right">
|
||||
<template #default="{ row }">{{ row.member_count ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="新增成员数" width="105" align="right">
|
||||
<template #default="{ row }">{{ row.window_added ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="退出成员数" width="105" align="right">
|
||||
<template #default="{ row }">{{ row.window_lost ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="沟通消息数" width="105" align="right">
|
||||
<template #default="{ row }">{{ row.window_msg_count ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" 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="exportGroupHtml(row)">导出HTML</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
Reference in New Issue
Block a user