feat(approval): add task workflow and waiver decisions

This commit is contained in:
caoxiaozhu
2026-07-16 16:52:12 +08:00
parent 28b834edd3
commit 242d68c36f
89 changed files with 16313 additions and 294 deletions

View File

@@ -0,0 +1,400 @@
"""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")