与 ad3beb7 实际实现对齐:day_msgs+cum 累积数组(hist=≤as_of,历史日沉睡门槛
不被未来消息虚增)、每日循环末 flush、accumulate import
86 KiB
云客经营管理后台·批次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 §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)。
执行者必读:关键代码事实与口径
- 日切 vs 跨日:
app/storage/summary.py_thread_stats是日切近似——客户 23:00 发、员工次日 9:00 回的场景整条样本丢失(Day1 串不闭合、Day2 重起算无客户消息)。本计划的compute_conversation按会话全历史计算,跨夜串不丢(spec §1.1 #1)。 - 混合口径:
app/storage/staff_summary.py聚合的sent/recv/threads/answered/fr无 kind 过滤 = friend+room 混合。宽表消息计数列/串类列一律 friend-only(spec §1.1 #2)。 - 同源原则:宽表串类三列与 fact 行出自同一个
compute_conversation调用——回复率分子分母一致(spec §1.1 #5)。 - 通话无渠道:
PhoneCall.channel是通话类型(phone_call/voice_call)非消息渠道(models.py:512 docstring),通话进独立小表employee_call_daily(day, user_id)(spec §1.2 #9)。 - 员工主数据 =
staff表(user_idPK +is_on_job,models.py:126)。幽灵员工(account_map 映射不上或不在 staff)过滤不落宽表(spec §1.3 #11)。 - day 口径:Asia/Shanghai 自然日 yyyymmdd,一律 Python 计算(复用
app.storage.summary.day_key),不用 SQL 日期函数。 - 生命周期口径(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(当前归属口径,换人后归新员工)。 - 测试命令(Windows venv,在
yunke-server/下):.venv/Scripts/python.exe -m pytest tests/<file> -v。提交信息用中文 conventional 风格(参考git log)。仓库 master 单主干,直接在 master 提交(项目惯例,不用 worktree)。 - 本地起服务前先查 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/ 下 4 个新测试文件(test_management_models / test_first_reply_facts / test_lifecycle / test_employee_metrics) |
判别性测试 | 新建 |
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 容器名与连通
docker ps --format '{{.Names}}' # 期望看到 yunke-mysql-1(常驻)
以下 SQL 用 docker exec -it yunke-mysql-1 mysql -uyunke -pyunke yunke -e "<SQL>" 执行。
- Step 2: 核实①员工主数据(staff 表)
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 列与单位
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 归属字段填充率
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: 核实④群占比(量化混合口径偏差)
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)
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 名回测员工
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(含每项结论 + 与本计划默认假设的偏差;偏差影响后续任务实现细节的,在对应任务执行时按核实结论调整并在提交信息注明)。
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_sessionfixture) -
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):
@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:
"""管理域 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: 跑测试确认失败
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/mapped_column/Mapped/sa/dt/_updated_at;唯一约束与 Text 列走 sa.UniqueConstraint/sa.Text,无需新增顶层 import):
# ==========================================================================
# 经营管理后台(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: 跑测试确认通过
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_management_models.py -v
期望:8 passed。
- Step 6: 全量回归
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q
期望:全绿(现有 ~575 + 新 8)。
- Step 7: 提交
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
cd yunke-server && .venv/Scripts/python.exe -m alembic heads
期望 f7a8b9c0d1e2(customer_account_link 是当前链尾;若执行时又有新迁移合入,把迁移文件的 down_revision 改成实际 head)。
- Step 2: 写迁移文件
照 f0a1b2c3d4e5_analytics_module_tables.py 的幂等守卫模式(sa.inspect 先查后建;不在 migration 里碰 message/msg_daily_stats 数据):
"""经营管理后台批次A:6 张新表(fact/宽表/通话小表/评分配置/快照/辅导)
Revision ID: b7c8d9e0f1a2
Revises: f7a8b9c0d1e2
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 = "f7a8b9c0d1e2"
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: 本地库升级实测
cd yunke-server && .venv/Scripts/python.exe -m alembic upgrade head
期望:输出包含 Running upgrade f7a8b9c0d1e2 -> b7c8d9e0f1a2。再跑一次(幂等)应无操作。
- Step 4: 验证表存在 + downgrade 冒烟
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: 全量测试 + 提交
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:
"""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: 跑测试确认失败
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
"""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: 跑测试确认通过 + 全量回归
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
期望:9 passed;全量绿。
- Step 5: 提交
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:
"""客户生命周期判定纯函数测试(批次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: 跑测试确认失败
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_lifecycle.py -v
期望:ModuleNotFoundError: app.analytics.lifecycle。
- Step 3: 实现 lifecycle.py
"""客户生命周期判定(管理域,批次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: 跑测试通过 + 提交
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:
"""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: 跑测试确认失败
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_employee_metrics.py -v
期望:ImportError: recompute_call_days。
- Step 3: 实现通话小表生成(employee_metrics.py 第一部分)
"""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
staff_ids = await known_user_ids(session) # 幽灵过滤(v2 §1.3 #11)
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 or uid not in staff_ids:
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: 跑测试通过
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_employee_metrics.py -v
期望:2 passed。
- Step 5: 提交
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 追加:
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: 跑测试确认失败
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_employee_metrics.py -v
期望:ImportError: recompute_metric_days。
- Step 3: 实现宽表生成(追加到 employee_metrics.py)
import math # (文件头补充)
from bisect import bisect_right
from itertools import accumulate
from app.analytics.lifecycle import classify
from app.db.models import Customer, EmployeeMetricDaily, MsgDailyStats
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 行全历史(互动日/条数)。hist 为 ≤as_of
的历史消息数(时点语义:与 last 同一 bisect 位取按日累积数组 cum[idx],
as_of 之后的消息不虚增历史日的沉睡门槛——Task6 评审修正)。16k 客户 ×
窗口天数 bisect+classify,夜间全量实测见批次A核实文档;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()
# peer → {day: 消息条数};days_list 与累积数组 cum 对齐:
# cum[i] = 该客户 ≤days_list[i] 的历史消息数(hist 时点语义)
day_msgs: dict[str, dict[int, int]] = {}
for peer, day, snd, rcv in rows:
dm = day_msgs.setdefault(peer, {})
dm[day] = dm.get(day, 0) + int(snd or 0) + int(rcv or 0)
interaction = {p: sorted(dm) for p, dm in day_msgs.items()}
custs = (
await session.execute(
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]
cum = list(accumulate(day_msgs[peer][d] for d in days_list))
for d in days:
idx = bisect_right(days_list, d) - 1
if idx < 0:
continue
last = days_list[idx]
prev = days_list[idx - 1] if idx > 0 else None
state = classify(first, last, prev, d, cum[idx])
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
await session.flush() # 防御性:防 autoflush=False 配置下 ORM 对象堆积(Task6 评审修正)
return written
注意实现要点(执行者核对):
-
load_conversation在 Task 3 已公有定义——宽表生成 import 复用,保证串类列与 fact 行同源同加载口径; -
_percentile的最近邻秩公式与测试对齐(若写math.ceil版本更清晰可替换,测试只断言行为); -
幂等删除条件带
metric_version == METRIC_VERSION,其他版本行不受影响。 -
Step 4: 跑测试通过 + 全量回归
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: 提交
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 之后)
# ``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之后)
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 之后):
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 真跑)
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: 幂等复跑 + 抽查
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: 全量测试 + 提交
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: 计时全量回填
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 个日期:
-- 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 回填注记之后追加(格式对齐现有条目):
- 管理后台批次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: 提交 + 批次收尾
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 + 新增 30:模型 8 + fact 9 + lifecycle 7 + 通话/宽表 6);
- 核实文档完整(五项核实 + 回测名单 + 回填耗时 + 对账记录);
- 写钩子零改动(
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 镜像重建、回测试运行。