2026-09-09 16:46:39 +08:00
# 云客经营管理后台·批次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 ,
2026-09-09 18:12:26 +08:00
"basis" : "period_distinct" },
2026-09-09 16:46:39 +08:00
"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}}}。"""
```
规则(写死进测试):
2026-09-09 18:12:26 +08:00
- 周期值派生:`reply_2h_rate = reply_2h_count/cust_threads` ( threads=0 → None);`fr_p90_ms` 直取;`active_share` **分子=期末该渠道最近一行(≤end)的 active_friends,分母=同一行的 active_friends+cooling_customer+dormant_customer** (分母 0 → None;宽表无生命周期 active 存量列,active 以 active_friends 列充当);band 用 active_friends 周期 distinct( B2 决议:抗单日噪声,与 B1 口径一致)与 call_count 总量。
2026-09-09 16:46:39 +08:00
- 样本保护:`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 | 员工详情 |
2026-09-09 18:12:26 +08:00
| workload_overload | active_friends 周期 distinct > band 上限 80 | warning | 员工详情 |
2026-09-09 16:46:39 +08:00
卡片字段照 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 镜像重建)+回测试运行。