feat(analytics): 首响时长+回复率数据管道——消息串算法/日汇总扩展列/员工聚合
批1(A1+A2):管理层要看「每个人的响应质量」。 - 迁移 d9e0f1a2b3c4:msg_daily_stats/stat_daily_staff 各加 cust_threads/threads_answered/fr_sum_ms(NOT NULL server_default 0, 幂等守卫照 9b0d0c133035 惯例;models.py 双声明同步) - summary.py _thread_stats:串=客户连续消息不被员工消息打断的段, is_send=None 不参与串判定;fr=员工首条回复−串末条客户消息; 日切近似 v1(日末未闭合串计 threads 不计 answered,口径写进 docstring) - recompute_days/recompute_all 都先按 ts 排序再算串(乱序防护) - staff_summary 聚合三列按账号→员工累加(fr_cnt≡threads_answered 不单列) - 测试 +10:串算法矩阵 9 例+staff 聚合传递;全量 589 绿 - 本地 86 万行回填验证:daily-stats 60s/staff 10s,rollup 总和与 明细完全一致,随机 5 行 SQL 独立复算全对 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
"""首响时长+回复率聚合列(msg_daily_stats / stat_daily_staff 各 3 列)
|
||||
|
||||
Revision ID: d9e0f1a2b3c4
|
||||
Revises: c3d4e5f6a7b8
|
||||
Create Date: 2026-09-07
|
||||
|
||||
数据分析板块批1(A1+A2,2026-09-07):管理层要看「每个人的响应质量」。
|
||||
两表各加 cust_threads / threads_answered / fr_sum_ms——消息串算法见
|
||||
app/storage/summary.py(口径与 8-24 spec 对齐,日切近似 v1)。
|
||||
fr_cnt ≡ threads_answered(被回串数=样本数),不单列。
|
||||
|
||||
幂等:sa.inspect 先查列存在则跳过(照 9b0d0c133035 惯例,SQLite/MySQL
|
||||
通用);downgrade 对称删列。模型侧 models.py 同步双声明,防 alembic
|
||||
autogenerate 反 DROP。NOT NULL + server_default 0:旧行/旧构造不传也不炸。
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "d9e0f1a2b3c4"
|
||||
down_revision: str | Sequence[str] | None = "c3d4e5f6a7b8"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_COLUMNS: tuple[tuple[str, str, sa.types.TypeEngine], ...] = (
|
||||
("msg_daily_stats", "cust_threads", sa.Integer()),
|
||||
("msg_daily_stats", "threads_answered", sa.Integer()),
|
||||
("msg_daily_stats", "fr_sum_ms", sa.BigInteger()),
|
||||
("stat_daily_staff", "cust_threads", sa.Integer()),
|
||||
("stat_daily_staff", "threads_answered", sa.Integer()),
|
||||
("stat_daily_staff", "fr_sum_ms", sa.BigInteger()),
|
||||
)
|
||||
|
||||
|
||||
def _has_column(bind, table: str, column: str) -> bool:
|
||||
insp = sa.inspect(bind)
|
||||
if not insp.has_table(table):
|
||||
return False
|
||||
return column in {c["name"] for c in insp.get_columns(table)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
bind = op.get_bind()
|
||||
for table, column, type_ in _COLUMNS:
|
||||
if _has_column(bind, table, column):
|
||||
continue # 幂等:已加过则跳过
|
||||
op.add_column(
|
||||
table,
|
||||
sa.Column(column, type_, nullable=False, server_default=sa.text("0")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
bind = op.get_bind()
|
||||
for table, column, _ in _COLUMNS:
|
||||
if _has_column(bind, table, column):
|
||||
op.drop_column(table, column)
|
||||
Reference in New Issue
Block a user