401 lines
16 KiB
Python
401 lines
16 KiB
Python
|
|
"""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")
|