From 373fb266a69bca1e09a965d14781bddcad145e66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Wed, 9 Sep 2026 12:33:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(management):=20=E6=89=B9=E6=AC=A1A?= =?UTF-8?q?=E5=BB=BA=E8=A1=A8=E8=BF=81=E7=A7=BB=E2=80=94=E2=80=94=E5=85=AD?= =?UTF-8?q?=E8=A1=A8=E5=B9=82=E7=AD=89=E5=AE=88=E5=8D=AB/=E7=B4=A2?= =?UTF-8?q?=E5=BC=95=E7=8B=AC=E7=AB=8B=E8=A1=A5=E9=BD=90/downgrade?= =?UTF-8?q?=E5=8F=AA=E5=88=A0=E6=96=B0=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../b7c8d9e0f1a2_management_phase1_tables.py | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 yunke-server/migrations/versions/b7c8d9e0f1a2_management_phase1_tables.py diff --git a/yunke-server/migrations/versions/b7c8d9e0f1a2_management_phase1_tables.py b/yunke-server/migrations/versions/b7c8d9e0f1a2_management_phase1_tables.py new file mode 100644 index 0000000..5326c73 --- /dev/null +++ b/yunke-server/migrations/versions/b7c8d9e0f1a2_management_phase1_tables.py @@ -0,0 +1,161 @@ +"""经营管理后台批次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)