"""add tenant-safe approval task work queue and append-only events Revision ID: 20260716_0013 Revises: 20260716_0012 Create Date: 2026-07-16 18:20:00 """ from __future__ import annotations from collections.abc import Sequence import sqlalchemy as sa from alembic import op revision: str = "20260716_0013" down_revision: str | None = "20260716_0012" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def _require_postgresql() -> None: dialect_name = op.get_bind().dialect.name if dialect_name != "postgresql": raise RuntimeError( "20260716_0013 only supports PostgreSQL; " f"refusing to mutate {dialect_name} without transactional constraint DDL" ) def _require_empty_audit_chain_for_downgrade() -> None: bind = op.get_bind() task_count = int(bind.scalar(sa.text("SELECT COUNT(*) FROM approval_tasks")) or 0) event_count = int(bind.scalar(sa.text("SELECT COUNT(*) FROM approval_task_events")) or 0) if task_count or event_count: raise RuntimeError( "cannot downgrade approval tasks: audit chain is not empty " f"({task_count} tasks, {event_count} events)" ) def upgrade() -> None: _require_postgresql() op.create_table( "approval_tasks", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("tenant_id", sa.String(length=64), nullable=False), # expense_claims 仍由 legacy bootstrap 管理,故这里只保存软引用。 sa.Column("claim_id", sa.String(length=36), nullable=False), sa.Column("expense_case_id", sa.String(length=36), nullable=True), sa.Column("node_instance_id", sa.String(length=120), nullable=False), sa.Column("node_entry_key", sa.String(length=160), nullable=False), sa.Column("parent_task_id", sa.String(length=36), nullable=True), sa.Column("task_kind", sa.String(length=24), nullable=False), sa.Column("node_key", sa.String(length=32), nullable=False), sa.Column("node_label", sa.String(length=120), nullable=False), sa.Column("node_sequence", sa.Integer(), nullable=False), sa.Column("sequence_order", sa.Integer(), nullable=False, server_default="0"), sa.Column("coordination_mode", sa.String(length=32), nullable=False), sa.Column("owner_kind", sa.String(length=20), nullable=False), sa.Column("owner_key", sa.String(length=120), nullable=False), sa.Column("owner_employee_id", sa.String(length=36), nullable=True), sa.Column("owner_name", sa.String(length=120), nullable=False), sa.Column("assignee_kind", sa.String(length=20), nullable=False), sa.Column("assignee_key", sa.String(length=120), nullable=False), sa.Column("assignee_employee_id", sa.String(length=36), nullable=True), sa.Column("assignee_name", sa.String(length=120), nullable=False), sa.Column("delegated_by", sa.String(length=120), nullable=True), sa.Column("delegation_expires_at", sa.DateTime(timezone=True), nullable=True), sa.Column( "status", sa.String(length=24), nullable=False, server_default="waiting", ), sa.Column("decision", sa.String(length=20), nullable=True), sa.Column("opinion", sa.Text(), nullable=True), sa.Column("version", sa.Integer(), nullable=False, server_default="1"), sa.Column("claim_status_snapshot", sa.String(length=30), nullable=False), sa.Column("claim_stage_snapshot", sa.String(length=50), nullable=False), sa.Column("entered_at", sa.DateTime(timezone=True), nullable=False), sa.Column("entered_at_source", sa.String(length=24), nullable=False), sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True), sa.Column("sla_hours_snapshot", sa.Integer(), nullable=False), sa.Column("due_at", sa.DateTime(timezone=True), nullable=False), sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True), sa.Column("escalation_level", sa.Integer(), nullable=False, server_default="0"), sa.Column("escalated_at", sa.DateTime(timezone=True), nullable=True), sa.Column("next_escalation_at", sa.DateTime(timezone=True), nullable=True), sa.Column("priority_score", sa.Integer(), nullable=False, server_default="0"), sa.Column( "priority_tier", sa.String(length=16), nullable=False, server_default="normal", ), sa.Column( "priority_reasons_json", sa.JSON(), nullable=False, server_default=sa.text("'[]'::json"), ), sa.Column( "risk_level", sa.String(length=16), nullable=False, server_default="low", ), sa.Column("open_risk_count", sa.Integer(), nullable=False, server_default="0"), sa.Column( "evidence_completeness", sa.Numeric(precision=5, scale=4), nullable=False, server_default="0", ), sa.Column( "batch_eligible", sa.Boolean(), nullable=False, server_default=sa.false(), ), sa.Column( "batch_block_reasons_json", sa.JSON(), nullable=False, server_default=sa.text("'[]'::json"), ), sa.Column( "projection_updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.Column( "updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.CheckConstraint( "task_kind IN ('root', 'add_sign', 'countersign')", name="ck_approval_tasks_task_kind", ), sa.CheckConstraint( "node_key IN ('direct_manager', 'budget_manager', 'finance')", name="ck_approval_tasks_node_key", ), sa.CheckConstraint( "coordination_mode IN ('single', 'sequential_before_owner', 'parallel_all')", name="ck_approval_tasks_coordination_mode", ), sa.CheckConstraint( "owner_kind IN ('employee', 'role')", name="ck_approval_tasks_owner_kind", ), sa.CheckConstraint( "assignee_kind IN ('employee', 'role')", name="ck_approval_tasks_assignee_kind", ), sa.CheckConstraint( "status IN ('waiting', 'pending', 'completed', 'returned', " "'cancelled', 'superseded')", name="ck_approval_tasks_status", ), sa.CheckConstraint( "decision IS NULL OR decision IN ('approved', 'returned')", name="ck_approval_tasks_decision", ), sa.CheckConstraint( "entered_at_source IN ('workflow_event', 'submitted_at', 'updated_at', 'backfill')", name="ck_approval_tasks_entered_at_source", ), sa.CheckConstraint( "priority_tier IN ('normal', 'high', 'urgent')", name="ck_approval_tasks_priority_tier", ), sa.CheckConstraint( "risk_level IN ('low', 'medium', 'high', 'critical')", name="ck_approval_tasks_risk_level", ), sa.CheckConstraint( "(task_kind = 'root' AND parent_task_id IS NULL AND sequence_order = 0) OR " "(task_kind IN ('add_sign', 'countersign') " "AND parent_task_id IS NOT NULL AND sequence_order > 0)", name="ck_approval_tasks_parent_shape", ), sa.CheckConstraint( "(status = 'completed' AND decision = 'approved' " "AND completed_at IS NOT NULL AND cancelled_at IS NULL) OR " "(status = 'returned' AND decision = 'returned' " "AND completed_at IS NOT NULL AND cancelled_at IS NULL) OR " "(status IN ('cancelled', 'superseded') AND decision IS NULL " "AND completed_at IS NULL AND cancelled_at IS NOT NULL) OR " "(status IN ('waiting', 'pending') AND decision IS NULL " "AND completed_at IS NULL AND cancelled_at IS NULL)", name="ck_approval_tasks_lifecycle", ), sa.CheckConstraint( "status NOT IN ('pending', 'completed', 'returned') OR activated_at IS NOT NULL", name="ck_approval_tasks_activation", ), sa.CheckConstraint( "delegation_expires_at IS NULL OR delegated_by IS NOT NULL", name="ck_approval_tasks_delegation", ), sa.CheckConstraint("version >= 1", name="ck_approval_tasks_version"), sa.CheckConstraint("node_sequence >= 1", name="ck_approval_tasks_node_sequence"), sa.CheckConstraint("sla_hours_snapshot > 0", name="ck_approval_tasks_sla_hours"), sa.CheckConstraint("due_at >= entered_at", name="ck_approval_tasks_due_at"), sa.CheckConstraint("escalation_level >= 0", name="ck_approval_tasks_escalation"), sa.CheckConstraint( "priority_score >= 0 AND priority_score <= 100", name="ck_approval_tasks_priority_score", ), sa.CheckConstraint( "open_risk_count >= 0", name="ck_approval_tasks_open_risk_count", ), sa.CheckConstraint( "evidence_completeness >= 0 AND evidence_completeness <= 1", name="ck_approval_tasks_evidence_completeness", ), sa.ForeignKeyConstraint( ["tenant_id", "parent_task_id"], ["approval_tasks.tenant_id", "approval_tasks.id"], name="fk_approval_tasks_tenant_parent", ondelete="RESTRICT", ), sa.ForeignKeyConstraint( ["tenant_id", "expense_case_id"], ["expense_cases.tenant_id", "expense_cases.id"], name="fk_approval_tasks_tenant_expense_case", ondelete="RESTRICT", ), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("tenant_id", "id", name="uq_approval_tasks_tenant_id"), sa.UniqueConstraint( "tenant_id", "node_entry_key", name="uq_approval_tasks_tenant_node_entry", ), sa.UniqueConstraint( "tenant_id", "node_instance_id", "assignee_kind", "assignee_key", name="uq_approval_tasks_node_participant", ), ) op.create_index( "uq_approval_tasks_open_root_per_claim", "approval_tasks", ["tenant_id", "claim_id"], unique=True, postgresql_where=sa.text( "task_kind = 'root' AND status IN ('waiting', 'pending')" ), ) op.create_index( "ix_approval_tasks_personal_inbox", "approval_tasks", ["tenant_id", "assignee_kind", "assignee_key", "status", "due_at"], unique=False, ) op.create_index( "ix_approval_tasks_tenant_queue", "approval_tasks", ["tenant_id", "status", "priority_score", "due_at"], unique=False, ) op.create_index( "ix_approval_tasks_tenant_claim", "approval_tasks", ["tenant_id", "claim_id", "node_sequence"], unique=False, ) op.create_index( "ix_approval_tasks_tenant_node", "approval_tasks", ["tenant_id", "node_instance_id", "sequence_order"], unique=False, ) op.create_table( "approval_task_events", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("tenant_id", sa.String(length=64), nullable=False), sa.Column("task_id", sa.String(length=36), nullable=False), sa.Column("node_instance_id", sa.String(length=120), nullable=False), sa.Column("event_type", sa.String(length=50), nullable=False), sa.Column("actor_id", sa.String(length=120), nullable=False), sa.Column("actor_name", sa.String(length=120), nullable=False), sa.Column("actor_type", sa.String(length=20), nullable=False), sa.Column("request_id", sa.String(length=120), nullable=False), sa.Column("expected_task_version", sa.Integer(), nullable=False), sa.Column("result_task_version", sa.Integer(), nullable=False), sa.Column("payload_fingerprint", sa.String(length=80), nullable=False), sa.Column("payload_json", sa.JSON(), nullable=False), sa.Column("before_json", sa.JSON(), nullable=False), sa.Column("after_json", sa.JSON(), nullable=False), sa.Column("response_json", sa.JSON(), nullable=False), sa.Column("approval_action_ledger_id", sa.String(length=36), nullable=True), sa.Column("business_event_id", sa.String(length=36), nullable=True), sa.Column("correlation_id", sa.String(length=64), nullable=True), sa.Column("causation_id", sa.String(length=64), nullable=True), sa.Column( "occurred_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.CheckConstraint( "length(trim(event_type)) > 0", name="ck_approval_task_events_type", ), sa.CheckConstraint( "actor_type IN ('user', 'system', 'agent', 'service')", name="ck_approval_task_events_actor_type", ), sa.CheckConstraint( "expected_task_version >= 0 AND result_task_version >= 1 " "AND result_task_version >= expected_task_version", name="ck_approval_task_events_version", ), sa.ForeignKeyConstraint( ["tenant_id", "task_id"], ["approval_tasks.tenant_id", "approval_tasks.id"], name="fk_approval_task_events_tenant_task", ondelete="RESTRICT", ), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint( "tenant_id", "actor_id", "request_id", name="uq_approval_task_events_actor_request", ), sa.UniqueConstraint( "tenant_id", "task_id", "result_task_version", name="uq_approval_task_events_task_version", ), ) op.create_index( "ix_approval_task_events_tenant_task_time", "approval_task_events", ["tenant_id", "task_id", "occurred_at"], unique=False, ) op.create_index( "ix_approval_task_events_tenant_node_time", "approval_task_events", ["tenant_id", "node_instance_id", "occurred_at"], unique=False, ) op.execute( "CREATE FUNCTION reject_approval_task_event_mutation() " "RETURNS trigger AS $$ " "BEGIN " "RAISE EXCEPTION 'approval_task_events is append-only'; " "RETURN OLD; " "END; " "$$ LANGUAGE plpgsql" ) op.execute( "CREATE TRIGGER trg_approval_task_events_append_only " "BEFORE UPDATE OR DELETE ON approval_task_events " "FOR EACH ROW EXECUTE FUNCTION reject_approval_task_event_mutation()" ) def downgrade() -> None: _require_postgresql() # 任务投影与事件共同组成审批审计链,降级前必须显式迁移或清空数据。 _require_empty_audit_chain_for_downgrade() op.execute("DROP TRIGGER trg_approval_task_events_append_only ON approval_task_events") op.execute("DROP FUNCTION reject_approval_task_event_mutation()") op.drop_index( "ix_approval_task_events_tenant_node_time", table_name="approval_task_events", ) op.drop_index( "ix_approval_task_events_tenant_task_time", table_name="approval_task_events", ) op.drop_table("approval_task_events") op.drop_index("ix_approval_tasks_tenant_node", table_name="approval_tasks") op.drop_index("ix_approval_tasks_tenant_claim", table_name="approval_tasks") op.drop_index("ix_approval_tasks_tenant_queue", table_name="approval_tasks") op.drop_index("ix_approval_tasks_personal_inbox", table_name="approval_tasks") op.drop_index("uq_approval_tasks_open_root_per_claim", table_name="approval_tasks") op.drop_table("approval_tasks")