diff --git a/docs/superpowers/plans/2026-09-08-management-console-batch-a-data-foundation.md b/docs/superpowers/plans/2026-09-08-management-console-batch-a-data-foundation.md new file mode 100644 index 0000000..507076d --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-management-console-batch-a-data-foundation.md @@ -0,0 +1,2071 @@ +# 云客经营管理后台·批次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/ -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/_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 ""` 执行。 + +- [ ] **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='' AND m.day IN (20260901, 20260815, 20260801) +GROUP BY m.day; +SELECT day, msg_sent, msg_recv FROM employee_metric_daily +WHERE user_id='' 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='' AND f.day IN (...) GROUP BY f.day; +SELECT day, threads_answered FROM employee_metric_daily WHERE user_id='' AND ...; +-- 3) 串类列 vs 日切口径(允许 fact ≥ 日切——差集=跨夜串,记录差值) +SELECT day, cust_threads FROM stat_daily_staff WHERE user_id='' 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 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 镜像重建、回测试运行。