Compare commits

...

15 Commits

Author SHA1 Message Date
浅念 b0ebe8a15a feat(group-stats): 微信群列表对齐云客「微信群统计」——getChatroomList 数据源+窗内统计
用户报微信群列表数据/内容不对,应对齐云客「沟通→微信号管理→微信群统计」
(/cms/wechat/wechatgroup)。真机探明(表单登录+页面导航抓 network):

- 数据源=POST /wechatweb/chatroom/getChatroomList(form:departmentId+
  startTime/endTime 窗+分页;信封 data.overviews[]/totalCount 1,854 行)
- 云客窗是任意时间窗实时算:沟通消息数实测同群全史 417/7天 36/单日 6
- **修正存量映射错误**:member_count 原存 friendNum(员工好友数),真群成员
  数是 friendCount/getChatroomList.memberCount
- 新增/退出成员数云客按窗实时算,本地无事件流 → 按天积累

实现:
- ChatGroup 补 group_remark 列(备注仅 friend_list/es 携带,两归一化均
  「有值才写」防互清);迁移含存量 raw_json 回填(须先于基线抓取执行)
- 新表 chat_group_member_stats(渠道×账号×群×天一行 added/lost)
- 新实体 chatroom-stats:全窗刷档案列(真成员数/群备注/员工信息,owner
  经 wechat_account alias 解析)+ --day 单日窗落进出日行;full_sync 5c 步
- /api/groups 扩展:msg_start/msg_end 窗内聚合(window_msg_count=
  msg_daily_stats 按天和、window_added/lost=日表和)、last_start/end
  最新消息窗、group_remark 子串;行补员工微信/员工信息关联列
- GroupList 微信 tab:统计时段(默认近7天)/最新消息时间段/群名称/群备注/
  员工下拉/查询 + 9 列(群信息|群备注|员工微信|员工信息|群成员数量|
  新增成员数|退出成员数|沟通消息数|操作);WA tab 不动

700 测试绿(新增窗聚合/筛选/员工列 3 例)。本地实测:基线 1,854 群=云客
total 一字不差,今日日窗 added 407/lost 166 落表,页面 9 列/员工列渲染正常。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 18:09:20 +08:00
浅念 03c08881b6 feat(management): 确定性异常规则×4——P90恶化/回复率骤降/活动骤降/过载+derive_metrics公有化
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 18:06:08 +08:00
浅念 07c9d091a1 fix(management): validate_config补grade_bounds形状与跨维度code唯一性校验 2026-09-09 17:56:55 +08:00
浅念 c9f8c844d6 feat(management): 评分引擎——三类标准化/维度归一/样本保护/等级/四对比明细+期末active_friends接口修订
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 17:42:57 +08:00
浅念 b51b32a14b fix(management): 期聚合active_friends按(uid,peer)去重——多账号共享客户不再双计;reply计数并入Python/单一METRIC_VERSION
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 17:32:34 +08:00
浅念 729710da63 feat(management): 期聚合查询——fact精确分位/期末存量跨渠道合并/distinct活跃/前窗基线窗口
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 17:18:33 +08:00
浅念 8b555a5ad4 refactor(management): 种子函数移入management包+深拷贝冻结配置+清理死代码
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 17:06:14 +08:00
浅念 afc71e61ef feat(management): 指标元数据+试算评分配置v1.0+种子CLI——权重校验/幂等落库
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 16:51:56 +08:00
浅念 d523813435 docs(management): 批次B实施计划——评分/异常/六只读API端点(Task B0-B5)
评审两minor+五advisory全采纳:active_share期末行口径/异常严重度加权排序/
窗口函数列表达式/affected_count定义/channel=all翻译/错误风格偏差注明
2026-09-09 16:46:39 +08:00
浅念 7758a3351f docs(management): 批次A终评修正——部署顺序理由/data_complete语义/通话类型注释
终评 Approve 后三处文档级修正:
- CLI help与DEPLOY的顺序理由改为事实(宽表不读fact,先跑不会出0;
  顺序约定保留,fact是批次B评分/下钻数据源)
- data_complete 注释改为预留语义待批次B定义(当前恒True)
- EmployeeCallDaily 注释通话类型实测值(voice_call/wa_voice_call)
2026-09-09 16:30:05 +08:00
浅念 639edf56a6 docs(management): 批次A回填实测+对账记录+DEPLOY部署注记
- 核实文档追加Task8小节:回填实测汇总(fact 68,476行/72s,宽表12,371+
  通话1,163/71s,幂等,同源68,476≡68,476,孤儿0,通话314天)
- 对账抽查:王云青+田瑞志×3日期×三组,①②组12/12逐日全一致,③组宽表
  ≤日切(差值=跨夜串归属效应,全期12,055 vs 20,981折叠57.4%验证串未丢)
- 批次A验收清单11项全过(653测试本机复跑绿,写钩子零改动逐commit核查)
- DEPLOY.md §7 新增批次A附加步骤:b7c8d9e0f1a2手动alembic+两条回填
  命令及顺序依赖(employee-metrics必须在first-reply-facts之后)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 16:20:33 +08:00
浅念 0fce21aaf7 fix(management): 回填CLI通话日并集——消息空窗日的通话不再漏(8天27条实测)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 16:11:04 +08:00
浅念 b2e8edacc2 feat(management): 两条回填CLI——first-reply-facts/employee-metrics全量重建幂等
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 14:55:16 +08:00
浅念 e725c7032a docs(management): 批次A计划Task6代码块同步评审修正——hist时点语义/防御性flush
与 ad3beb7 实际实现对齐:day_msgs+cum 累积数组(hist=≤as_of,历史日沉睡门槛
不被未来消息虚增)、每日循环末 flush、accumulate import
2026-09-09 14:45:58 +08:00
浅念 ad3beb7625 fix(management): 宽表生命周期hist改时点语义(≤as_of累积)——历史日dormant不再被未来消息虚增;补正向同源/percentile None判别+防御性flush
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 14:44:35 +08:00
27 changed files with 3353 additions and 53 deletions
+26
View File
@@ -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 镜像重建)+回测试运行。
+110 -2
View File
@@ -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)
+11
View File
@@ -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
+36 -3
View File
@@ -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 群通话除外)。
"""
+234
View File
@@ -0,0 +1,234 @@
"""管理域确定性异常规则(批次B Task B3,v1 §5.4 异常卡片)。
4 条规则,输入=当前+个人基线 PeriodMetrics,输出异常卡片 dict 列表。
全部确定性——不使用 LLM 推断,reason/title/suggested_action 均为固定
中文模板填数,reason 只描述数据事实(百分比/样本数/未回串数)。
规则表(触发比较均为严格 > / <):
- reply_p90_worsened : cur.fr_p90_ms > base×1.5 且 cur.cust_threads≥
soft_threads;>×2.5 升 critical
- reply_rate_dropped : derive(reply_2h_rate) cur < base 同式×0.7 且
cur.cust_threads≥soft_threads(率由 scoring.
derive_metrics 派生,与评分同一口径)
- activity_drop : cur 日均 (msg_sent+msg_recv)/n_days < base 日均
(…/28)×0.5 且 base.days_with_data≥min_days
(基线太稀疏不可比;基线窗口恒 28 天=baseline_window
语义,当前窗口天数由 n_days 传入)
- workload_overload : cur.active_friends(周期 distinct) > workload 维
active_friends_band.high(与评分 band 同源阈值)
无基线不判:baseline=None 直接返回 [](新员工无个人对比位即不出卡,
含与基线无关的 overload——新入职负载爬坡期误报多,不判);某侧值
None/不可算(无串样本的率、无 fact 的分位、0ms 退化基线)→ 对应规则
不触发。
纯函数(无 session/IO):B4 dashboard/employees 对全员逐个调用。
阈值不硬编码:soft_threads/min_days 取 config.sample_guards,过载上限取
config.dimensions.workload.metrics.active_friends_band.high;乘数
(1.5/2.5/0.7/0.5)是规则语义本身,定义为模块常量而非可调参数。
"""
from __future__ import annotations
from app.management.employee_metrics import PeriodMetrics
from app.management.scoring import derive_metrics
# 规则乘数(语义常量,非可调参数——调整即换规则版本)
P90_WARN_MULTIPLIER = 1.5
P90_CRITICAL_MULTIPLIER = 2.5
RATE_DROP_MULTIPLIER = 0.7
ACTIVITY_DROP_MULTIPLIER = 0.5
BASELINE_WINDOW_DAYS = 28 # 个人基线窗口恒 28 天(employee_metrics.baseline_window)
# ---- 固定文案模板(.format 填数,reason 只含数据事实) ----
TITLE_P90 = "回复 P90 较个人基线上升 {pct}%"
TITLE_RATE = "2h 回复率较个人基线下降 {pct}%"
TITLE_ACTIVITY = "日均消息量较个人基线下降 {pct}%"
TITLE_OVERLOAD = "活跃客户 {current} 人超过负载上限 {limit} 人"
REASON_P90 = (
"近 {n_days} 天首响 P90 为 {cur}(基线前 28 天为 {base}),上升 {pct}%;"
"期内 {threads} 串客户消息中 {unreplied} 串未回"
)
REASON_RATE = (
"近 {n_days} 天 2h 回复率为 {cur:.0%}({cur_cnt}/{cur_thr} 串),"
"基线(前 28 天)为 {base:.0%}({base_cnt}/{base_thr} 串);"
"期内 {unreplied} 串未回"
)
REASON_ACTIVITY = (
"近 {n_days} 天日均消息 {cur:.1f} 条/天,基线(前 28 天)日均 {base:.1f} 条/天,"
"下降 {pct}%;当前活跃客户 {friends} 人"
)
REASON_OVERLOAD = (
"近 {n_days} 天活跃客户 {current} 人(周期 distinct 口径),"
"超过配置上限 {limit} 人"
)
ACTION_P90 = "查看超时未回客户明细,与员工确认近期负载与接待安排"
ACTION_RATE = "查看未回串明细,排查回复流程与时段覆盖问题"
ACTION_ACTIVITY = "与员工确认在岗状态与客户维护情况,并核对账号数据接入是否正常"
ACTION_OVERLOAD = "评估客户分配与负载均衡,考虑分流部分客户或安排协助"
def _fmt_ms(ms: float) -> str:
# 与 scoring._fmt_ms 同式(模块私有不跨文件引用)
minutes = ms / 60_000
if minutes >= 60 and minutes % 60 == 0:
return f"{minutes // 60:g}h"
return f"{minutes:g}min"
def _pct_up(cur: float, base: float) -> int:
"""较基线上升百分比(四舍五入取整;触发后调用,base>0 已保证)。"""
return int(round((cur - base) / base * 100))
def _pct_down(cur: float, base: float) -> int:
"""较基线下降百分比(触发后调用,base>0 已由触发条件保证)。"""
return int(round((base - cur) / base * 100))
def detect_anomalies(
user_id: str,
current: PeriodMetrics,
baseline: PeriodMetrics | None,
*,
n_days: int,
config: dict,
) -> list[dict]:
"""单员工确定性异常检测(纯函数,卡片结构见 v1 §5.4/模块 docstring)。
输出按规则表序:p90 恶化 → 回复率骤降 → 活动骤降 → 过载
(排序/聚合由 B4 调用方按 severity/affected_count 处理)。
"""
if baseline is None:
return [] # 无基线不判(新员工)
guards = config.get("sample_guards", {})
soft = guards.get("soft_threads", 10)
min_days = guards.get("min_days", 5)
band_high = (
config.get("dimensions", {})
.get("workload", {})
.get("metrics", {})
.get("active_friends_band", {})
.get("high")
)
cards: list[dict] = []
unreplied = current.cust_threads - current.threads_answered
threads_ok = current.cust_threads >= soft
# ---- 1. reply_p90_worsened:P90 较个人基线恶化 ×1.5/×2.5 ----
if (
threads_ok
and current.fr_p90_ms is not None
and baseline.fr_p90_ms is not None
and baseline.fr_p90_ms > 0 # 0ms 基线为退化数据,不可比
):
base_p90 = baseline.fr_p90_ms
if current.fr_p90_ms > base_p90 * P90_CRITICAL_MULTIPLIER:
severity = "critical"
elif current.fr_p90_ms > base_p90 * P90_WARN_MULTIPLIER:
severity = "warning"
else:
severity = None
if severity is not None:
pct = _pct_up(current.fr_p90_ms, base_p90)
cards.append({
"rule_code": "reply_p90_worsened",
"severity": severity,
"employee_user_id": user_id,
"title": TITLE_P90.format(pct=pct),
"current_value": current.fr_p90_ms,
"baseline_value": base_p90,
"unit": "ms",
"sample_size": current.cust_threads,
"affected_count": unreplied,
"reason": REASON_P90.format(
n_days=n_days, cur=_fmt_ms(current.fr_p90_ms),
base=_fmt_ms(base_p90), pct=pct,
threads=current.cust_threads, unreplied=unreplied,
),
"drilldown": {"type": "metric", "query": {
"metric_code": "first_reply_p90", "user_id": user_id}},
"suggested_action": ACTION_P90,
})
# ---- 2. reply_rate_dropped:2h 回复率较基线降 30%+ ----
if threads_ok:
cur_rate = derive_metrics(current)["reply_2h_rate"]
base_rate = derive_metrics(baseline)["reply_2h_rate"]
if (
cur_rate is not None and base_rate is not None
and cur_rate < base_rate * RATE_DROP_MULTIPLIER
):
cards.append({
"rule_code": "reply_rate_dropped",
"severity": "warning",
"employee_user_id": user_id,
"title": TITLE_RATE.format(
pct=_pct_down(cur_rate, base_rate)),
"current_value": cur_rate,
"baseline_value": base_rate,
"unit": "0-1",
"sample_size": current.cust_threads,
"affected_count": unreplied,
"reason": REASON_RATE.format(
n_days=n_days, cur=cur_rate, cur_cnt=current.reply_2h_count,
cur_thr=current.cust_threads, base=base_rate,
base_cnt=baseline.reply_2h_count,
base_thr=baseline.cust_threads, unreplied=unreplied,
),
"drilldown": {"type": "metric", "query": {
"metric_code": "unreplied", "user_id": user_id}},
"suggested_action": ACTION_RATE,
})
# ---- 3. activity_drop:日均消息量较基线腰斩 ----
if n_days > 0 and baseline.days_with_data >= min_days:
cur_daily = (current.msg_sent + current.msg_recv) / n_days
base_daily = (
baseline.msg_sent + baseline.msg_recv
) / BASELINE_WINDOW_DAYS
if cur_daily < base_daily * ACTIVITY_DROP_MULTIPLIER:
cards.append({
"rule_code": "activity_drop",
"severity": "warning",
"employee_user_id": user_id,
"title": TITLE_ACTIVITY.format(
pct=_pct_down(cur_daily, base_daily)),
"current_value": cur_daily,
"baseline_value": base_daily,
"unit": "条",
"sample_size": current.active_friends,
"affected_count": current.active_friends,
"reason": REASON_ACTIVITY.format(
n_days=n_days, cur=cur_daily, base=base_daily,
pct=_pct_down(cur_daily, base_daily),
friends=current.active_friends,
),
"drilldown": {"type": "employee",
"query": {"user_id": user_id}},
"suggested_action": ACTION_ACTIVITY,
})
# ---- 4. workload_overload:活跃客户超 band 上限 ----
if band_high is not None and current.active_friends > band_high:
cards.append({
"rule_code": "workload_overload",
"severity": "warning",
"employee_user_id": user_id,
"title": TITLE_OVERLOAD.format(
current=current.active_friends, limit=band_high),
"current_value": current.active_friends,
"baseline_value": None, # 与个人基线无关,阈值在 band 配置
"unit": "人",
"sample_size": current.active_friends,
"affected_count": current.active_friends,
"reason": REASON_OVERLOAD.format(
n_days=n_days, current=current.active_friends,
limit=band_high,
),
"drilldown": {"type": "employee", "query": {"user_id": user_id}},
"suggested_action": ACTION_OVERLOAD,
})
return cards
@@ -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"]
+230
View File
@@ -0,0 +1,230 @@
"""管理域评分引擎(批次B)——试算版(v2 §0 决议:全默认+试算标记)。
三类标准化(v1 §10.2):
- target : floor..target 线性 0..100(≤floor 得 0,≥target 得 100)
- reverse : ≤target_ms 100 分,≥floor_ms 0 分,中间线性降
- band : 区间内 100,超上限 80(不再加),低于下限 None(负载不足→不评分)
样本保护(v1 §10.3):指标样本量=串类 cust_threads/band 类 active_friends;
<soft_threads(10) insufficient 不评分;soft(10)~min_threads(30) 低置信照评。
维度分=指标均分(等权,None 剔除按剩余归一);总分=可用维度加权重归一
(满分 Σweight×100/Σweight,一期可用 70 → 100/70 放大回百分制)——
action/risk pending_data 不参与,不默认 0/100。
band 基数=周期 distinct active_friends(basis=period_distinct,B2 决议:
比单日期末值更抗噪声且与 B1 口径一致)。
team 中位数键=派生指标名(spec 的 metric 字段,无则 code 本身):
fr_p90_ms/active_friends/call_count 可直接用 team_medians 输出,
reply_*_rate/active_share 需调用方对派生值取中位后传入。
"""
from __future__ import annotations
import math
from app.management.employee_metrics import PeriodMetrics
from app.management.metric_definitions import METRIC_DEFINITIONS
_GRADE_ORDER = ("A", "B", "C", "D") # 低于 D 线为 E
def normalize(spec: dict, value: float | None) -> float | None:
"""三类标准化(v1 §10.2):value None→None;band 低于下限→None。
- target : (v-floor)/(target-floor) 线性 0..100,两端截断
- reverse : (floor_ms-v)/(floor_ms-target_ms) 线性降 100..0,两端截断
- band : [low,high] 内 100,>high 80(不再加),<low None(不评分)
"""
if value is None:
return None
mtype = spec["type"]
if mtype == "target":
floor, target = spec["floor"], spec["target"]
if value <= floor:
return 0.0
if value >= target:
return 100.0
return (value - floor) / (target - floor) * 100.0
if mtype == "reverse":
target_ms, floor_ms = spec["target_ms"], spec["floor_ms"]
if value <= target_ms:
return 100.0
if value >= floor_ms:
return 0.0
return (floor_ms - value) / (floor_ms - target_ms) * 100.0
if mtype == "band":
low, high = spec["low"], spec["high"]
if value < low:
return None
if value > high:
return 80.0
return 100.0
raise ValueError(f"未知指标类型 {mtype!r}(须 target/reverse/band)")
def derive_metrics(pm: PeriodMetrics) -> dict[str, float | None]:
"""从 PeriodMetrics 派生可评分指标值(键=指标名,None=无值≠0)。
公有供 anomalies(B3)/dashboard(B4) 复用——评分与异常对比同一派生口径。
"""
out: dict[str, float | None] = {
"fr_p90_ms": pm.fr_p90_ms,
"active_friends": pm.active_friends, # band 用,周期 distinct
"call_count": pm.call_count, # band 用,period_total
}
if pm.cust_threads:
out["reply_2h_rate"] = pm.reply_2h_count / pm.cust_threads
out["reply_24h_rate"] = pm.reply_24h_count / pm.cust_threads
else:
# 无串样本→无率(严格区分 0%),分子分母同源
out["reply_2h_rate"] = None
out["reply_24h_rate"] = None
denom = pm.end_active_friends + pm.cooling_customer + pm.dormant_customer
out["active_share"] = (
pm.end_active_friends / denom if denom > 0 else None
)
return out
def _fmt_ms(ms: float) -> str:
minutes = ms / 60_000
if minutes >= 60 and minutes % 60 == 0:
return f"{minutes // 60:g}h"
return f"{minutes:g}min"
def _formula_note(spec: dict) -> str:
"""指标计分公式的中文说明(B4 透出,阈值全部来自 config)。"""
mtype = spec["type"]
if mtype == "target":
return (
f"floor {spec['floor']}..target {spec['target']} 线性 0..100 分"
"(≤floor 0,≥target 100)"
)
if mtype == "reverse":
return (
f"≤{_fmt_ms(spec['target_ms'])} 100 分,"
f"≥{_fmt_ms(spec['floor_ms'])} 0 分,中间线性降"
)
return (
f"{spec['low']}..{spec['high']} 区间内 100 分,超上限 80 分,"
f"低于 {spec['low']} 不评分(负载不足);basis={spec.get('basis', '-')}"
)
def score_employee(
config: dict,
current: PeriodMetrics,
baseline: PeriodMetrics | None = None,
team: dict[str, float] | None = None,
prev: PeriodMetrics | None = None,
) -> dict:
"""单员工试算评分(纯函数,B4 端点直接透出)。
prev/baseline/team 为四对比位槽位:prev/baseline 按同口径派生后对比,
team 为指标级中位数(键=派生指标名,可 None)。返回结构见模块 docstring
——metrics_detail 键=配置里的 metric_code(band 型如 active_friends_band)。
"""
cur_vals = derive_metrics(current)
prev_vals = derive_metrics(prev) if prev is not None else None
base_vals = derive_metrics(baseline) if baseline is not None else None
guards = config.get("sample_guards", {})
soft = guards.get("soft_threads", 10)
minimum = guards.get("min_threads", 30)
dimensions: dict[str, dict] = {}
metrics_detail: dict[str, dict] = {}
for dim_key, dim in config.get("dimensions", {}).items():
entry: dict = {
"name": dim.get("name", dim_key),
"weight": dim.get("weight", 0),
"score": None,
"status": "pending",
}
if dim.get("status") == "pending_data" or not dim.get("metrics"):
# 一期 action/risk:数据未接入——不参与归一,不带 metrics
dimensions[dim_key] = entry
continue
dim_scores: list[float] = []
any_insufficient = False # 有值但样本不足→维度 insufficient
dim_metrics: dict[str, dict] = {}
for code, spec in dim["metrics"].items():
src = spec.get("metric", code)
value = cur_vals.get(src)
# 样本量:串类=cust_threads,band 类=active_friends
sample_size = (
current.active_friends
if spec.get("type") == "band" else current.cust_threads
)
if sample_size >= minimum:
sample_status = "ok"
elif sample_size >= soft:
sample_status = "low_confidence"
else:
sample_status = "insufficient"
score: float | None = None
if value is not None:
if sample_status == "insufficient":
any_insufficient = True # 有可评值但不评分
else:
score = normalize(spec, value)
if score is not None:
dim_scores.append(score)
dim_metrics[code] = {
"value": value,
"score": score,
"sample_size": sample_size,
"sample_status": sample_status,
"formula_note": _formula_note(spec),
}
metrics_detail[code] = {
"value": value,
"prev": prev_vals.get(src) if prev_vals else None,
"baseline": base_vals.get(src) if base_vals else None,
"median": team.get(src) if team else None,
"sample_size": sample_size,
"sample_status": sample_status,
"score": score,
"unit": METRIC_DEFINITIONS.get(src, {}).get("unit", ""),
"name": METRIC_DEFINITIONS.get(src, {}).get("name", code),
}
if dim_scores:
entry["score"] = sum(dim_scores) / len(dim_scores)
entry["status"] = "scored"
elif any_insufficient:
entry["status"] = "insufficient"
else:
entry["status"] = "pending" # 全部无值(期末无存量/无串样本等)
entry["metrics"] = dim_metrics
dimensions[dim_key] = entry
# 可用维度加权重归一:Σ(w×s)/Σw(满分=Σw×100/Σw=100),四舍五入取整
available = [
(d["weight"], d["score"]) for d in dimensions.values()
if d["score"] is not None
]
total: int | None = None
if available:
wsum = sum(w for w, _ in available)
total = int(math.floor(
sum(w * s for w, s in available) / wsum + 0.5
))
grade: str | None = None
if total is not None:
grade = "E"
bounds = config.get("grade_bounds", {})
for g in _GRADE_ORDER: # A..D 阈值递减,首中即停
if total >= bounds[g]:
grade = g
break
return {
"total_score": total,
"grade": grade,
"score_status": "scored" if total is not None else "insufficient_sample",
"trial": bool(config.get("trial")),
"dimensions": dimensions,
"metrics_detail": metrics_detail,
}
+7 -1
View File
@@ -901,7 +901,7 @@ def _normalize_chat_group(record: dict[str, Any]) -> dict[str, Any]:
``'YYYY-MM-DD HH:mm:ss'`` 字符串,另兼容 ms-int/数字串,统一归一为
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(
+17 -9
View File
@@ -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
+14
View File
@@ -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)
# ---------------------------------------------------------------------------
+119
View File
@@ -15,6 +15,9 @@ Three invocations (plus analytics backfills, see below)::
python -m app.sync.cli daily-stats
python -m app.sync.cli 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)
+6
View File
@@ -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 —— 必须紧跟录音重链接
+161
View File
@@ -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":
+76
View File
@@ -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) == []
+13
View File
@@ -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,
}),
+13
View File
@@ -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
View File
@@ -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>