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

@@ -24,11 +24,13 @@ from app.db.migration_preflight import (
)
from app.db.schema_ownership import MIGRATION_OWNED_TABLES, create_legacy_schema
from app.models.ai_memory import MemoryEntry
from app.models.approval_task import ApprovalTask, ApprovalTaskEvent
from app.models.risk_disposition import RiskDisposition, RiskDispositionEvent
from app.models.risk_observation import RiskObservation
MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip()
LEGACY_PROBE_TABLE = "legacy_migration_probe_records"
HEAD_REVISION = "20260716_0012"
HEAD_REVISION = "20260716_0014"
SERVER_DIR = Path(__file__).resolve().parents[1]
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
@@ -424,6 +426,11 @@ def _assert_head_schema(engine: Engine) -> None:
"due_at",
),
"ix_risk_dispositions_assignee": ("tenant_id", "assignee"),
"ix_risk_dispositions_tenant_waiver_expiry": (
"tenant_id",
"lifecycle_status",
"waiver_expires_at",
),
},
)
_assert_check_constraint(
@@ -441,6 +448,21 @@ def _assert_head_schema(engine: Engine) -> None:
"risk_dispositions",
"ck_risk_dispositions_version",
)
_assert_check_constraint(
engine,
"risk_dispositions",
"ck_risk_dispositions_waiver_request",
)
_assert_check_constraint(
engine,
"risk_dispositions",
"ck_risk_dispositions_waiver_decision",
)
_assert_check_constraint(
engine,
"risk_dispositions",
"ck_risk_dispositions_waiver_lifecycle",
)
_assert_unique_constraint(
engine,
"risk_disposition_events",
@@ -495,6 +517,24 @@ def _assert_head_schema(engine: Engine) -> None:
)
}
assert "response_json" in risk_disposition_event_columns
risk_disposition_columns = {
str(item["name"])
for item in inspect(engine).get_columns("risk_dispositions", schema="public")
}
assert {
"waiver_requester_id",
"waiver_requester_name",
"waiver_requested_at",
"waiver_reason",
"waiver_scope",
"waiver_expires_at",
"waiver_conditions_json",
"waiver_decision",
"waiver_decider_id",
"waiver_decider_name",
"waiver_decided_at",
"waiver_decision_reason",
}.issubset(risk_disposition_columns)
with engine.connect() as connection:
append_only_trigger_count = int(
connection.scalar(
@@ -509,6 +549,151 @@ def _assert_head_schema(engine: Engine) -> None:
or 0
)
assert append_only_trigger_count == 1
_assert_unique_constraint(
engine,
"approval_tasks",
"uq_approval_tasks_tenant_id",
("tenant_id", "id"),
)
_assert_unique_constraint(
engine,
"approval_tasks",
"uq_approval_tasks_tenant_node_entry",
("tenant_id", "node_entry_key"),
)
_assert_unique_constraint(
engine,
"approval_tasks",
"uq_approval_tasks_node_participant",
("tenant_id", "node_instance_id", "assignee_kind", "assignee_key"),
)
_assert_unique_constraint(
engine,
"approval_task_events",
"uq_approval_task_events_actor_request",
("tenant_id", "actor_id", "request_id"),
)
_assert_unique_constraint(
engine,
"approval_task_events",
"uq_approval_task_events_task_version",
("tenant_id", "task_id", "result_task_version"),
)
_assert_composite_foreign_key(
engine,
"approval_tasks",
("tenant_id", "parent_task_id"),
"approval_tasks",
)
_assert_composite_foreign_key(
engine,
"approval_tasks",
("tenant_id", "expense_case_id"),
"expense_cases",
)
_assert_composite_foreign_key(
engine,
"approval_task_events",
("tenant_id", "task_id"),
"approval_tasks",
)
_assert_no_foreign_key(
engine,
"approval_tasks",
("claim_id",),
"expense_claims",
)
_assert_indexes(
engine,
"approval_tasks",
{
"uq_approval_tasks_open_root_per_claim": ("tenant_id", "claim_id"),
"ix_approval_tasks_personal_inbox": (
"tenant_id",
"assignee_kind",
"assignee_key",
"status",
"due_at",
),
"ix_approval_tasks_tenant_queue": (
"tenant_id",
"status",
"priority_score",
"due_at",
),
"ix_approval_tasks_tenant_claim": (
"tenant_id",
"claim_id",
"node_sequence",
),
"ix_approval_tasks_tenant_node": (
"tenant_id",
"node_instance_id",
"sequence_order",
),
},
)
_assert_postgresql_index_predicate(
engine,
"approval_tasks",
"uq_approval_tasks_open_root_per_claim",
"task_kind",
"root",
"status",
"waiting",
"pending",
)
_assert_indexes(
engine,
"approval_task_events",
{
"ix_approval_task_events_tenant_task_time": (
"tenant_id",
"task_id",
"occurred_at",
),
"ix_approval_task_events_tenant_node_time": (
"tenant_id",
"node_instance_id",
"occurred_at",
),
},
)
for constraint_name in (
"ck_approval_tasks_task_kind",
"ck_approval_tasks_status",
"ck_approval_tasks_version",
"ck_approval_tasks_priority_score",
"ck_approval_tasks_evidence_completeness",
"ck_approval_task_events_type",
"ck_approval_task_events_actor_type",
"ck_approval_task_events_version",
):
table_name = (
"approval_task_events"
if constraint_name.startswith("ck_approval_task_events")
else "approval_tasks"
)
_assert_check_constraint(engine, table_name, constraint_name)
approval_event_columns = {
str(item["name"])
for item in inspect(engine).get_columns("approval_task_events", schema="public")
}
assert "response_json" in approval_event_columns
with engine.connect() as connection:
approval_append_only_trigger_count = int(
connection.scalar(
text(
"SELECT COUNT(*) FROM pg_trigger trigger "
"JOIN pg_class relation ON relation.oid = trigger.tgrelid "
"WHERE relation.relname = 'approval_task_events' "
"AND trigger.tgname = 'trg_approval_task_events_append_only' "
"AND NOT trigger.tgisinternal"
)
)
or 0
)
assert approval_append_only_trigger_count == 1
_assert_check_constraint(
engine,
"memory_entries",
@@ -1335,6 +1520,10 @@ def _assert_base_schema(engine: Engine) -> None:
("20260716_0011_risk_disposition.py", "downgrade"),
("20260716_0012_risk_disposition_response_snapshot.py", "upgrade"),
("20260716_0012_risk_disposition_response_snapshot.py", "downgrade"),
("20260716_0013_approval_tasks.py", "upgrade"),
("20260716_0013_approval_tasks.py", "downgrade"),
("20260716_0014_risk_waiver_decision.py", "upgrade"),
("20260716_0014_risk_waiver_decision.py", "downgrade"),
],
)
def test_postgresql_only_migrations_reject_other_dialects_before_mutation(
@@ -1363,6 +1552,30 @@ def test_risk_disposition_snapshot_migration_refuses_lossy_downgrade() -> None:
assert operation_guard.mutation_calls == []
def test_approval_task_migration_refuses_non_empty_audit_chain_downgrade() -> None:
migration = _load_migration_module("20260716_0013_approval_tasks.py")
operation_guard = _UnsupportedDialectOperationGuard(dialect_name="postgresql")
operation_guard.bind.scalar = lambda _statement: 1
migration.op = operation_guard
with pytest.raises(RuntimeError, match="audit chain is not empty"):
migration.downgrade()
assert operation_guard.mutation_calls == []
def test_risk_waiver_migration_refuses_lossy_audit_downgrade() -> None:
migration = _load_migration_module("20260716_0014_risk_waiver_decision.py")
operation_guard = _UnsupportedDialectOperationGuard(dialect_name="postgresql")
operation_guard.bind.scalar = lambda _statement: 1
migration.op = operation_guard
with pytest.raises(RuntimeError, match="immutable waiver audit data exists"):
migration.downgrade()
assert operation_guard.mutation_calls == []
def test_head_model_declares_soft_claim_reference_and_organization_only_active_index() -> None:
claim_column = RiskObservation.__table__.c.claim_id
assert not claim_column.foreign_keys
@@ -1379,6 +1592,83 @@ def test_head_model_declares_soft_claim_reference_and_organization_only_active_i
assert "scope_type IN ('department', 'enterprise')" in predicate
def test_approval_task_model_declares_tenant_safety_and_open_root_invariant() -> None:
claim_column = ApprovalTask.__table__.c.claim_id
assert not claim_column.foreign_keys
assert inspect(ApprovalTask).relationships["claim"].viewonly is True
constraint_names = {
constraint.name for constraint in ApprovalTask.__table__.constraints
}
assert {
"uq_approval_tasks_tenant_id",
"uq_approval_tasks_tenant_node_entry",
"uq_approval_tasks_node_participant",
"fk_approval_tasks_tenant_parent",
"ck_approval_tasks_status",
"ck_approval_tasks_version",
"ck_approval_tasks_priority_score",
"ck_approval_tasks_evidence_completeness",
}.issubset(constraint_names)
open_root_index = next(
index
for index in ApprovalTask.__table__.indexes
if index.name == "uq_approval_tasks_open_root_per_claim"
)
assert open_root_index.unique is True
predicate = str(open_root_index.dialect_options["postgresql"]["where"])
assert "task_kind = 'root'" in predicate
assert "status IN ('waiting', 'pending')" in predicate
event_constraint_names = {
constraint.name for constraint in ApprovalTaskEvent.__table__.constraints
}
assert {
"uq_approval_task_events_actor_request",
"uq_approval_task_events_task_version",
"fk_approval_task_events_tenant_task",
"ck_approval_task_events_version",
}.issubset(event_constraint_names)
assert ApprovalTaskEvent.__table__.c.response_json.nullable is False
def test_risk_waiver_model_declares_decision_metadata_constraints() -> None:
constraint_names = {
constraint.name for constraint in RiskDisposition.__table__.constraints
}
assert {
"ck_risk_dispositions_waiver_request",
"ck_risk_dispositions_waiver_decision",
"ck_risk_dispositions_waiver_lifecycle",
}.issubset(constraint_names)
lifecycle_constraint = next(
constraint
for constraint in RiskDisposition.__table__.constraints
if constraint.name == "ck_risk_dispositions_lifecycle"
)
assert "waived" in str(lifecycle_constraint.sqltext)
assert "waiver_rejected" in str(lifecycle_constraint.sqltext)
waiver_index = next(
index
for index in RiskDisposition.__table__.indexes
if index.name == "ix_risk_dispositions_tenant_waiver_expiry"
)
assert tuple(column.name for column in waiver_index.columns) == (
"tenant_id",
"lifecycle_status",
"waiver_expires_at",
)
event_action_constraint = next(
constraint
for constraint in RiskDispositionEvent.__table__.constraints
if constraint.name == "ck_risk_disposition_events_action"
)
assert "approve_waiver" in str(event_action_constraint.sqltext)
assert "reject_waiver" in str(event_action_constraint.sqltext)
@pytest.mark.parametrize(
"database_url",
[