"""首响时长+回复率聚合列(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)