feat(approval): add task workflow and waiver decisions
This commit is contained in:
400
server/alembic/versions/20260716_0013_approval_tasks.py
Normal file
400
server/alembic/versions/20260716_0013_approval_tasks.py
Normal 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")
|
||||
248
server/alembic/versions/20260716_0014_risk_waiver_decision.py
Normal file
248
server/alembic/versions/20260716_0014_risk_waiver_decision.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""add risk waiver decision metadata and separation of duties state
|
||||
|
||||
Revision ID: 20260716_0014
|
||||
Revises: 20260716_0013
|
||||
Create Date: 2026-07-16 19:10:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260716_0014"
|
||||
down_revision: str | None = "20260716_0013"
|
||||
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_0014 only supports PostgreSQL; "
|
||||
f"refusing to mutate {dialect_name} without transactional constraint DDL"
|
||||
)
|
||||
|
||||
|
||||
def _require_no_waiver_audit_data_for_downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
disposition_count = int(
|
||||
bind.scalar(
|
||||
sa.text(
|
||||
"SELECT COUNT(*) FROM risk_dispositions "
|
||||
"WHERE waiver_requester_id IS NOT NULL "
|
||||
"OR waiver_decision IS NOT NULL "
|
||||
"OR lifecycle_status IN ('waived', 'waiver_rejected')"
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
event_count = int(
|
||||
bind.scalar(
|
||||
sa.text(
|
||||
"SELECT COUNT(*) FROM risk_disposition_events "
|
||||
"WHERE action IN ('approve_waiver', 'reject_waiver')"
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if disposition_count or event_count:
|
||||
raise RuntimeError(
|
||||
"cannot downgrade risk waiver decisions: immutable waiver audit data exists "
|
||||
f"({disposition_count} dispositions, {event_count} decision events)"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_require_postgresql()
|
||||
op.add_column(
|
||||
"risk_dispositions",
|
||||
sa.Column("waiver_requester_id", sa.String(length=120), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"risk_dispositions",
|
||||
sa.Column("waiver_requester_name", sa.String(length=120), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"risk_dispositions",
|
||||
sa.Column("waiver_requested_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"risk_dispositions",
|
||||
sa.Column("waiver_reason", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"risk_dispositions",
|
||||
sa.Column("waiver_scope", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"risk_dispositions",
|
||||
sa.Column("waiver_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"risk_dispositions",
|
||||
sa.Column(
|
||||
"waiver_conditions_json",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'[]'::json"),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"risk_dispositions",
|
||||
sa.Column("waiver_decision", sa.String(length=20), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"risk_dispositions",
|
||||
sa.Column("waiver_decider_id", sa.String(length=120), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"risk_dispositions",
|
||||
sa.Column("waiver_decider_name", sa.String(length=120), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"risk_dispositions",
|
||||
sa.Column("waiver_decided_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"risk_dispositions",
|
||||
sa.Column("waiver_decision_reason", sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
op.drop_constraint(
|
||||
"ck_risk_dispositions_lifecycle",
|
||||
"risk_dispositions",
|
||||
type_="check",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_risk_dispositions_lifecycle",
|
||||
"risk_dispositions",
|
||||
"lifecycle_status IN ('open', 'supplement_requested', "
|
||||
"'remediation_in_progress', 'waiver_requested', 'waived', "
|
||||
"'waiver_rejected', 'resolved')",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_risk_dispositions_waiver_request",
|
||||
"risk_dispositions",
|
||||
"(waiver_requester_id IS NULL AND waiver_requester_name IS NULL "
|
||||
"AND waiver_requested_at IS NULL AND waiver_reason IS NULL "
|
||||
"AND waiver_scope IS NULL AND waiver_expires_at IS NULL) OR "
|
||||
"(waiver_requester_id IS NOT NULL "
|
||||
"AND length(trim(waiver_requester_id)) > 0 "
|
||||
"AND waiver_requester_name IS NOT NULL "
|
||||
"AND length(trim(waiver_requester_name)) > 0 "
|
||||
"AND waiver_requested_at IS NOT NULL "
|
||||
"AND waiver_reason IS NOT NULL AND length(trim(waiver_reason)) > 0 "
|
||||
"AND waiver_scope IS NOT NULL AND length(trim(waiver_scope)) > 0 "
|
||||
"AND waiver_expires_at IS NOT NULL "
|
||||
"AND waiver_expires_at > waiver_requested_at)",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_risk_dispositions_waiver_decision",
|
||||
"risk_dispositions",
|
||||
"(waiver_decision IS NULL AND waiver_decider_id IS NULL "
|
||||
"AND waiver_decider_name IS NULL AND waiver_decided_at IS NULL "
|
||||
"AND waiver_decision_reason IS NULL) OR "
|
||||
"(waiver_decision IN ('approved', 'rejected') "
|
||||
"AND waiver_requester_id IS NOT NULL "
|
||||
"AND waiver_decider_id IS NOT NULL "
|
||||
"AND length(trim(waiver_decider_id)) > 0 "
|
||||
"AND waiver_decider_name IS NOT NULL "
|
||||
"AND length(trim(waiver_decider_name)) > 0 "
|
||||
"AND waiver_decided_at IS NOT NULL "
|
||||
"AND waiver_decision_reason IS NOT NULL "
|
||||
"AND length(trim(waiver_decision_reason)) > 0)",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_risk_dispositions_waiver_lifecycle",
|
||||
"risk_dispositions",
|
||||
"(lifecycle_status != 'waived' OR waiver_decision = 'approved') AND "
|
||||
"(lifecycle_status != 'waiver_rejected' OR waiver_decision = 'rejected')",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_risk_dispositions_tenant_waiver_expiry",
|
||||
"risk_dispositions",
|
||||
["tenant_id", "lifecycle_status", "waiver_expires_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.drop_constraint(
|
||||
"ck_risk_disposition_events_action",
|
||||
"risk_disposition_events",
|
||||
type_="check",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_risk_disposition_events_action",
|
||||
"risk_disposition_events",
|
||||
"action IN ('confirm', 'false_positive', 'request_supplement', "
|
||||
"'start_remediation', 'resolve', 'request_waiver', "
|
||||
"'approve_waiver', 'reject_waiver')",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_require_postgresql()
|
||||
# 申请与决定元数据已经进入不可变事件快照时,禁止静默丢弃审计链。
|
||||
_require_no_waiver_audit_data_for_downgrade()
|
||||
op.drop_constraint(
|
||||
"ck_risk_disposition_events_action",
|
||||
"risk_disposition_events",
|
||||
type_="check",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_risk_disposition_events_action",
|
||||
"risk_disposition_events",
|
||||
"action IN ('confirm', 'false_positive', 'request_supplement', "
|
||||
"'start_remediation', 'resolve', 'request_waiver')",
|
||||
)
|
||||
|
||||
op.drop_index(
|
||||
"ix_risk_dispositions_tenant_waiver_expiry",
|
||||
table_name="risk_dispositions",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_risk_dispositions_waiver_lifecycle",
|
||||
"risk_dispositions",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_risk_dispositions_waiver_decision",
|
||||
"risk_dispositions",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_risk_dispositions_waiver_request",
|
||||
"risk_dispositions",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_risk_dispositions_lifecycle",
|
||||
"risk_dispositions",
|
||||
type_="check",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_risk_dispositions_lifecycle",
|
||||
"risk_dispositions",
|
||||
"lifecycle_status IN ('open', 'supplement_requested', "
|
||||
"'remediation_in_progress', 'waiver_requested', 'resolved')",
|
||||
)
|
||||
|
||||
for column_name in (
|
||||
"waiver_decision_reason",
|
||||
"waiver_decided_at",
|
||||
"waiver_decider_name",
|
||||
"waiver_decider_id",
|
||||
"waiver_decision",
|
||||
"waiver_conditions_json",
|
||||
"waiver_expires_at",
|
||||
"waiver_scope",
|
||||
"waiver_reason",
|
||||
"waiver_requested_at",
|
||||
"waiver_requester_name",
|
||||
"waiver_requester_id",
|
||||
):
|
||||
op.drop_column("risk_dispositions", column_name)
|
||||
Reference in New Issue
Block a user