Files
yunke/docs/superpowers/plans/2026-09-08-management-console-batch-a-data-foundation.md
T
浅念 27f91e560d docs(management): 批次A数据底座实施计划——Task0-8全码级拆解
五项核实→六表模型/migration→跨日精确fact算法→生命周期纯函数→
通话小表→宽表(1:1口径/同源/幽灵过滤/纯生命周期日出行)→两条回填CLI→
本地全量实测对账。判别测试覆盖v2 §9全部防线(跨夜串/friend口径/
同源/超集/幂等)。批次B-F待批次A合入后另出计划
2026-09-09 10:34:32 +08:00

2072 lines
85 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 云客经营管理后台·批次A(数据底座)实施计划
> **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:** 落地经营管理后台的数据底座——6 张新表、首响事实跨日精确计算、员工指标宽表生成、两条回填 CLI,全部带判别性测试。
**Architecture:** 不动现有写钩子与两张汇总表;新增 `first_reply_fact`(跨日精确串)+ `employee_metric_daily`(1:1 口径宽表)+ `employee_call_daily`(通话小表)三张数据表与三张业务表(评分配置/快照/辅导,本批次只建模型不写业务逻辑)。依据 spec:[2026-09-08-yunke-management-console-implementation-v2.md](./2026-09-08-yunke-management-console-implementation-v2.md) §1-§4、§9(本计划 = v2 的 Task 0-2 展开;评分/异常/API/前端在批次 B-F 另出计划)。
**Tech Stack:** Python 3.12 + FastAPI + SQLAlchemy 2.0 async + Alembic;测试 pytest + 内存 SQLite(`StaticPool`);生产 MySQL 8.4(本地 Docker,`DB_URL` 已在 `.env` 指向 `mysql+asyncmy://yunke:yunke@localhost:3307/yunke`)。
---
## 执行者必读:关键代码事实与口径
1. **日切 vs 跨日**:`app/storage/summary.py` `_thread_stats` 是日切近似——客户 23:00 发、员工次日 9:00 回的场景**整条样本丢失**(Day1 串不闭合、Day2 重起算无客户消息)。本计划的 `compute_conversation` 按会话全历史计算,跨夜串不丢(spec §1.1 #1)。
2. **混合口径**:`app/storage/staff_summary.py` 聚合的 `sent/recv/threads/answered/fr` 无 kind 过滤 = friend+room 混合。宽表消息计数列/串类列一律 **friend-only**(spec §1.1 #2)。
3. **同源原则**:宽表串类三列与 fact 行出自**同一个** `compute_conversation` 调用——回复率分子分母一致(spec §1.1 #5)。
4. **通话无渠道**:`PhoneCall.channel` 是通话类型(`phone_call`/`voice_call`)非消息渠道(models.py:512 docstring),通话进独立小表 `employee_call_daily(day, user_id)`(spec §1.2 #9)。
5. **员工主数据** = `staff` 表(`user_id` PK + `is_on_job`,models.py:126)。幽灵员工(account_map 映射不上或不在 staff)过滤不落宽表(spec §1.3 #11)。
6. **day 口径**:Asia/Shanghai 自然日 yyyymmdd,一律 Python 计算(复用 `app.storage.summary.day_key`),不用 SQL 日期函数。
7. **生命周期口径**(spec §1.1 #3 + v1 §21 默认):new=首次沟通≤7 天;reactivated=当日回流且前次间隔>30 天;active=最近互动≤6 天;cooling=7~29 天;dormant=≥30 天且历史消息≥5 条(对齐 churn real 的 min_msgs=5)。`customer` 归属用 `owner_account_id`→`account_map`→`user_id`(当前归属口径,换人后归新员工)。
8. **测试命令**(Windows venv,在 `yunke-server/` 下):`.venv/Scripts/python.exe -m pytest tests/<file> -v`。提交信息用中文 conventional 风格(参考 `git log`)。**仓库 master 单主干,直接在 master 提交**(项目惯例,不用 worktree)。
9. **本地起服务前先查 Docker**:yunke-backend-1/frontend-1 容器占 8000/5173,需保持 stopped(`docker stop yunke-backend-1 yunke-frontend-1`;mysql/scheduler 常驻)。
## 文件地图
| 文件 | 职责 | 动作 |
|---|---|---|
| `yunke-server/app/db/models.py` | 6 张新表 ORM 定义 | 追加 |
| `yunke-server/migrations/versions/<new>_management_phase1_tables.py` | 建表迁移(幂等守卫) | 新建 |
| `yunke-server/app/storage/first_reply_facts.py` | 跨日精确串算法 + fact 落库 | 新建 |
| `yunke-server/app/analytics/lifecycle.py` | 生命周期判定纯函数(阈值单一定义处) | 新建 |
| `yunke-server/app/storage/employee_metrics.py` | 宽表 + 通话小表生成 | 新建 |
| `yunke-server/app/sync/cli.py` | 两条回填命令 | 修改 |
| `yunke-server/tests/conftest.py` | `make_session` fixture | 修改 |
| `yunke-server/tests/test_management_models.py` 等 5 个测试文件 | 判别性测试 | 新建 |
| `docs/superpowers/plans/2026-09-08-management-batch-a-verification.md` | Task 0 核实记录 | 新建 |
| `docs/DEPLOY.md` | 两条回填命令部署注记 | 修改 |
---
### Task 0: 五项核实 + 回测名单(不改代码)
**Files:**
- Create: `docs/superpowers/plans/2026-09-08-management-batch-a-verification.md`
- [ ] **Step 1: 确认本地 MySQL 容器名与连通**
```bash
docker ps --format '{{.Names}}' # 期望看到 yunke-mysql-1(常驻)
```
以下 SQL 用 `docker exec -it yunke-mysql-1 mysql -uyunke -pyunke yunke -e "<SQL>"` 执行。
- [ ] **Step 2: 核实①员工主数据(staff 表)**
```sql
SELECT COUNT(*) AS total, SUM(is_on_job=1) AS on_job FROM staff;
SELECT COUNT(DISTINCT user_id) FROM wechat_account WHERE user_id IS NOT NULL;
SELECT COUNT(DISTINCT user_id) FROM whatsapp_account WHERE user_id IS NOT NULL;
```
期望:staff 总数≈41;账号表 user_id 全部能落在 staff(`SELECT COUNT(*) FROM wechat_account w LEFT JOIN staff s ON w.user_id=s.user_id WHERE w.user_id IS NOT NULL AND s.user_id IS NULL` 应为 0 或极少——不为 0 时记录哪些幽灵账号)。
- [ ] **Step 3: 核实②phone_call 列与单位**
```sql
SHOW COLUMNS FROM phone_call;
SELECT channel, COUNT(*) FROM phone_call GROUP BY channel;
SELECT call_id, channel, user_id, call_number, start_call_time, call_duration, incoming_call
FROM phone_call ORDER BY start_call_time DESC LIMIT 10;
SELECT COUNT(*) AS total, SUM(call_duration) AS dur_sum, AVG(call_duration) FROM phone_call WHERE call_duration IS NOT NULL;
```
判定并记录:`call_duration` 单位(秒/毫秒——对照已知总量约 73 小时/3,190 条 ⇒ 均值≈82 秒);`call_status`/`incoming_call` 取值分布(后续 Batch B 接通率可能用);`start_call_time` 是否毫秒 epoch。
- [ ] **Step 4: 核实③customer 归属字段填充率**
```sql
SELECT COUNT(*) AS total,
SUM(user_id IS NOT NULL) AS uid_fill,
SUM(owner_account_id IS NOT NULL) AS owner_fill,
SUM(delete_flag=1) AS deleted
FROM customer;
SELECT channel, COUNT(*), SUM(owner_account_id IS NOT NULL) FROM customer GROUP BY channel;
```
记录:owner_fill 比例(决定生命周期归属覆盖面);`user_id` 列与 `owner_account_id→account_map` 两条路径是否一致(抽 5 行比对)。默认走 owner_account_id 路径。
- [ ] **Step 5: 核实④群占比(量化混合口径偏差)**
```sql
SELECT channel, peer_kind, COUNT(*) AS rows_cnt,
SUM(send_cnt+recv_cnt) AS msgs, SUM(cust_threads) AS threads
FROM msg_daily_stats GROUP BY channel, peer_kind;
```
记录 friend vs room 的 threads 占比——这就是现有看板首响/回复率被群污染的程度(写进核实文档,Batch C 上线时口径说明引用)。
- [ ] **Step 6: 核实⑤call_followup join 键(voice_call 客户号能否对上消息 talker)**
```sql
SELECT COUNT(*) FROM phone_call p
WHERE p.channel='voice_call' AND p.call_number IS NOT NULL
AND EXISTS(SELECT 1 FROM message m WHERE m.talker_id = p.call_number
AND (m.room_id IS NULL OR m.room_id=''));
```
记录命中率。低于 50% 时:call_followup_count 一期标记低置信(列照建,口径注明)。
- [ ] **Step 7: 选定 3-5 名回测员工**
```sql
-- 消息量 top(高负载)
SELECT s.user_id, s.name, SUM(m.msg_sent+m.msg_recv) AS msgs
FROM stat_daily_staff s JOIN staff st ON s.user_id=st.user_id
JOIN staff st2 ON st2.user_id=s.user_id, stat_daily_staff m -- (示意;实际写法见下)
```
实际用(更直接):
```sql
SELECT user_id, SUM(msg_sent+msg_recv) AS msgs FROM stat_daily_staff GROUP BY user_id ORDER BY msgs DESC LIMIT 5;
SELECT user_id, SUM(msg_sent+msg_recv) AS msgs FROM stat_daily_staff WHERE channel='whatsapp' GROUP BY user_id ORDER BY msgs DESC LIMIT 3;
SELECT user_id, SUM(active_rooms) AS rooms FROM stat_daily_staff GROUP BY user_id ORDER BY rooms DESC LIMIT 3;
SELECT user_id, SUM(msg_sent+msg_recv) FROM stat_daily_staff GROUP BY user_id ORDER BY 2 ASC LIMIT 5; -- 低样本
```
覆盖:微信为主 1 名、WA 为主 1 名、群运营为主 1 名、高负载 1 名、低样本 1 名(可合并)。
- [ ] **Step 8: 写核实文档并提交**
把 Step 2-7 结果写入 `docs/superpowers/plans/2026-09-08-management-batch-a-verification.md`(含每项结论 + 与本计划默认假设的偏差;**偏差影响后续任务实现细节的,在对应任务执行时按核实结论调整并在提交信息注明**)。
```bash
git add docs/superpowers/plans/2026-09-08-management-batch-a-verification.md
git commit -m "docs(management): 批次A Task0核实记录——phone_call/customer/staff/群占比/回测名单"
```
---
### Task 1: 6 张表模型 + make_session fixture + 冒烟测试
**Files:**
- Modify: `yunke-server/tests/conftest.py`(追加 `make_session` fixture)
- Modify: `yunke-server/app/db/models.py`(文件末尾 SensitiveScanState 之后追加 6 个类;`__all__` 按字母序插入 6 个名字)
- Test: `yunke-server/tests/test_management_models.py`
- [ ] **Step 1: conftest 追加存储层测试 fixture**
在 `tests/conftest.py` 末尾追加(import 区已有 `async_sessionmaker/create_async_engine/StaticPool`):
```python
@pytest.fixture
async def make_session() -> AsyncIterator[AsyncSession]:
"""独立内存 SQLite AsyncSession(不挂 API 依赖)——存储层/管理域测试用。
与 make_client 共用 StaticPool 单连接模式;每个测试独享全新 schema。
"""
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as s:
yield s
await engine.dispose()
```
- [ ] **Step 2: 写模型冒烟测试(先失败)**
新建 `tests/test_management_models.py`:
```python
"""管理域 6 张新表的模型冒烟测试(批次A Task1)。
只验证:建表可用、主键/唯一约束生效、默认值正确。业务逻辑在
first_reply_facts / employee_metrics 的测试里。
"""
import datetime as dt
import pytest
import sqlalchemy as sa
from sqlalchemy.exc import IntegrityError
from app.db.models import (
EmployeeCallDaily,
EmployeeMetricDaily,
EmployeeScoreConfig,
EmployeeScoreSnapshot,
FirstReplyFact,
ManagementFollowup,
)
async def test_first_reply_fact_roundtrip(make_session):
s = make_session
s.add(FirstReplyFact(channel="wechat", owner_account_id="wx1", peer_id="p1",
thread_end_ts=1_700_000_000_000,
reply_ts=1_700_000_600_000,
day=20260901, fr_ms=600_000))
await s.commit()
val = (await s.execute(sa.select(FirstReplyFact.fr_ms))).scalar_one()
assert val == 600_000
async def test_first_reply_fact_unique_sample(make_session):
s = make_session
kwargs = dict(channel="wechat", owner_account_id="wx1", peer_id="p1",
thread_end_ts=1, reply_ts=2, day=20260901, fr_ms=1)
s.add(FirstReplyFact(**kwargs))
await s.commit()
s.add(FirstReplyFact(**kwargs))
with pytest.raises(IntegrityError):
await s.commit()
async def test_metric_daily_versioning_coexists(make_session):
s = make_session
for v in ("v1", "v2"):
s.add(EmployeeMetricDaily(day=20260901, channel="wechat",
user_id="u1", metric_version=v))
await s.commit()
n = (await s.execute(
sa.select(sa.func.count()).select_from(EmployeeMetricDaily))).scalar_one()
assert n == 2
async def test_metric_daily_defaults_zero_not_none(make_session):
s = make_session
s.add(EmployeeMetricDaily(day=20260901, channel="wechat", user_id="u1"))
await s.commit()
row = (await s.execute(sa.select(EmployeeMetricDaily))).scalar_one()
assert row.msg_sent == 0 and row.cust_threads == 0 and row.fr_sum_ms == 0
assert row.metric_version == "v1"
assert row.data_complete is False or row.data_complete == 0 # SQLite 0/1
async def test_call_daily_roundtrip(make_session):
s = make_session
s.add(EmployeeCallDaily(day=20260901, user_id="u1", call_count=2,
call_seconds=164, answered_call_count=1,
call_followup_count=1))
await s.commit()
row = (await s.execute(sa.select(EmployeeCallDaily))).scalar_one()
assert row.call_seconds == 164
async def test_score_config_version_unique(make_session):
s = make_session
s.add(EmployeeScoreConfig(version="v1.0", name="一期试算",
config_json={"dimensions": {}}))
await s.commit()
s.add(EmployeeScoreConfig(version="v1.0", name="重复",
config_json={}))
with pytest.raises(IntegrityError):
await s.commit()
async def test_snapshot_batch_user_unique(make_session):
s = make_session
common = dict(snapshot_batch_id="b1", period_type="week",
period_start=20260901, period_end=20260907,
score_config_version="v1.0", metric_version="v1",
data_as_of=20260907)
s.add(EmployeeScoreSnapshot(user_id="u1", **common))
await s.commit()
s.add(EmployeeScoreSnapshot(user_id="u1", **common))
with pytest.raises(IntegrityError):
await s.commit()
async def test_followup_defaults(make_session):
s = make_session
s.add(ManagementFollowup(employee_user_id="u1", source_type="manual"))
await s.commit()
row = (await s.execute(sa.select(ManagementFollowup))).scalar_one()
assert row.status == "open"
assert row.created_at is not None
```
- [ ] **Step 3: 跑测试确认失败**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_management_models.py -v
```
期望:collection 报 `ImportError: cannot import name 'FirstReplyFact'`。
- [ ] **Step 4: models.py 追加 6 个类**
在 `SensitiveScanState` 类之后、`__all__` 之前追加(文件头部已有所需 import:`String/Integer/BigInteger/Boolean/JSON/Text/Index/UniqueConstraint/mapped_column/Mapped/sa/dt/_updated_at`——若缺 `Text` 则补):
```python
# ==========================================================================
# 经营管理后台(2026-09-08 v2 spec,批次A)——fact/宽表/通话小表 + 三张业务表
# ==========================================================================
class FirstReplyFact(Base):
"""首响事实表(管理域)——一行 = 一个被回的 1:1 客户消息串(v2 §3.1)。
跨日精确串口径:按会话**全历史**计算,fr_ms = 员工首条回复 ts − 串末条
客户消息 ts,day = 串末条所在上海自然日。区别于 msg_daily_stats 首响
三列的日切近似(跨夜串整条丢失,v2 §1.1 #1)。只写 1:1(friend)会话
——群串语义不成立。由回填 CLI / 4h 滚动 / 夜间全量重算,**不挂写钩子**
(写钩子零改动)。用途:精确 P50/P90、2h/24h 回复率分子、下钻证据。
"""
__tablename__ = "first_reply_fact"
channel: Mapped[str] = mapped_column(String(16), primary_key=True)
owner_account_id: Mapped[str] = mapped_column(String(128), primary_key=True)
peer_id: Mapped[str] = mapped_column(String(128), primary_key=True)
thread_end_ts: Mapped[int] = mapped_column(BigInteger, primary_key=True)
reply_ts: Mapped[int] = mapped_column(BigInteger, primary_key=True)
# 串末条所在上海自然日 yyyymmdd(冗余物化,窗口/趋势索引)
day: Mapped[int] = mapped_column(Integer, nullable=False)
fr_ms: Mapped[int] = mapped_column(BigInteger, nullable=False)
__table_args__ = (Index("ix_fr_fact_day_channel", "day", "channel"),)
class EmployeeMetricDaily(Base):
"""员工×日管理指标宽表(v2 §3.2)——1:1 口径,管理域专用。
列来源(v2 §1.1 #2/#5):消息计数/负载列自 msg_daily_stats
(peer_kind='friend' 过滤 + account_map 归并);串类三列与首响日值由
first_reply_facts.compute_conversation 同源输出(串末条归属日)——
回复率分子分母同口径。metric_version 只影响派生列(生命周期),
默认 'v1'。account_map 映射不上或不在 staff 的 owner(幽灵员工)
过滤不落表(v2 §1.3 #11)。通话在 employee_call_daily(无渠道维)。
"""
__tablename__ = "employee_metric_daily"
day: Mapped[int] = mapped_column(Integer, primary_key=True)
channel: Mapped[str] = mapped_column(String(16), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
metric_version: Mapped[str] = mapped_column(
String(16), primary_key=True, default="v1", server_default="v1"
)
# 消息计数列(1:1 口径)
msg_sent: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
msg_recv: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
# 负载列(active_rooms 为群负载,唯一计入宽表的群维度)
active_friends: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
active_rooms: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
# 串类列(与 first_reply_fact 同源,按串末条归属日)
cust_threads: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
threads_answered: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
fr_sum_ms: Mapped[int] = mapped_column(
BigInteger, nullable=False, default=0, server_default=sa.text("0")
)
# 首响日值(趋势图用;评分用周期值从 fact 表窗口函数精确计算)
first_reply_p50_ms: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
first_reply_p90_ms: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
# 生命周期 4 列(距今天数口径,当日持有客户状态分布,当前归属)
new_customer_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
cooling_customer_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
dormant_customer_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
reactivated_customer_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
# fact 是否已覆盖当日(宽表生成前置条件;False=串类列可能滞后)
data_complete: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=sa.text("0")
)
computed_at: Mapped[dt.datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=sa.func.now()
)
class EmployeeCallDaily(Base):
"""员工×日通话小表(v2 §1.2 #9)——通话无消息渠道维度,独立于宽表。
phone_call.channel 是通话类型(phone_call/voice_call)非消息渠道,
塞进 (day,channel,user) 宽表会在 channel=all 聚合时双计。接通 =
call_duration > 0;followup = 通话后 24h 内该员工任一账号对该客户
有消息(Task 0 核实 join 键,默认双渠道 talker_id 匹配)。
"""
__tablename__ = "employee_call_daily"
day: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
call_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
call_seconds: Mapped[int] = mapped_column(
BigInteger, nullable=False, default=0, server_default=sa.text("0")
)
answered_call_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
call_followup_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
computed_at: Mapped[dt.datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=sa.func.now()
)
class EmployeeScoreConfig(Base):
"""评分配置版本(v1 §10.4/v2 §3.3)——config_json 存维度/指标/权重/
方向/阈值/样本门槛;激活前须过权重总和与阈值校验(批次B scoring 实现)。
MVP 无可视化编辑器,受控 CLI 种子维护。"""
__tablename__ = "employee_score_config"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
version: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(128), nullable=False)
# draft | active | retired
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="draft", server_default="draft"
)
effective_from: Mapped[int | None] = mapped_column(Integer, nullable=True)
effective_to: Mapped[int | None] = mapped_column(Integer, nullable=True)
config_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=False)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
approved_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[dt.datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=sa.func.now()
)
activated_at: Mapped[dt.datetime | None] = mapped_column(sa.DateTime, nullable=True)
class EmployeeScoreSnapshot(Base):
"""周/月考核快照(v1 §8/v2 §3.3)——不可变:冻结后业务层禁改评分字段;
重算走 supersedes_id 替代链。同批次×员工唯一。"""
__tablename__ = "employee_score_snapshot"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
snapshot_batch_id: Mapped[str] = mapped_column(String(64), nullable=False)
# week | month | custom
period_type: Mapped[str] = mapped_column(String(8), nullable=False)
period_start: Mapped[int] = mapped_column(Integer, nullable=False)
period_end: Mapped[int] = mapped_column(Integer, nullable=False)
user_id: Mapped[str] = mapped_column(String(64), nullable=False)
score_config_version: Mapped[str] = mapped_column(String(32), nullable=False)
metric_version: Mapped[str] = mapped_column(String(16), nullable=False)
total_score: Mapped[int | None] = mapped_column(Integer, nullable=True)
grade: Mapped[str | None] = mapped_column(String(2), nullable=True)
# scored | insufficient_sample | incomplete_data
score_status: Mapped[str] = mapped_column(
String(24), nullable=False, default="scored", server_default="scored"
)
dimension_scores_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
metric_details_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
data_as_of: Mapped[int] = mapped_column(Integer, nullable=False)
generated_at: Mapped[dt.datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=sa.func.now()
)
frozen_at: Mapped[dt.datetime | None] = mapped_column(sa.DateTime, nullable=True)
frozen_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
supersedes_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
__table_args__ = (
sa.UniqueConstraint("snapshot_batch_id", "user_id",
name="uq_score_snapshot_batch_user"),
Index("ix_score_snapshot_period", "period_type", "period_start"),
)
class ManagementFollowup(Base):
"""管理辅导闭环(v1 §7.3/v2 §3.3)——来源指标→问题→管理动作→结果。"""
__tablename__ = "management_followup"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
employee_user_id: Mapped[str] = mapped_column(String(64), nullable=False)
# metric | anomaly | review | manual
source_type: Mapped[str] = mapped_column(String(16), nullable=False)
source_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
source_period_start: Mapped[int | None] = mapped_column(Integer, nullable=True)
source_period_end: Mapped[int | None] = mapped_column(Integer, nullable=True)
source_value_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
reason: Mapped[str | None] = mapped_column(sa.Text, nullable=True)
action_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
owner_manager_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# open | in_progress | resolved | cancelled
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="open", server_default="open"
)
due_at: Mapped[dt.datetime | None] = mapped_column(sa.DateTime, nullable=True)
result: Mapped[str | None] = mapped_column(sa.Text, nullable=True)
employee_feedback: Mapped[str | None] = mapped_column(sa.Text, nullable=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[dt.datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=sa.func.now()
)
updated_at: Mapped[dt.datetime] = _updated_at()
closed_at: Mapped[dt.datetime | None] = mapped_column(sa.DateTime, nullable=True)
__table_args__ = (
Index("ix_followup_emp_status_due", "employee_user_id", "status", "due_at"),
Index("ix_followup_owner_status", "owner_manager_id", "status"),
)
```
`__all__` 按字母序插入:`"EmployeeCallDaily"`, `"EmployeeMetricDaily"`, `"EmployeeScoreConfig"`, `"EmployeeScoreSnapshot"`, `"FirstReplyFact"`, `"ManagementFollowup"`。
- [ ] **Step 5: 跑测试确认通过**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_management_models.py -v
```
期望:8 passed。
- [ ] **Step 6: 全量回归**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q
```
期望:全绿(现有 ~575 + 新 8)。
- [ ] **Step 7: 提交**
```bash
git add yunke-server/app/db/models.py yunke-server/tests/conftest.py yunke-server/tests/test_management_models.py
git commit -m "feat(management): 批次A六表模型——fact/宽表/通话小表+评分配置/快照/辅导"
```
---
### Task 2: Alembic migration
**Files:**
- Create: `yunke-server/migrations/versions/b7c8d9e0f1a2_management_phase1_tables.py`
- [ ] **Step 1: 确认当前 head**
```bash
cd yunke-server && .venv/Scripts/python.exe -m alembic heads
```
期望 `e6f7a8b9c0d1`(若不同,把迁移文件的 `down_revision` 改成实际 head)。
- [ ] **Step 2: 写迁移文件**
照 `f0a1b2c3d4e5_analytics_module_tables.py` 的幂等守卫模式(`sa.inspect` 先查后建;**不在 migration 里碰 message/msg_daily_stats 数据**):
```python
"""经营管理后台批次A:6 张新表(fact/宽表/通话小表/评分配置/快照/辅导)
Revision ID: b7c8d9e0f1a2
Revises: e6f7a8b9c0d1
Create Date: 2026-09-08
spec: docs/superpowers/plans/2026-09-08-yunke-management-console-implementation-v2.md §3。
幂等:sa.inspect 先查,存在即跳过;SQLite/MySQL 通用。回填走 CLI
(first-reply-facts / employee-metrics),migration 不扫数据。
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "b7c8d9e0f1a2"
down_revision: str | Sequence[str] | None = "e6f7a8b9c0d1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_TABLES = [
("first_reply_fact", lambda: op.create_table(
"first_reply_fact",
sa.Column("channel", sa.String(length=16), primary_key=True),
sa.Column("owner_account_id", sa.String(length=128), primary_key=True),
sa.Column("peer_id", sa.String(length=128), primary_key=True),
sa.Column("thread_end_ts", sa.BigInteger(), primary_key=True),
sa.Column("reply_ts", sa.BigInteger(), primary_key=True),
sa.Column("day", sa.Integer(), nullable=False),
sa.Column("fr_ms", sa.BigInteger(), nullable=False),
)),
("employee_metric_daily", lambda: op.create_table(
"employee_metric_daily",
sa.Column("day", sa.Integer(), primary_key=True),
sa.Column("channel", sa.String(length=16), primary_key=True),
sa.Column("user_id", sa.String(length=64), primary_key=True),
sa.Column("metric_version", sa.String(length=16), primary_key=True,
server_default="v1"),
sa.Column("msg_sent", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("msg_recv", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("active_friends", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("active_rooms", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("cust_threads", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("threads_answered", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("fr_sum_ms", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
sa.Column("first_reply_p50_ms", sa.BigInteger(), nullable=True),
sa.Column("first_reply_p90_ms", sa.BigInteger(), nullable=True),
sa.Column("new_customer_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("cooling_customer_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("dormant_customer_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("reactivated_customer_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("data_complete", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("computed_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)),
("employee_call_daily", lambda: op.create_table(
"employee_call_daily",
sa.Column("day", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.String(length=64), primary_key=True),
sa.Column("call_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("call_seconds", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
sa.Column("answered_call_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("call_followup_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("computed_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)),
("employee_score_config", lambda: op.create_table(
"employee_score_config",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("version", sa.String(length=32), nullable=False, unique=True),
sa.Column("name", sa.String(length=128), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False, server_default="draft"),
sa.Column("effective_from", sa.Integer(), nullable=True),
sa.Column("effective_to", sa.Integer(), nullable=True),
sa.Column("config_json", sa.JSON(), nullable=False),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("approved_by", sa.String(length=64), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("activated_at", sa.DateTime(), nullable=True),
)),
("employee_score_snapshot", lambda: op.create_table(
"employee_score_snapshot",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("snapshot_batch_id", sa.String(length=64), nullable=False),
sa.Column("period_type", sa.String(length=8), nullable=False),
sa.Column("period_start", sa.Integer(), nullable=False),
sa.Column("period_end", sa.Integer(), nullable=False),
sa.Column("user_id", sa.String(length=64), nullable=False),
sa.Column("score_config_version", sa.String(length=32), nullable=False),
sa.Column("metric_version", sa.String(length=16), nullable=False),
sa.Column("total_score", sa.Integer(), nullable=True),
sa.Column("grade", sa.String(length=2), nullable=True),
sa.Column("score_status", sa.String(length=24), nullable=False, server_default="scored"),
sa.Column("dimension_scores_json", sa.JSON(), nullable=True),
sa.Column("metric_details_json", sa.JSON(), nullable=True),
sa.Column("data_as_of", sa.Integer(), nullable=False),
sa.Column("generated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("frozen_at", sa.DateTime(), nullable=True),
sa.Column("frozen_by", sa.String(length=64), nullable=True),
sa.Column("supersedes_id", sa.Integer(), nullable=True),
sa.UniqueConstraint("snapshot_batch_id", "user_id",
name="uq_score_snapshot_batch_user"),
)),
("management_followup", lambda: op.create_table(
"management_followup",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("employee_user_id", sa.String(length=64), nullable=False),
sa.Column("source_type", sa.String(length=16), nullable=False),
sa.Column("source_code", sa.String(length=64), nullable=True),
sa.Column("source_period_start", sa.Integer(), nullable=True),
sa.Column("source_period_end", sa.Integer(), nullable=True),
sa.Column("source_value_json", sa.JSON(), nullable=True),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("action_type", sa.String(length=32), nullable=True),
sa.Column("owner_manager_id", sa.String(length=64), nullable=True),
sa.Column("status", sa.String(length=16), nullable=False, server_default="open"),
sa.Column("due_at", sa.DateTime(), nullable=True),
sa.Column("result", sa.Text(), nullable=True),
sa.Column("employee_feedback", sa.Text(), nullable=True),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("closed_at", sa.DateTime(), nullable=True),
)),
]
_INDEXES = [
("first_reply_fact", "ix_fr_fact_day_channel", ["day", "channel"]),
("employee_score_snapshot", "ix_score_snapshot_period", ["period_type", "period_start"]),
("management_followup", "ix_followup_emp_status_due",
["employee_user_id", "status", "due_at"]),
("management_followup", "ix_followup_owner_status", ["owner_manager_id", "status"]),
]
def _has_index(bind, table: str, index: str) -> bool:
insp = sa.inspect(bind)
if not insp.has_table(table):
return False
return index in {ix["name"] for ix in insp.get_indexes(table)}
def upgrade() -> None:
bind = op.get_bind()
insp = sa.inspect(bind)
for name, create in _TABLES:
if not insp.has_table(name):
create()
# 索引守卫独立于建表(MySQL DDL 逐句自动提交;建表成功但索引失败的
# 中断重跑要能补上索引)
for table, index, cols in _INDEXES:
if not _has_index(bind, table, index):
op.create_index(index, table, cols)
def downgrade() -> None:
bind = op.get_bind()
insp = sa.inspect(bind)
for table in ("management_followup", "employee_score_snapshot",
"employee_score_config", "employee_call_daily",
"employee_metric_daily", "first_reply_fact"):
if insp.has_table(table):
op.drop_table(table)
```
注意:`updated_at` 无 alembic 服务端 onupdate(模型 `_updated_at()` 是 Python 侧 onupdate,迁移里静态 now() 即可——与 f0a1b2c3d4e5 同做法)。
- [ ] **Step 3: 本地库升级实测**
```bash
cd yunke-server && .venv/Scripts/python.exe -m alembic upgrade head
```
期望:输出包含 `Running upgrade ... -> b7c8d9e0f1a2`。再跑一次(幂等)应无操作。
- [ ] **Step 4: 验证表存在 + downgrade 冒烟**
```bash
docker exec -it yunke-mysql-1 mysql -uyunke -pyunke yunke -e "SHOW TABLES LIKE '%employee%'; SHOW TABLES LIKE '%first_reply%'; SHOW TABLES LIKE '%management%';"
cd yunke-server && .venv/Scripts/python.exe -m alembic downgrade -1 && .venv/Scripts/python.exe -m alembic upgrade head
```
期望:6 张表存在;downgrade 后消失、再 upgrade 恢复。
- [ ] **Step 5: 全量测试 + 提交**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q
git add yunke-server/migrations/versions/b7c8d9e0f1a2_management_phase1_tables.py
git commit -m "feat(management): 批次A建表迁移——六表幂等守卫/索引独立补齐/downgrade只删新表"
```
---
### Task 3: first_reply_facts 跨日精确算法 + 落库
**Files:**
- Create: `yunke-server/app/storage/first_reply_facts.py`
- Test: `yunke-server/tests/test_first_reply_facts.py`
- [ ] **Step 1: 写判别性测试(先失败)**
新建 `tests/test_first_reply_facts.py`:
```python
"""first_reply_fact 跨日精确串算法判别测试(批次A Task3)。
四个核心判据(v2 §1.1 #1/#5、§9):
1. 跨夜串必须产生样本(日切口径丢失的场景);
2. 多日未回串只计一次、归属末条日(日切会按天重复计——分母膨胀);
3. 同源:threads_answered ≡ fact 行数(按归属日);
4. is_send=None 不参与串判定(与 _thread_stats 一致)。
"""
import datetime as dt
from zoneinfo import ZoneInfo
import sqlalchemy as sa
from app.db.models import FirstReplyFact, Message, MsgDailyStats
from app.storage.first_reply_facts import (
all_friend_pairs,
compute_conversation,
recompute_pairs,
)
_SH = ZoneInfo("Asia/Shanghai")
def _ts(*args) -> int:
return int(dt.datetime(*args, tzinfo=_SH).timestamp() * 1000)
# ---------------------------- 纯函数 ----------------------------
def test_overnight_thread_recovered():
"""客户 23:00 发→员工次日 9:00 回:fr_ms≈10h,归属 D1(v2 §1.1 #1)。"""
facts, agg = compute_conversation(
[(_ts(2026, 9, 1, 23, 0), False), (_ts(2026, 9, 2, 9, 0), True)]
)
assert len(facts) == 1
assert facts[0].fr_ms == 10 * 3600 * 1000
assert facts[0].day == 20260901
assert agg[20260901].answered == 1
def test_multiday_open_thread_counts_once_at_end_day():
"""客户消息跨 D1/D2 无回复:1 个串归属 D2;日切口径会 D1、D2 各计 1。"""
facts, agg = compute_conversation(
[(_ts(2026, 9, 1, 10, 0), False), (_ts(2026, 9, 1, 20, 0), False),
(_ts(2026, 9, 2, 9, 0), False)]
)
assert facts == []
assert 20260901 not in agg
assert agg[20260902].threads == 1 and agg[20260902].answered == 0
def test_reply_closes_thread_and_opens_new():
"""员工回复后客户再发=新串;串内多条客户消息取末条为 end。"""
facts, agg = compute_conversation(
[(_ts(2026, 9, 1, 10, 0), False), (_ts(2026, 9, 1, 10, 5), False),
(_ts(2026, 9, 1, 10, 30), True), # 回复1:fr=30-10:05=25min
(_ts(2026, 9, 1, 11, 0), False), (_ts(2026, 9, 1, 12, 0), True)]
)
assert len(facts) == 2
assert facts[0].fr_ms == 25 * 60 * 1000
assert agg[20260901].threads == 2 and agg[20260901].answered == 2
def test_unknown_direction_ignored():
facts, agg = compute_conversation(
[(_ts(2026, 9, 1, 10, 0), False), (_ts(2026, 9, 1, 10, 10), None),
(_ts(2026, 9, 1, 10, 20), True)]
)
assert len(facts) == 1 # None 不打断串:仍视为对 10:00 串的回复
def test_staff_message_outside_thread_is_noop():
facts, agg = compute_conversation(
[(_ts(2026, 9, 1, 9, 0), True), (_ts(2026, 9, 1, 10, 0), True)]
)
assert facts == [] and agg == {}
def test_unsorted_rows_raise_or_documented():
"""调用方契约:rows 须升序。乱序输入行为未定义——文档断言 + 防御排序
在 load_conversation 里做(与 summary.py 惯例一致)。"""
# 纯函数不做防御(性能);落库路径负责排序。此处仅锁定契约注释。
import app.storage.first_reply_facts as m
assert "升序" in m.compute_conversation.__doc__
# ---------------------------- 落库 ----------------------------
async def _seed_conversation(session, *, channel="wechat", owner="wx1",
peer="p1", rows):
for i, (ts, is_send) in enumerate(rows):
session.add(Message(msg_id=f"{peer}-{i}", channel=channel,
owner_account_id=owner, talker_id=peer,
timestamp=ts, is_send=is_send, type="text",
text="x"))
session.add(MsgDailyStats(channel=channel, owner_account_id=owner,
peer_kind="friend", peer_id=peer,
day=20260901, send_cnt=1, recv_cnt=1))
await session.commit()
async def test_recompute_pairs_persists_and_idempotent(make_session):
s = make_session
await _seed_conversation(
s, rows=[(_ts(2026, 9, 1, 23, 0), False), (_ts(2026, 9, 2, 9, 0), True)])
n = await recompute_pairs(s, [("wechat", "wx1", "p1")])
await s.commit()
assert n == 1
rows = (await s.execute(sa.select(FirstReplyFact))).scalars().all()
assert len(rows) == 1 and rows[0].fr_ms == 10 * 3600 * 1000
# 幂等:重跑不重复
await recompute_pairs(s, [("wechat", "wx1", "p1")])
await s.commit()
assert len((await s.execute(sa.select(FirstReplyFact))).scalars().all()) == 1
async def test_recompute_pairs_replaces_stale(make_session):
"""会话追加消息后重算,旧样本被替换(delete+insert 语义)。"""
s = make_session
await _seed_conversation(
s, rows=[(_ts(2026, 9, 1, 10, 0), False), (_ts(2026, 9, 1, 11, 0), True)])
await recompute_pairs(s, [("wechat", "wx1", "p1")])
await s.commit()
s.add(Message(msg_id="p1-new", channel="wechat", owner_account_id="wx1",
talker_id="p1", timestamp=_ts(2026, 9, 1, 12, 0),
is_send=False, type="text", text="y"))
await s.commit()
await recompute_pairs(s, [("wechat", "wx1", "p1")])
await s.commit()
rows = (await s.execute(sa.select(FirstReplyFact))).scalars().all()
assert len(rows) == 1 # 新串未回,只剩旧样本
async def test_all_friend_pairs_only_friend(make_session):
s = make_session
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="room", peer_id="r1", day=20260901))
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="p1", day=20260901))
await s.commit()
assert await all_friend_pairs(s) == [("wechat", "wx1", "p1")]
```
- [ ] **Step 2: 跑测试确认失败**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_first_reply_facts.py -v
```
期望:`ModuleNotFoundError: app.storage.first_reply_facts`。
- [ ] **Step 3: 实现 first_reply_facts.py**
```python
"""first_reply_fact 维护层(管理域,批次A)——跨日精确串算法。
区别于 app.storage.summary._thread_stats 的日切近似(v2 §1.1 #1):
按会话**全历史**计算,跨夜串(客户 23:00 → 员工次日 9:00 回)不再丢失。
**不挂写钩子**(v2 §3.1):由回填 CLI / 4h 滚动 / 夜间全量重算,写钩子
零改动。只处理 1:1(friend)会话——群串语义不成立。
compute_conversation 同时输出每日串聚合(threads/answered/fr_sum_ms,
按串末条归属日)——employee_metric_daily 的串类列与 fact 行**同源**
(回复率分子分母一致,v2 §1.1 #5)。
"""
from __future__ import annotations
import logging
from collections import namedtuple
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import FirstReplyFact, Message, MsgDailyStats
from app.storage.summary import day_key
logger = logging.getLogger(__name__)
# 一个被回串的首响样本;day = 串末条客户消息所在上海自然日
FactSample = namedtuple("FactSample", "thread_end_ts reply_ts fr_ms day")
# 某会话某日的串聚合(分子分母同源)
DayAgg = namedtuple("DayAgg", "threads answered fr_sum_ms")
class _DayAcc:
__slots__ = ("threads", "answered", "fr_sum_ms")
def __init__(self) -> None:
self.threads = 0
self.answered = 0
self.fr_sum_ms = 0
def compute_conversation(rows: list) -> tuple[list[FactSample], dict[int, DayAgg]]:
"""跨日精确串算法。rows = [(ts, is_send)] 该会话全历史,**须按 ts 升序**。
- 串 = 客户连续消息(is_send=False)不被员工消息(is_send=True)打断的
段;is_send=None 跳过(不打断也不起串)——与 _thread_stats 判定一致。
- 每个被回串一个样本:fr_ms = 员工首条回复 ts − 串**末条**客户消息 ts,
归属 day = 串末条所在上海自然日(跨夜串不再丢失)。
- 会话末尾仍未闭合的串:计入当日 threads(回复率分母),不产生样本。
"""
facts: list[FactSample] = []
acc: dict[int, _DayAcc] = {}
in_thread = False
end_ts: int | None = None
for ts, is_send in rows:
if is_send is False:
in_thread = True
end_ts = int(ts)
elif is_send is True:
if in_thread and end_ts is not None:
fr = int(ts) - end_ts
d = day_key(end_ts)
facts.append(FactSample(end_ts, int(ts), fr, d))
a = acc.setdefault(d, _DayAcc())
a.threads += 1
a.answered += 1
a.fr_sum_ms += fr
in_thread = False
end_ts = None
# is_send=None:跳过
if in_thread and end_ts is not None: # 会话末尾未闭合串
a = acc.setdefault(day_key(end_ts), _DayAcc())
a.threads += 1
return facts, {d: DayAgg(a.threads, a.answered, a.fr_sum_ms)
for d, a in acc.items()}
async def load_conversation(
session: AsyncSession, channel: str, owner: str, peer: str
) -> list:
"""拉取该 1:1 会话全历史 (timestamp, is_send),Python 侧排序(串算法
需严格时序——与 summary.recompute_days 同惯例,避免依赖 SQL 排序)。
公有命名:employee_metrics 宽表生成复用同一加载口径(同源原则)。"""
rows = (
await session.execute(
sa.select(Message.timestamp, Message.is_send).where(
Message.channel == channel,
Message.owner_account_id == owner,
Message.talker_id == peer,
sa.or_(Message.room_id.is_(None), Message.room_id == ""),
Message.timestamp.is_not(None),
)
)
).all()
return sorted(rows, key=lambda r: r[0])
async def recompute_pairs(
session: AsyncSession, pairs: list[tuple[str, str, str]]
) -> int:
"""重算给定 friend 会话的 fact 行:按会话 delete+insert,幂等。
pairs = [(channel, owner_account_id, peer_id)]。返回会话数。
"""
for channel, owner, peer in sorted(pairs):
rows = await load_conversation(session, channel, owner, peer)
facts, _agg = compute_conversation(rows)
await session.execute(
sa.delete(FirstReplyFact).where(
FirstReplyFact.channel == channel,
FirstReplyFact.owner_account_id == owner,
FirstReplyFact.peer_id == peer,
)
)
for f in facts:
session.add(
FirstReplyFact(
channel=channel,
owner_account_id=owner,
peer_id=peer,
thread_end_ts=f.thread_end_ts,
reply_ts=f.reply_ts,
day=f.day,
fr_ms=f.fr_ms,
)
)
return len(pairs)
async def all_friend_pairs(session: AsyncSession) -> list[tuple[str, str, str]]:
"""全部 1:1 会话(friend 行 distinct)——全量回填用。"""
rows = (
await session.execute(
sa.select(
MsgDailyStats.channel,
MsgDailyStats.owner_account_id,
MsgDailyStats.peer_id,
).where(MsgDailyStats.peer_kind == "friend")
)
).all()
return sorted({(r[0], r[1], r[2]) for r in rows})
async def pairs_active_in_days(
session: AsyncSession, days
) -> list[tuple[str, str, str]]:
"""窗口内有消息的 friend 会话(4h 滚动重算用;批次F 接调度)。"""
rows = (
await session.execute(
sa.select(
MsgDailyStats.channel,
MsgDailyStats.owner_account_id,
MsgDailyStats.peer_id,
).where(
MsgDailyStats.peer_kind == "friend",
MsgDailyStats.day.in_(list(days)),
)
)
).all()
return sorted({(r[0], r[1], r[2]) for r in rows})
async def recompute_all(session: AsyncSession) -> int:
"""全量重建(回填/夜间自愈,幂等)。"""
return await recompute_pairs(session, await all_friend_pairs(session))
```
- [ ] **Step 4: 跑测试确认通过 + 全量回归**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_first_reply_facts.py -v
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q
```
期望:10 passed;全量绿。
- [ ] **Step 5: 提交**
```bash
git add yunke-server/app/storage/first_reply_facts.py yunke-server/tests/test_first_reply_facts.py
git commit -m "feat(management): 首响事实表跨日精确算法——跨夜串不丢/串末条归属日/同源日聚合"
```
---
### Task 4: 生命周期判定纯函数
**Files:**
- Create: `yunke-server/app/analytics/lifecycle.py`
- Test: `yunke-server/tests/test_lifecycle.py`
- [ ] **Step 1: 写测试(先失败)**
新建 `tests/test_lifecycle.py`:
```python
"""客户生命周期判定纯函数测试(批次A Task4)。
口径(v2 §1.1 #3 + v1 §21 默认,已用户确认):新客 7 天、沉睡 30 天、
历史≥5 条才算沉睡(对齐 churn real min_msgs=5)。analytics 现有页
(churn/客户资产)暂不切换(口径差异 v2 §5 标注),不动 575 既有测试。
"""
from app.analytics.lifecycle import NEW_DAYS, DORMANT_DAYS, MIN_HISTORY_MSGS, classify, day_add
def test_constants_locked_to_confirmed_defaults():
assert NEW_DAYS == 7 and DORMANT_DAYS == 30 and MIN_HISTORY_MSGS == 5
def test_new_customer():
# 首次沟通在 7 天窗口内
assert classify(first_day=20260901, last_day=20260903, prev_day=None,
as_of=20260905, hist_msgs=2) == "new"
def test_reactivated_requires_gap_over_30d():
# 当日回流且前次间隔>30 天
assert classify(first_day=20260101, last_day=20260905, prev_day=20260801,
as_of=20260905, hist_msgs=50) == "reactivated"
# 间隔仅 10 天 → 只是 active
assert classify(first_day=20260101, last_day=20260905, prev_day=20260827,
as_of=20260905, hist_msgs=50) == "active"
def test_active_cooling_dormant_boundaries():
f, m = 20260101, 100
assert classify(f, 20260830, None, 20260905, m) == "active" # 6 天
assert classify(f, 20260829, None, 20260905, m) == "cooling" # 7 天
assert classify(f, 20260807, None, 20260905, m) == "cooling" # 29 天
assert classify(f, 20260806, None, 20260905, m) == "dormant" # 30 天
def test_dormant_requires_history():
# ≥30 天无互动但历史<5 条:不计入任何状态(None)
assert classify(20260101, 20260806, None, 20260905, 4) is None
assert classify(20260101, 20260806, None, 20260905, 5) == "dormant"
def test_no_interaction_returns_none():
assert classify(None, None, None, 20260905, 0) is None
def test_day_add():
assert day_add(20260831, 1) == 20260901
assert day_add(20260901, -7) == 20260825
```
- [ ] **Step 2: 跑测试确认失败**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_lifecycle.py -v
```
期望:`ModuleNotFoundError: app.analytics.lifecycle`。
- [ ] **Step 3: 实现 lifecycle.py**
```python
"""客户生命周期判定(管理域,批次A)——距今天数口径(v2 §1.1 #3 决议)。
阈值取已确认默认(v1 §21,用户拍板):新客 7 天、沉睡 30 天;历史互动
门槛 MIN_HISTORY_MSGS=5 对齐 analytics churn real 的 min_msgs=5。
analytics 现有页(churn/客户资产)**暂不切换**到此模块——两套数字的
差异在 metric-definitions 标注(v2 §5);后续统一时再接,避免动 575
个既有测试。
"""
from __future__ import annotations
import datetime as dt
NEW_DAYS = 7
DORMANT_DAYS = 30
MIN_HISTORY_MSGS = 5
STATES = ("new", "active", "cooling", "dormant", "reactivated")
def _to_date(day: int) -> dt.date:
return dt.date(day // 10000, (day // 100) % 100, day % 100)
def day_add(day: int, delta_days: int) -> int:
"""yyyymmdd 键加减天数。"""
d = _to_date(day) + dt.timedelta(days=delta_days)
return d.year * 10000 + d.month * 100 + d.day
def days_between(day_a: int, day_b: int) -> int:
"""b − a 的天数(输入 yyyymmdd 键)。"""
return (_to_date(day_b) - _to_date(day_a)).days
def classify(
first_day: int | None,
last_day: int | None,
prev_day: int | None,
as_of: int,
hist_msgs: int,
) -> str | None:
"""as_of 日的客户状态;输入均为互动日键(yyyymmdd,上海自然日)。
- first_day: 该客户全历史首次互动日;last_day: ≤as_of 的最近互动日;
prev_day: last_day 之前的一次互动日(无则 None);hist_msgs: 历史总消息数。
- 返回 None = 未激活(first/last 缺)或历史不足的长期静默(不计入任何列)。
判定优先级:new → reactivated → active → cooling → dormant。
"""
if first_day is None or last_day is None:
return None
if first_day >= day_add(as_of, -(NEW_DAYS - 1)):
return "new"
if (
last_day == as_of
and prev_day is not None
and days_between(prev_day, last_day) > DORMANT_DAYS
):
return "reactivated"
gap = days_between(last_day, as_of)
if gap < NEW_DAYS:
return "active"
if gap < DORMANT_DAYS:
return "cooling"
if hist_msgs >= MIN_HISTORY_MSGS:
return "dormant"
return None
```
- [ ] **Step 4: 跑测试通过 + 提交**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_lifecycle.py -v
git add yunke-server/app/analytics/lifecycle.py yunke-server/tests/test_lifecycle.py
git commit -m "feat(management): 生命周期判定纯函数——距今天数口径/7天新客/30天沉睡/历史门槛5条"
```
---
### Task 5: employee_call_daily 通话小表生成
**Files:**
- Create: `yunke-server/app/storage/employee_metrics.py`(本任务先写通话部分)
- Test: `yunke-server/tests/test_employee_metrics.py`
- [ ] **Step 1: 写测试(先失败)**
新建 `tests/test_employee_metrics.py`:
```python
"""employee_call_daily / employee_metric_daily 生成判别测试(批次A Task5/6)。
判据(v2 §1.1 #2/#5、§1.2 #9、§1.3 #11):
- 通话无渠道维,独立小表;接通=duration>0;followup=24h 内任一账号有消息;
- 幽灵员工(owner 映射不上/不在 staff)过滤;
- 宽表消息计数 friend-only;串类与 fact 同源。
"""
import datetime as dt
from zoneinfo import ZoneInfo
import sqlalchemy as sa
from app.db.models import (
EmployeeCallDaily,
EmployeeMetricDaily,
FirstReplyFact,
Message,
MsgDailyStats,
PhoneCall,
Staff,
WechatAccount,
)
from app.storage.employee_metrics import recompute_call_days
_SH = ZoneInfo("Asia/Shanghai")
def _ts(*args) -> int:
return int(dt.datetime(*args, tzinfo=_SH).timestamp() * 1000)
async def _seed_org(session):
session.add(Staff(user_id="u1", name="张三", is_on_job=True))
session.add(WechatAccount(wx_id="wx1", user_id="u1"))
await session.commit()
async def test_call_daily_aggregates(make_session):
s = make_session
await _seed_org(s)
s.add(PhoneCall(call_id="c1", channel="voice_call", user_id="u1",
call_number="p1", start_call_time=_ts(2026, 9, 1, 10, 0),
call_duration=120))
s.add(PhoneCall(call_id="c2", channel="voice_call", user_id="u1",
call_number="p2", start_call_time=_ts(2026, 9, 1, 15, 0),
call_duration=0)) # 未接通
# c1 后 2h 员工有跟进消息(多账号语义:u1 只有 wx1)
s.add(Message(msg_id="f1", channel="wechat", owner_account_id="wx1",
talker_id="p1", timestamp=_ts(2026, 9, 1, 12, 0),
is_send=True, type="text", text="跟进"))
await s.commit()
n = await recompute_call_days(s, [20260901])
await s.commit()
assert n == 1
row = (await s.execute(sa.select(EmployeeCallDaily))).scalar_one()
assert row.call_count == 2
assert row.call_seconds == 120 # duration 单位按 Task0 核实,默认秒
assert row.answered_call_count == 1
assert row.call_followup_count == 1
async def test_call_daily_ignores_unknown_staff(make_session):
s = make_session
await _seed_org(s)
s.add(PhoneCall(call_id="c9", channel="phone_call", user_id="ghost",
call_number="p9", start_call_time=_ts(2026, 9, 1, 10, 0),
call_duration=60))
await s.commit()
await recompute_call_days(s, [20260901])
await s.commit()
assert (await s.execute(
sa.select(sa.func.count()).select_from(EmployeeCallDaily))).scalar_one() == 0
```
- [ ] **Step 2: 跑测试确认失败**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_employee_metrics.py -v
```
期望:`ImportError: recompute_call_days`。
- [ ] **Step 3: 实现通话小表生成(employee_metrics.py 第一部分)**
```python
"""employee_call_daily / employee_metric_daily 维护层(管理域,批次A)。
数据流(v2 §4):
msg_daily_stats(friend) + first_reply_facts 同源扫描 + customer 生命周期
→ employee_metric_daily (day, channel, user, metric_version)
phone_call(按 user_id+start_call_time 日聚合)
→ employee_call_daily (day, user)——通话无渠道维(v2 §1.2 #9)
幽灵员工过滤(v2 §1.3 #11):owner 映射不上或不在 staff 表的不落表。
重算语义:按 day delete+insert,幂等;夜间全量 + (批次F)4h 滚动。
"""
from __future__ import annotations
import logging
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import (
EmployeeCallDaily,
Message,
PhoneCall,
Staff,
)
from app.storage.staff_summary import account_map
from app.storage.summary import day_bounds_ms
logger = logging.getLogger(__name__)
async def known_user_ids(session: AsyncSession) -> set[str]:
"""员工主数据 user_id 集合(staff 表;Task0 已核实 ≈41 行)。"""
rows = (await session.execute(sa.select(Staff.user_id))).scalars().all()
return {u for u in rows if u}
async def _followup_exists(
session: AsyncSession,
owner_accounts: list[str],
peer: str,
call_ts_ms: int,
window_ms: int = 86_400_000,
) -> bool:
"""通话后 24h 内员工任一账号对该客户有 1:1 消息(双渠道匹配——
voice_call 行无法从列判别微信/WA,Task0 核实后可收窄)。"""
if not owner_accounts or not peer:
return False
n = (
await session.execute(
sa.select(sa.func.count()).select_from(Message).where(
Message.owner_account_id.in_(owner_accounts),
Message.talker_id == peer,
sa.or_(Message.room_id.is_(None), Message.room_id == ""),
Message.timestamp.is_not(None),
Message.timestamp > call_ts_ms,
Message.timestamp <= call_ts_ms + window_ms,
)
)
).scalar_one()
return bool(n)
async def recompute_call_days(session: AsyncSession, days) -> int:
"""重算给定日的通话小表:按 day delete+insert,幂等。返回写入行数。"""
# 员工→全部账号(双渠道),followup 判定用
acct = {ch: await account_map(session, ch) for ch in ("wechat", "whatsapp")}
rev: dict[str, list[str]] = {}
for amap in acct.values():
for owner, uid in amap.items():
rev.setdefault(uid, []).append(owner)
written = 0
for day in sorted(set(days)):
lo, hi = day_bounds_ms(day)
calls = (
await session.execute(
sa.select(
PhoneCall.user_id,
PhoneCall.call_number,
PhoneCall.start_call_time,
PhoneCall.call_duration,
).where(
PhoneCall.start_call_time.is_not(None),
PhoneCall.start_call_time >= lo,
PhoneCall.start_call_time < hi,
)
)
).all()
await session.execute(
sa.delete(EmployeeCallDaily).where(EmployeeCallDaily.day == day)
)
# duration 单位按 Task0 核实结论调整(默认秒;若毫秒在此换算)
agg: dict[str, dict] = {}
for uid, number, ts, dur in calls:
if not uid:
continue
a = agg.setdefault(
uid, {"cnt": 0, "sec": 0, "answered": 0, "followup": 0}
)
a["cnt"] += 1
a["sec"] += int(dur or 0)
if (dur or 0) > 0:
a["answered"] += 1
for uid, a in sorted(agg.items()):
followup = 0
for number, ts, dur in (
(c[1], c[2], c[3])
for c in calls
if c[0] == uid and (c[3] or 0) > 0
):
if await _followup_exists(session, rev.get(uid, []), number, ts):
followup += 1
a["followup"] = followup
session.add(
EmployeeCallDaily(
day=day,
user_id=uid,
call_count=a["cnt"],
call_seconds=a["sec"],
answered_call_count=a["answered"],
call_followup_count=a["followup"],
)
)
written += 1
return written
```
- [ ] **Step 4: 跑测试通过**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_employee_metrics.py -v
```
期望:2 passed。
- [ ] **Step 5: 提交**
```bash
git add yunke-server/app/storage/employee_metrics.py yunke-server/tests/test_employee_metrics.py
git commit -m "feat(management): 通话日聚合小表——无渠道维/接通=duration>0/24h任一账号跟进"
```
---
### Task 6: employee_metric_daily 宽表生成
**Files:**
- Modify: `yunke-server/app/storage/employee_metrics.py`(追加宽表部分)
- Test: `yunke-server/tests/test_employee_metrics.py`(追加)
- [ ] **Step 1: 追加宽表判别测试(先失败)**
在 `tests/test_employee_metrics.py` 追加:
```python
from app.storage.employee_metrics import recompute_metric_days # noqa: E402
async def _seed_msg_rows(s, *, day=20260901, channel="wechat", owner="wx1",
kind="friend", peer="p1", send=3, recv=2):
s.add(MsgDailyStats(channel=channel, owner_account_id=owner, peer_kind=kind,
peer_id=peer, day=day, send_cnt=send, recv_cnt=recv))
await s.commit()
async def test_metric_daily_friend_only_caliber(make_session):
"""口径判别(v2 §1.1 #2 防线):room 行不进消息计数/串列,只进 active_rooms。"""
s = make_session
await _seed_org(s)
await _seed_msg_rows(s, send=3, recv=2, kind="friend", peer="p1")
await _seed_msg_rows(s, send=10, recv=0, kind="room", peer="r1")
# friend 会话给一条消息供串扫描(无消息则无串)
s.add(Message(msg_id="p1-0", channel="wechat", owner_account_id="wx1",
talker_id="p1", timestamp=_ts(2026, 9, 1, 10, 0),
is_send=False, type="text", text="q"))
await s.commit()
await recompute_metric_days(s, [20260901])
await s.commit()
row = (await s.execute(sa.select(EmployeeMetricDaily))).scalar_one()
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 # 未回复串
async def test_metric_daily_ghost_filtered(make_session):
s = make_session
await _seed_org(s)
await _seed_msg_rows(s, owner="wx-ghost", peer="pg")
await recompute_metric_days(s, [20260901])
await s.commit()
assert (await s.execute(
sa.select(sa.func.count()).select_from(EmployeeMetricDaily))).scalar_one() == 0
async def test_metric_daily_same_source_as_fact(make_session):
"""同源判别(v2 §1.1 #5 防线):
1) threads_answered ≡ fact 行数(同归属日);
2) 多日未回串宽表 threads < 日切 cust_threads 之和(分母不再膨胀);
3) 幂等。"""
from app.storage.first_reply_facts import recompute_pairs
s = make_session
await _seed_org(s)
# 客户 D1 10:00、D1 20:00、D2 09:00 三条消息,始终未回 → 1 个跨日串
rows = [(_ts(2026, 9, 1, 10, 0), False), (_ts(2026, 9, 1, 20, 0), False),
(_ts(2026, 9, 2, 9, 0), False)]
for i, (ts, is_send) in enumerate(rows):
s.add(Message(msg_id=f"g{i}", channel="wechat", owner_account_id="wx1",
talker_id="pg", timestamp=ts, is_send=is_send,
type="text", text="x"))
# 日切口径的 msg_daily_stats:D1、D2 各计一个未回串(供对照)
await _seed_msg_rows(s, day=20260901, peer="pg", send=0, recv=2)
await _seed_msg_rows(s, day=20260902, peer="pg", send=0, recv=1)
await s.commit()
await recompute_pairs(s, [("wechat", "wx1", "pg")])
await s.commit()
await recompute_metric_days(s, [20260901, 20260902])
await s.commit()
got = {r.day: r for r in (
await s.execute(sa.select(EmployeeMetricDaily))).scalars().all()}
# 1) fact 行数=0(未回),宽表 threads_answered 也=0 且同源
assert (await s.execute(
sa.select(sa.func.count()).select_from(FirstReplyFact))).scalar_one() == 0
# 2) 跨日串只归属 D2;日切 D1+D2=2,宽表合计=1
assert got[20260901].cust_threads == 0
assert got[20260902].cust_threads == 1
# 3) 幂等
await recompute_metric_days(s, [20260901, 20260902])
await s.commit()
assert len((await s.execute(
sa.select(EmployeeMetricDaily))).scalars().all()) == 2
async def test_metric_daily_lifecycle_counts(make_session):
"""生命周期列:cooling/dormant/reactivated 判定进宽表。"""
s = make_session
await _seed_org(s)
from app.db.models import Customer
# 客户 pc:D1 有互动后静默(历史≥5 条) → as_of=D1+40 为 dormant;
# D41 回流 → 该日 reactivated
s.add(Customer(friend_id="pc", channel="wechat", owner_account_id="wx1",
delete_flag=0))
hist = []
for h in range(9, 17): # D1 8 条消息(≥5)
hist.append((_ts(2026, 9, 1, h, 0), h % 2 == 0))
for i, (ts, is_send) in enumerate(hist):
s.add(Message(msg_id=f"lc{i}", channel="wechat", owner_account_id="wx1",
talker_id="pc", timestamp=ts, is_send=is_send,
type="text", text="x"))
await _seed_msg_rows(s, day=20260901, peer="pc", send=4, recv=4)
await s.commit()
# D41(10-11)客户回流一条
s.add(Message(msg_id="lc-b", channel="wechat", owner_account_id="wx1",
talker_id="pc", timestamp=_ts(2026, 10, 11, 9, 0),
is_send=False, type="text", text="back"))
await _seed_msg_rows(s, day=20261011, peer="pc", send=0, recv=1)
await s.commit()
from app.storage.first_reply_facts import recompute_pairs
await recompute_pairs(s, [("wechat", "wx1", "pc")])
await recompute_metric_days(s, [20261010, 20261011])
await s.commit()
got = {r.day: r for r in (
await s.execute(sa.select(EmployeeMetricDaily))).scalars().all()}
assert got[20261010].dormant_customer_count == 1 # 40 天静默+历史 8 条
assert got[20261011].reactivated_customer_count == 1 # 当日回流且 gap>30
```
- [ ] **Step 2: 跑测试确认失败**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_employee_metrics.py -v
```
期望:`ImportError: recompute_metric_days`。
- [ ] **Step 3: 实现宽表生成(追加到 employee_metrics.py)**
```python
import math # (文件头补充)
from bisect import bisect_right
from app.analytics.lifecycle import classify
from app.db.models import Customer, EmployeeMetricDaily, MsgDailyStats
from app.storage.first_reply_facts import (
compute_conversation,
load_conversation,
pairs_active_in_days,
)
METRIC_VERSION = "v1"
def _percentile(vals: list[int], q: float) -> int | None:
"""最近邻秩分位 ceil(q·n);空列表→None(与 0 严格区分)。"""
if not vals:
return None
s = sorted(vals)
idx = min(len(s) - 1, max(0, math.ceil(q * len(s)) - 1))
return s[idx]
async def _lifecycle_daily(
session: AsyncSession,
days: list[int],
channel: str,
amap: dict[str, str],
valid_users: set[str],
) -> dict[int, dict[str, dict[str, int]]]:
"""逐日 {day: {user: {state: count}}}——customer 当前归属 + 互动日 sweep。
数据源:msg_daily_stats friend 行全历史(互动日/条数)。16k 客户 × 窗口
天数 bisect,夜间全量约秒级;4h tick 只算当日+3 天。
"""
rows = (
await session.execute(
sa.select(
MsgDailyStats.peer_id, MsgDailyStats.day,
MsgDailyStats.send_cnt, MsgDailyStats.recv_cnt,
).where(
MsgDailyStats.channel == channel,
MsgDailyStats.peer_kind == "friend",
)
)
).all()
days_map: dict[str, set[int]] = {}
cnt: dict[str, 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()}
custs = (
await session.execute(
sa.select(Customer.owner_account_id, Customer.friend_id).where(
Customer.channel == channel,
sa.or_(Customer.delete_flag.is_(None), Customer.delete_flag == 0),
Customer.owner_account_id.is_not(None),
)
)
).all()
peer_to_user: dict[str, str] = {}
for owner, peer in custs:
uid = amap.get(owner or "")
if uid and uid in valid_users and peer:
peer_to_user[str(peer)] = uid
out: dict[int, dict[str, dict[str, int]]] = {d: {} for d in days}
for peer, days_list in interaction.items():
uid = peer_to_user.get(peer)
if uid is None:
continue
first = days_list[0]
hist = cnt.get(peer, 0)
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)
if state:
out[d].setdefault(uid, {}).setdefault(state, 0)
out[d][uid][state] += 1
return out
async def recompute_metric_days(
session: AsyncSession,
days,
channels: tuple[str, ...] = ("wechat", "whatsapp"),
) -> int:
"""重算给定日的宽表:按 (day, channel) delete+insert,幂等。返回写入行数。
串类列与首响日值:扫窗口内活跃 friend 会话的**全历史**(每会话一次),
与 first_reply_fact 同一 compute_conversation——分子分母同源(v2 §1.1 #5)。
"""
days = sorted(set(days))
valid_users = await known_user_ids(session)
pairs = await pairs_active_in_days(session, days)
pairs_by_channel: dict[str, list] = {}
for p in pairs:
pairs_by_channel.setdefault(p[0], []).append((p[1], p[2]))
written = 0
for channel in channels:
amap = await account_map(session, channel)
# ---- 串类+分位:逐会话全历史一次,聚合到 (day, user) ----
thread_cols: dict[tuple[int, str], dict] = {}
frs: dict[tuple[int, str], list[int]] = {}
day_set = set(days)
for owner, peer in pairs_by_channel.get(channel, []):
uid = amap.get(owner)
if uid is None or uid not in valid_users:
continue
rows = await load_conversation(session, channel, owner, peer)
facts, agg = compute_conversation(rows)
for d, a in agg.items():
if d not in day_set:
continue
key = (d, uid)
t = thread_cols.setdefault(
key, {"threads": 0, "answered": 0, "fr": 0}
)
t["threads"] += a.threads
t["answered"] += a.answered
t["fr"] += a.fr_sum_ms
frs.setdefault(key, []).extend(
f.fr_ms for f in facts if f.day == d
)
# ---- 生命周期 ----
life = await _lifecycle_daily(session, days, channel, amap, valid_users)
# ---- 消息计数+负载:msg_daily_stats(算法无关列) ----
for day in days:
mds = (
await session.execute(
sa.select(
MsgDailyStats.owner_account_id,
MsgDailyStats.peer_kind,
MsgDailyStats.peer_id,
MsgDailyStats.send_cnt,
MsgDailyStats.recv_cnt,
).where(
MsgDailyStats.day == day, MsgDailyStats.channel == channel
)
)
).all()
await session.execute(
sa.delete(EmployeeMetricDaily).where(
EmployeeMetricDaily.day == day,
EmployeeMetricDaily.channel == channel,
EmployeeMetricDaily.metric_version == METRIC_VERSION,
)
)
agg: dict[str, dict] = {}
for owner, kind, peer, snd, rcv in mds:
uid = amap.get(owner)
if uid is None or uid not in valid_users:
continue # 幽灵过滤
a = agg.setdefault(
uid,
{"sent": 0, "recv": 0, "friends": set(), "rooms": set()},
)
# 消息计数列 = 1:1 口径(v2 §1.1 #2):room 只进 active_rooms
if kind == "friend":
a["sent"] += int(snd or 0)
a["recv"] += int(rcv or 0)
a["friends"].add(peer)
elif kind == "room":
a["rooms"].add(peer)
day_life = life.get(day, {})
# 行并集:消息源 ∪ 生命周期源——纯生命周期日(员工当日无消息
# 但持有降温/沉睡客户)也要出宽表行,否则客户经营维度漏天
all_uids = sorted(set(agg) | set(day_life))
for uid in all_uids:
a = agg.get(
uid, {"sent": 0, "recv": 0, "friends": set(), "rooms": set()}
)
key = (day, uid)
t = thread_cols.get(key, {"threads": 0, "answered": 0, "fr": 0})
lv = day_life.get(uid, {})
session.add(
EmployeeMetricDaily(
day=day,
channel=channel,
user_id=uid,
metric_version=METRIC_VERSION,
msg_sent=a["sent"],
msg_recv=a["recv"],
active_friends=len(a["friends"]),
active_rooms=len(a["rooms"]),
cust_threads=t["threads"],
threads_answered=t["answered"],
fr_sum_ms=t["fr"],
first_reply_p50_ms=_percentile(frs.get(key, []), 0.5),
first_reply_p90_ms=_percentile(frs.get(key, []), 0.9),
new_customer_count=lv.get("new", 0),
cooling_customer_count=lv.get("cooling", 0),
dormant_customer_count=lv.get("dormant", 0),
reactivated_customer_count=lv.get("reactivated", 0),
data_complete=True,
)
)
written += 1
return written
```
注意实现要点(执行者核对):
- `load_conversation` 在 Task 3 已公有定义——宽表生成 import 复用,保证串类列与 fact 行同源同加载口径;
- `_percentile` 的最近邻秩公式与测试对齐(若写 `math.ceil` 版本更清晰可替换,测试只断言行为);
- 幂等删除条件带 `metric_version == METRIC_VERSION`,其他版本行不受影响。
- [ ] **Step 4: 跑测试通过 + 全量回归**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_employee_metrics.py -v
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q
```
期望:6 passed(通话 2 + 宽表 4);全量绿。
- [ ] **Step 5: 提交**
```bash
git add yunke-server/app/storage/employee_metrics.py yunke-server/tests/test_employee_metrics.py
git commit -m "feat(management): 员工指标宽表——1:1口径/串类与fact同源/生命周期列/幽灵过滤"
```
---
### Task 7: 两条回填 CLI
**Files:**
- Modify: `yunke-server/app/sync/cli.py`
- [ ] **Step 1: 注册子命令(`_build_parser` 的 sensitive-scan 之后)**
```python
# ``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 (thread columns share the same "
"source). Deploy backfill + nightly self-heal. Idempotent."
),
)
```
- [ ] **Step 2: 实现命令函数(`_cmd_sensitive_scan` 之后)**
```python
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:
import sqlalchemy as sa
from app.db.models import MsgDailyStats
from app.storage.employee_metrics import (
recompute_call_days,
recompute_metric_days,
)
async with session_maker() as session:
days = (
await session.execute(sa.select(MsgDailyStats.day).distinct())
).scalars().all()
rows = await recompute_metric_days(session, list(days))
calls = await recompute_call_days(session, list(days))
await session.commit()
print(
f"[employee-metrics] rebuilt {rows} wide rows, {calls} call rows"
)
return 0
finally:
await engine.dispose()
```
`main()` 分发表追加两行(`sensitive-scan` 之后):
```python
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))
```
- [ ] **Step 3: 冒烟验证(对本地 Docker MySQL 真跑)**
```bash
cd yunke-server && .venv/Scripts/python.exe -m app.sync.cli first-reply-facts
cd yunke-server && .venv/Scripts/python.exe -m app.sync.cli employee-metrics
```
期望:退出码 0,输出会话数(预期数千)与行数(宽表预期 = 活跃 day×channel×员工组合,数千~数万)。
- [ ] **Step 4: 幂等复跑 + 抽查**
```bash
cd yunke-server && .venv/Scripts/python.exe -m app.sync.cli employee-metrics # 复跑行数应一致
docker exec -it yunke-mysql-1 mysql -uyunke -pyunke yunke -e "
SELECT COUNT(*) AS fact_rows FROM first_reply_fact;
SELECT COUNT(*) AS wide_rows, SUM(threads_answered) AS answered FROM employee_metric_daily;
SELECT COUNT(*) FROM first_reply_fact f
WHERE NOT EXISTS (SELECT 1 FROM msg_daily_stats m
WHERE m.peer_kind='friend' AND m.channel=f.channel
AND m.owner_account_id=f.owner_account_id AND m.peer_id=f.peer_id AND m.day=f.day);
"
```
期望:第三个查询返回 0(每个 fact 样本所在会话×日都有 msg_daily_stats 行——落库链路自洽)。
- [ ] **Step 5: 全量测试 + 提交**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q
git add yunke-server/app/sync/cli.py
git commit -m "feat(management): 两条回填CLI——first-reply-facts/employee-metrics全量重建幂等"
```
---
### Task 8: 本地全量回填实测 + 对账 + DEPLOY.md 注记
**Files:**
- Modify: `docs/DEPLOY.md`(§7 回填清单追加两条命令)
- Modify: `docs/superpowers/plans/2026-09-08-management-batch-a-verification.md`(追加实测数据)
- [ ] **Step 1: 计时全量回填**
```bash
cd yunke-server && time .venv/Scripts/python.exe -m app.sync.cli first-reply-facts
cd yunke-server && time .venv/Scripts/python.exe -m app.sync.cli employee-metrics
```
记录耗时到核实文档(预期:fact 分钟级、宽表分钟级;超过 10 分钟时记下瓶颈会话,评估尾部增量优化——**不阻塞批次验收**)。
- [ ] **Step 2: 对账抽查(v2 §9 数据核验的批次A子集)**
用 Task 0 回测名单中的 2 名员工 × 3 个日期:
```sql
-- 1) 消息计数列 ↔ msg_daily_stats friend 聚合(必须一致)
SELECT m.day, SUM(m.send_cnt) sent, SUM(m.recv_cnt) recv
FROM msg_daily_stats m JOIN wechat_account w ON m.owner_account_id=w.wx_id
WHERE m.peer_kind='friend' AND w.user_id='<UID>' AND m.day IN (20260901, 20260815, 20260801)
GROUP BY m.day;
SELECT day, msg_sent, msg_recv FROM employee_metric_daily
WHERE user_id='<UID>' AND channel='wechat' AND day IN (20260901, 20260815, 20260801);
-- 2) 串类列 ↔ fact 行数(必须一致——同源)
SELECT f.day, COUNT(*) FROM first_reply_fact f JOIN wechat_account w ON f.owner_account_id=w.wx_id
WHERE w.user_id='<UID>' AND f.day IN (...) GROUP BY f.day;
SELECT day, threads_answered FROM employee_metric_daily WHERE user_id='<UID>' AND ...;
-- 3) 串类列 vs 日切口径(允许 fact ≥ 日切——差集=跨夜串,记录差值)
SELECT day, cust_threads FROM stat_daily_staff WHERE user_id='<UID>' AND ...;
```
把对账结果(一致/差异+解释)写入核实文档。**发现不一致且无法用口径差异解释时停下排查,不得带病进入批次B**。
- [ ] **Step 3: DEPLOY.md §7 追加回填注记**
在现有 daily-stats/staff-daily-stats 回填注记之后追加(格式对齐现有条目):
```markdown
- 管理后台批次A新增(部署带迁移 b7c8d9e0f1a2 后各跑一次,幂等):
- `python -m app.sync.cli first-reply-facts`(跨日精确首响事实;本地实测耗时见批次A核实文档)
- `python -m app.sync.cli employee-metrics`(员工宽表+通话小表;**必须在 first-reply-facts 之后跑**)
- 服务器注意:backend 容器不自动跑 alembic,须手动 `sudo docker exec <backend> alembic upgrade head`
```
- [ ] **Step 4: 提交 + 批次收尾**
```bash
git add docs/DEPLOY.md docs/superpowers/plans/2026-09-08-management-batch-a-verification.md
git commit -m "docs(management): 批次A回填实测+对账记录+DEPLOY部署注记"
```
---
## 批次A验收清单(全部勾完才算完成)
- [ ] 6 张表在本地 MySQL 存在,migration 幂等 + downgrade 干净;
- [ ] `first-reply-facts` 全量回填成功且复跑幂等;
- [ ] `employee-metrics` 全量回填成功且复跑幂等;
- [ ] 跨夜串判别测试通过(23:00→次日 9:00 产生 ~10h 样本);
- [ ] friend-only 口径判别测试通过(room 不进消息计数/串列);
- [ ] 同源判别测试通过(threads_answered ≡ fact 行数;多日未回串分母不膨胀);
- [ ] 幽灵员工过滤测试通过;
- [ ] 对账抽查:消息计数列与 msg_daily_stats friend 聚合一致;串类列差异全部可用跨夜串解释;
- [ ] 全量测试套件绿(现有 ~575 + 新增 ~25);
- [ ] 核实文档完整(五项核实 + 回测名单 + 回填耗时 + 对账记录);
- [ ] 写钩子零改动(`git diff` 确认 summary.py/repos.py 未动)。
## 批次B-F 的后续计划
批次A合入后另出计划(避免远期细节空转):
- **批次B**(v2 Task 3-5):scoring/anomalies/管理 API + 配置种子 CLI;
- **批次C**(Task 6-7):前端导航+管理驾驶舱;
- **批次D**(Task 8-9):员工总览+详情;
- **批次E**(Task 10-11):辅导闭环+考核快照;
- **批次F**(Task 12-13):analytics_tick 第三步、full_sync 第 13/14 步、scheduler 镜像重建、回测试运行。