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",
[

View File

@@ -0,0 +1,436 @@
from __future__ import annotations
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
import pytest
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.approval_task import ApprovalTask, ApprovalTaskEvent
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.role import Role
from app.schemas.approval_task import (
ApprovalTaskAssignmentAction,
ApprovalTaskDecisionAction,
ApprovalTaskParticipantsAction,
)
from app.schemas.reimbursement import ExpenseClaimRead
from app.services.approval_task_actions import ApprovalTaskActionService
from app.services.approval_task_lifecycle import ApprovalTaskLifecycleService
from app.services.approval_task_protocol import ApprovalTaskVersionConflictError
from app.services.approval_task_query import ApprovalTaskQueryService
from app.services.approval_task_sla import ApprovalTaskSlaService
from app.services.expense_cases import ExpenseCaseService
from app.services.expense_claims import ExpenseClaimService
from app.test_helpers.db import build_in_memory_session_factory
def _user(
employee: Employee,
*,
tenant_id: str = "tenant-approval-task",
roles: list[str] | None = None,
is_admin: bool = False,
) -> CurrentUserContext:
return CurrentUserContext(
username=employee.email,
name=employee.name,
role_codes=list(roles or []),
is_admin=is_admin,
tenant_id=tenant_id,
employee_id=employee.id,
employee_no=employee.employee_no,
)
def _seed_root_task(
db: Session,
*,
tenant_id: str = "tenant-approval-task",
extra_managers: int = 1,
) -> tuple[ExpenseClaim, ApprovalTask, Employee, list[Employee]]:
manager_role = Role(
id="role-task-manager",
role_code="manager",
name="任务审批经理",
)
manager = Employee(
id="employee-task-manager",
employee_no="M-TASK-001",
name="李经理",
email="manager-task@example.com",
roles=[manager_role],
)
participants = [
Employee(
id=f"employee-task-participant-{index}",
employee_no=f"M-TASK-{index + 1:03d}",
name=f"加签经理{index}",
email=f"participant-task-{index}@example.com",
roles=[manager_role],
)
for index in range(1, extra_managers + 1)
]
employee = Employee(
id="employee-task-owner",
employee_no="E-TASK-001",
name="张三",
email="owner-task@example.com",
manager=manager,
)
occurred_at = datetime.now(UTC) - timedelta(hours=1)
claim = ExpenseClaim(
id="claim-task-001",
claim_no="RE-TASK-001",
employee=employee,
employee_name=employee.name,
department_name="市场部",
expense_type="transport",
reason="客户现场差旅",
location="上海",
amount=Decimal("88.00"),
currency="CNY",
invoice_count=1,
occurred_at=occurred_at,
submitted_at=occurred_at,
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
created_at=occurred_at,
updated_at=occurred_at,
)
claim.items = [
ExpenseClaimItem(
id="claim-task-item-001",
claim_id=claim.id,
item_date=date.today(),
item_type="transport",
item_reason="客户现场交通",
item_location="上海",
item_note="",
item_amount=Decimal("88.00"),
invoice_id="INV-TASK-001",
)
]
db.add_all([claim, *participants])
db.flush()
_, event = ExpenseCaseService(db).record_claim_event(
claim,
event_type="claim_submitted",
actor_id=employee.email,
tenant_id=tenant_id,
idempotency_key="claim-task-submitted-001",
previous_status="draft",
previous_approval_stage="待提交",
)
task = ApprovalTaskLifecycleService(db).ensure_root_task(
claim,
tenant_id=tenant_id,
entered_at=event.occurred_at,
entered_at_source="workflow_event",
business_event=event,
request_id=f"node-enter:{event.id}",
)
assert task is not None
db.commit()
return claim, task, manager, participants
def test_queue_is_tenant_safe_and_admin_has_no_implicit_approval() -> None:
factory = build_in_memory_session_factory()
with factory() as db:
_claim, task, manager, _participants = _seed_root_task(db)
manager_queue = ApprovalTaskQueryService(db).list_tasks(
_user(manager, roles=["manager"]),
)
assert manager_queue.total == 1
assert manager_queue.items[0].task.id == task.id
assert manager_queue.items[0].task.can_act is True
assert "approve" in manager_queue.items[0].task.available_actions
admin = Employee(
id="employee-task-admin",
employee_no="A-TASK-001",
name="平台管理员",
email="admin-task@example.com",
)
db.add(admin)
db.commit()
admin_queue = ApprovalTaskQueryService(db).list_tasks(
_user(admin, is_admin=True),
)
assert admin_queue.total == 1
assert admin_queue.items[0].task.can_act is False
assert "approve" not in admin_queue.items[0].task.available_actions
assert set(admin_queue.items[0].task.available_actions) >= {
"transfer",
"sla_escalate",
}
other_tenant_queue = ApprovalTaskQueryService(db).list_tasks(
_user(manager, tenant_id="tenant-other", roles=["manager"]),
)
assert other_tenant_queue.total == 0
def test_delegated_assignee_can_approve_claim_and_replay_is_immutable() -> None:
factory = build_in_memory_session_factory()
with factory() as db:
claim, task, manager, participants = _seed_root_task(db)
delegate = participants[0]
action_service = ApprovalTaskActionService(db)
payload = ApprovalTaskAssignmentAction(
request_id="delegate-task-001",
expected_task_version=1,
reason="出差期间委托处理",
target_employee_id=delegate.id,
expires_at=datetime.now(UTC) + timedelta(days=1),
)
first = action_service.assign(
task.id,
_user(manager, roles=["manager"]),
action="delegate",
payload=payload,
)
replay = action_service.assign(
task.id,
_user(manager, roles=["manager"]),
action="delegate",
payload=payload,
)
assert first.task.version == 2
assert first.task.assignee_employee_id == delegate.id
assert replay.replayed is True
assert replay.task.model_dump(mode="json") == first.task.model_dump(mode="json")
approved = ExpenseClaimService(db).approve_claim(
claim.id,
_user(delegate, roles=["manager"]),
opinion="已核对材料,同意",
request_id="approve-delegated-task-001",
expected_status="submitted",
expected_approval_stage="直属领导审批",
task_id=task.id,
expected_task_version=2,
)
action_replay = ExpenseClaimService(db).approve_claim(
claim.id,
_user(delegate, roles=["manager"]),
opinion="已核对材料,同意",
request_id="approve-delegated-task-001",
expected_status="submitted",
expected_approval_stage="直属领导审批",
task_id=task.id,
expected_task_version=2,
)
assert approved is not None and action_replay is not None
assert ExpenseClaimRead.model_validate(approved).model_dump(mode="json") == (
ExpenseClaimRead.model_validate(action_replay).model_dump(mode="json")
)
persisted = db.get(ApprovalTask, task.id)
assert persisted is not None
assert persisted.status == "completed"
assert persisted.version == 3
next_root = db.scalar(
select(ApprovalTask).where(
ApprovalTask.claim_id == claim.id,
ApprovalTask.task_kind == "root",
ApprovalTask.status == "pending",
)
)
assert next_root is not None
assert next_root.node_key == "finance"
assert (
db.scalar(
select(func.count())
.select_from(ApprovalTaskEvent)
.where(
ApprovalTaskEvent.task_id == task.id,
ApprovalTaskEvent.event_type == "task_approved",
)
)
== 1
)
def test_sequential_add_sign_reactivates_root_after_child_approval() -> None:
factory = build_in_memory_session_factory()
with factory() as db:
claim, root, manager, participants = _seed_root_task(db)
participant = participants[0]
service = ApprovalTaskActionService(db)
started = service.add_participants(
root.id,
_user(manager, roles=["manager"]),
action="add_sign",
payload=ApprovalTaskParticipantsAction(
request_id="add-sign-task-001",
expected_task_version=1,
reason="需要业务负责人补充复核",
participant_employee_ids=[participant.id],
),
)
assert started.task.status == "waiting"
assert len(started.related_tasks) == 1
child = started.related_tasks[0]
assert child.status == "pending"
completed = service.approve_child(
child.id,
_user(participant, roles=["manager"]),
ApprovalTaskDecisionAction(
request_id="approve-add-sign-task-001",
expected_task_version=1,
reason="补充复核完成",
opinion="材料真实完整",
),
)
assert completed.task.status == "completed"
refreshed_root = db.get(ApprovalTask, root.id)
assert refreshed_root is not None
assert refreshed_root.status == "pending"
assert refreshed_root.coordination_mode == "single"
assert refreshed_root.version == 3
assert refreshed_root.claim_stage_snapshot == claim.approval_stage
def test_parallel_countersign_waits_for_every_participant() -> None:
factory = build_in_memory_session_factory()
with factory() as db:
_claim, root, manager, participants = _seed_root_task(db, extra_managers=2)
service = ApprovalTaskActionService(db)
started = service.add_participants(
root.id,
_user(manager, roles=["manager"]),
action="countersign",
payload=ApprovalTaskParticipantsAction(
request_id="countersign-task-001",
expected_task_version=1,
reason="需要双人并行复核",
participant_employee_ids=[item.id for item in participants],
),
)
children = started.related_tasks
assert len(children) == 2
assert {item.status for item in children} == {"pending"}
service.approve_child(
children[0].id,
_user(participants[0], roles=["manager"]),
ApprovalTaskDecisionAction(
request_id="approve-countersign-task-001",
expected_task_version=1,
reason="第一位复核完成",
),
)
db.refresh(root)
assert root.status == "waiting"
service.approve_child(
children[1].id,
_user(participants[1], roles=["manager"]),
ApprovalTaskDecisionAction(
request_id="approve-countersign-task-002",
expected_task_version=1,
reason="第二位复核完成",
),
)
db.refresh(root)
assert root.status == "pending"
assert root.version == 3
def test_participant_return_cancels_open_node_family_and_returns_claim() -> None:
factory = build_in_memory_session_factory()
with factory() as db:
claim, root, manager, participants = _seed_root_task(db, extra_managers=2)
started = ApprovalTaskActionService(db).add_participants(
root.id,
_user(manager, roles=["manager"]),
action="countersign",
payload=ApprovalTaskParticipantsAction(
request_id="countersign-return-task-001",
expected_task_version=1,
reason="并行复核后再决定",
participant_employee_ids=[item.id for item in participants],
),
)
returned_child, sibling = started.related_tasks
returned = ExpenseClaimService(db).return_claim(
claim.id,
_user(participants[0], roles=["manager"]),
reason="材料与实际行程不一致",
request_id="return-countersign-task-001",
expected_status="submitted",
expected_approval_stage="直属领导审批",
task_id=returned_child.id,
expected_task_version=1,
)
assert returned is not None
assert returned.status == "returned"
assert db.get(ApprovalTask, returned_child.id).status == "returned"
assert db.get(ApprovalTask, sibling.id).status == "cancelled"
assert db.get(ApprovalTask, root.id).status == "cancelled"
def test_task_action_rejects_stale_version() -> None:
factory = build_in_memory_session_factory()
with factory() as db:
_claim, task, manager, participants = _seed_root_task(db)
with pytest.raises(ApprovalTaskVersionConflictError) as captured:
ApprovalTaskActionService(db).assign(
task.id,
_user(manager, roles=["manager"]),
action="transfer",
payload=ApprovalTaskAssignmentAction(
request_id="transfer-task-stale-001",
expected_task_version=99,
reason="测试过期版本保护",
target_employee_id=participants[0].id,
),
)
assert captured.value.current_version == 1
def test_sla_scheduler_escalates_due_task_once_per_window() -> None:
factory = build_in_memory_session_factory()
with factory() as db:
_claim, task, _manager, _participants = _seed_root_task(db)
now = datetime.now(UTC)
task.next_escalation_at = now - timedelta(minutes=1)
db.commit()
service = ApprovalTaskSlaService(db)
first = service.escalate_due_tasks(now=now)
repeated = service.escalate_due_tasks(now=now)
db.commit()
assert first == [task.id]
assert repeated == []
db.refresh(task)
assert task.escalation_level == 1
assert task.version == 2
persisted_next = task.next_escalation_at
assert persisted_next is not None
if persisted_next.tzinfo is None:
persisted_next = persisted_next.replace(tzinfo=UTC)
assert persisted_next == now + timedelta(hours=4)
event = db.scalar(
select(ApprovalTaskEvent).where(
ApprovalTaskEvent.task_id == task.id,
ApprovalTaskEvent.event_type == "sla_escalated",
)
)
assert event is not None
assert event.response_json["task"]["escalation_level"] == 1

View File

@@ -0,0 +1,409 @@
from __future__ import annotations
import argparse
import importlib.util
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from pathlib import Path
import pytest
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.models.approval_task import ApprovalTask, ApprovalTaskEvent
from app.models.employee import Employee
from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.schemas.approval_task import ApprovalTaskMutationRead
from app.services.approval_task_backfill import (
ApprovalTaskBackfillDisposition,
ApprovalTaskBackfillPreview,
ApprovalTaskBackfillService,
)
from app.services.approval_task_protocol import ApprovalTaskEventProtocol
from app.services.expense_claim_workflow_constants import (
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
)
def _load_cli_module():
path = Path(__file__).resolve().parents[1] / "scripts" / "backfill_approval_tasks.py"
spec = importlib.util.spec_from_file_location("backfill_approval_tasks_cli", path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
CLI = _load_cli_module()
def _session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)()
def _persist_claim(
db: Session,
*,
suffix: str,
tenant_id: str,
stage: str = DIRECT_MANAGER_APPROVAL_STAGE,
status: str = "submitted",
with_manager: bool = True,
with_event: bool = True,
) -> tuple[ExpenseClaim, Employee | None, BusinessEvent | None]:
submitted_at = datetime(2026, 7, 14, 10, 0, tzinfo=UTC)
manager = (
Employee(
id=f"manager-{suffix}",
employee_no=f"M-{suffix}",
name=f"经理{suffix}",
email=f"manager-{suffix}@example.com",
)
if with_manager
else None
)
employee = Employee(
id=f"employee-{suffix}",
employee_no=f"E-{suffix}",
name=f"员工{suffix}",
email=f"employee-{suffix}@example.com",
manager=manager,
)
claim = ExpenseClaim(
id=f"claim-{suffix}",
claim_no=f"RE-BACKFILL-{suffix}",
employee=employee,
employee_name=employee.name,
department_name="市场部",
project_code="PRJ-BACKFILL",
expense_type="travel",
reason="客户现场差旅",
location="上海",
amount=Decimal("888.00"),
currency="CNY",
invoice_count=1,
occurred_at=submitted_at - timedelta(days=1),
submitted_at=submitted_at,
status=status,
approval_stage=stage,
risk_flags_json=[],
created_at=submitted_at - timedelta(hours=2),
updated_at=submitted_at,
)
claim.items = [
ExpenseClaimItem(
id=f"item-{suffix}",
claim_id=claim.id,
item_date=date(2026, 7, 14),
item_type="transport",
item_reason="客户现场交通",
item_location="上海",
item_note="",
item_amount=Decimal("888.00"),
invoice_id=f"invoice-{suffix}",
created_at=submitted_at,
updated_at=submitted_at,
)
]
expense_case = ExpenseCase(
id=f"case-{suffix}",
tenant_id=tenant_id,
case_no=f"CASE-RE-BACKFILL-{suffix}",
scene_code="travel",
title="客户现场差旅",
owner_employee_id=employee.id,
current_stage="reviewing",
status="active",
created_at=submitted_at - timedelta(hours=2),
updated_at=submitted_at,
)
link = ExpenseCaseLink(
id=f"link-{suffix}",
tenant_id=tenant_id,
expense_case_id=expense_case.id,
resource_type="expense_claim",
resource_id=claim.id,
relation_type="claim",
created_at=submitted_at,
)
event = None
if with_event:
event = BusinessEvent(
id=f"event-{suffix}",
tenant_id=tenant_id,
expense_case_id=expense_case.id,
aggregate_type="expense_claim",
aggregate_id=claim.id,
event_type="claim_submitted",
event_version=1,
idempotency_key=f"submit-{suffix}",
correlation_id=f"correlation-{suffix}",
actor_id=employee.id,
actor_type="user",
payload_json={
"next_status": "submitted",
"next_approval_stage": stage,
},
delivery_status="published",
occurred_at=submitted_at + timedelta(hours=1),
)
db.add_all(
[
*([manager] if manager is not None else []),
employee,
claim,
expense_case,
link,
*([event] if event is not None else []),
]
)
db.commit()
return claim, manager, event
def test_preview_is_default_read_only_tenant_scoped_and_prefers_business_event() -> None:
with _session() as db:
claim, manager, event = _persist_claim(db, suffix="A", tenant_id="tenant-a")
_persist_claim(db, suffix="B", tenant_id="tenant-b")
assert manager is not None and event is not None
service = ApprovalTaskBackfillService(db, tenant_id="tenant-a")
result = service.run()
assert isinstance(result, ApprovalTaskBackfillPreview)
assert result.inspected == 1
assert result.eligible == 1
assert result.existing == 0
assert result.skipped == 0
assert result.items[0].claim_id == claim.id
assert result.items[0].entered_at_source == "workflow_event"
assert result.items[0].entered_at == event.occurred_at
assert result.items[0].assignee_key == manager.id
assert db.scalar(select(func.count()).select_from(ApprovalTask)) == 0
assert db.scalar(select(func.count()).select_from(ApprovalTaskEvent)) == 0
def test_apply_creates_audited_projected_root_task_and_is_idempotent() -> None:
applied_at = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
with _session() as db:
claim, manager, source_event = _persist_claim(
db,
suffix="APPLY",
tenant_id="tenant-a",
)
assert manager is not None and source_event is not None
service = ApprovalTaskBackfillService(db, tenant_id="tenant-a")
first = service.apply_batch(
run_id="approval-task-backfill-run-1",
backfilled_at=applied_at,
)
repeated = service.apply_batch(
run_id="approval-task-backfill-run-1",
backfilled_at=applied_at,
)
task = db.scalar(select(ApprovalTask).where(ApprovalTask.claim_id == claim.id))
event = db.scalar(select(ApprovalTaskEvent).where(ApprovalTaskEvent.task_id == task.id))
assert first.created == 1
assert repeated.created == 0
assert repeated.existing == 1
assert task.task_kind == "root"
assert task.status == "pending"
assert task.tenant_id == "tenant-a"
assert task.expense_case_id == "case-APPLY"
assert task.node_key == "direct_manager"
assert task.assignee_employee_id == manager.id
assert task.entered_at_source == "workflow_event"
assert task.due_at > task.entered_at
assert task.projection_updated_at.replace(tzinfo=UTC) == applied_at
assert task.risk_level == "low"
assert task.evidence_completeness == Decimal("1.0000")
assert task.batch_eligible is False
assert "sla_overdue" in task.batch_block_reasons_json
assert event.event_type == "node_entered_backfilled"
assert event.expected_task_version == 0
assert event.result_task_version == 1
assert event.business_event_id == source_event.id
assert event.payload_json["history_reconstructed"] is False
assert event.payload_json["current_node_reconstructed"] is True
assert event.response_json["task"]["id"] == task.id
snapshot = ApprovalTaskMutationRead.model_validate(event.response_json)
replay = ApprovalTaskEventProtocol(db).find_replay(
tenant_id="tenant-a",
actor_id="approval-task-backfill",
request_id=event.request_id,
fingerprint=event.payload_fingerprint,
)
assert snapshot.event.id == event.id
assert replay is not None and replay.replayed is True
assert replay.task.id == task.id
assert db.scalar(select(func.count()).select_from(ApprovalTask)) == 1
assert db.scalar(select(func.count()).select_from(ApprovalTaskEvent)) == 1
def test_unresolved_direct_manager_is_fail_closed_but_finance_role_is_safe_fallback() -> None:
with _session() as db:
unresolved, _manager, _event = _persist_claim(
db,
suffix="NO-MANAGER",
tenant_id="tenant-a",
with_manager=False,
)
inactive, inactive_manager, _event = _persist_claim(
db,
suffix="INACTIVE-MANAGER",
tenant_id="tenant-a",
)
assert inactive_manager is not None
inactive_manager.employment_status = "离职"
db.commit()
finance, _manager, _event = _persist_claim(
db,
suffix="FINANCE",
tenant_id="tenant-a",
stage=FINANCE_APPROVAL_STAGE,
with_manager=False,
)
service = ApprovalTaskBackfillService(db, tenant_id="tenant-a")
preview = service.preview()
by_claim = {item.claim_id: item for item in preview.items}
assert by_claim[unresolved.id].disposition is (
ApprovalTaskBackfillDisposition.ASSIGNEE_UNRESOLVED
)
assert by_claim[inactive.id].disposition is (
ApprovalTaskBackfillDisposition.ASSIGNEE_UNRESOLVED
)
assert by_claim[finance.id].disposition is ApprovalTaskBackfillDisposition.ELIGIBLE
assert by_claim[finance.id].assignee_key == "finance"
assert preview.eligible == 1
assert preview.skipped == 2
def test_legacy_in_approval_state_is_canonicalized_without_mutating_claim() -> None:
with _session() as db:
claim, manager, _event = _persist_claim(
db,
suffix="LEGACY",
tenant_id="tenant-a",
status="in_approval",
stage="",
with_event=False,
)
assert manager is not None
service = ApprovalTaskBackfillService(db, tenant_id="tenant-a")
preview = service.preview()
applied = service.apply_batch(
run_id="approval-task-backfill-legacy",
backfilled_at=datetime(2026, 7, 16, 12, 0, tzinfo=UTC),
)
task = db.scalar(select(ApprovalTask).where(ApprovalTask.claim_id == claim.id))
assert preview.eligible == 1
assert preview.items[0].entered_at_source == "submitted_at"
assert applied.created == 1
assert task.claim_status_snapshot == "submitted"
assert task.claim_stage_snapshot == DIRECT_MANAGER_APPROVAL_STAGE
assert task.assignee_employee_id == manager.id
assert claim.status == "in_approval"
assert claim.approval_stage == ""
def test_entered_at_fallback_order_is_explicit_and_deterministic() -> None:
submitted_at = datetime(2026, 7, 14, 10, 0, tzinfo=UTC)
claim = ExpenseClaim(
id="claim-time-fallback",
claim_no="RE-TIME-FALLBACK",
employee_name="张三",
department_name="市场部",
expense_type="travel",
reason="差旅",
location="上海",
amount=Decimal("100"),
occurred_at=submitted_at - timedelta(days=1),
submitted_at=submitted_at,
status="submitted",
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
created_at=submitted_at - timedelta(hours=2),
updated_at=submitted_at - timedelta(hours=1),
)
with _session() as db:
service = ApprovalTaskBackfillService(db, tenant_id="tenant-a")
entered_at, source, _event = service.derive_entered_at(
claim,
events=[],
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
)
assert (entered_at, source) == (submitted_at, "submitted_at")
claim.submitted_at = None
entered_at, source, _event = service.derive_entered_at(
claim,
events=[],
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
)
assert (entered_at, source) == (claim.updated_at, "updated_at")
claim.updated_at = None
entered_at, source, _event = service.derive_entered_at(
claim,
events=[],
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
)
assert (entered_at, source) == (claim.created_at, "backfill")
def test_backfill_cli_defaults_to_dry_run_and_requires_exact_apply_confirmation(
monkeypatch,
) -> None:
args = CLI.build_parser().parse_args(
[
"--tenant-id",
"tenant-a",
"--created-before",
"2026-07-16T00:00:00+08:00",
"--expected-host",
"migration-probe",
"--expected-database",
"migration_probe",
]
)
assert args.apply is False
assert args.created_before == datetime(2026, 7, 15, 16, 0, tzinfo=UTC)
apply_args = CLI.build_parser().parse_args(
[
"--apply",
"--tenant-id",
"tenant-a",
"--created-before",
"2026-07-16T00:00:00Z",
"--expected-host",
"migration-probe",
"--expected-database",
"migration_probe",
]
)
monkeypatch.delenv("DATABASE_URL", raising=False)
with pytest.raises(CLI.BackfillCommandError) as exc_info:
CLI.run(apply_args)
assert exc_info.value.code == "confirm_target_required"
@pytest.mark.parametrize("value", ["", "2026-07-16T00:00:00", "not-a-time"])
def test_backfill_cli_rejects_timestamp_without_explicit_timezone(value: str) -> None:
with pytest.raises(argparse.ArgumentTypeError):
CLI.parse_timestamp(value)

View File

@@ -0,0 +1,237 @@
from __future__ import annotations
import os
import uuid
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime, timedelta
from decimal import Decimal
import pytest
from sqlalchemy import create_engine, func, select
from sqlalchemy.engine import make_url
from sqlalchemy.orm import Session, sessionmaker
from app.api.deps import CurrentUserContext
from app.db.base import Base
from app.models.approval_task import ApprovalTask, ApprovalTaskEvent
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.models.role import Role
from app.schemas.approval_task import ApprovalTaskAssignmentAction
from app.services.approval_task_actions import ApprovalTaskActionService
from app.services.approval_task_lifecycle import ApprovalTaskLifecycleService
from app.services.approval_task_protocol import ApprovalTaskVersionConflictError
from app.services.expense_cases import ExpenseCaseService
DATABASE_URL = os.environ.get("MIGRATION_TEST_DATABASE_URL", "").strip()
TENANT_ID = "tenant-approval-task-concurrency"
def test_concurrent_identical_delegation_replays_one_immutable_event() -> None:
database_url = _require_disposable_database_url()
engine = create_engine(database_url, pool_pre_ping=True)
Base.metadata.create_all(bind=engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
suffix = uuid.uuid4().hex[:12]
try:
task_id, manager, delegate_ids = _seed_case(factory, suffix=suffix, delegates=1)
payload = ApprovalTaskAssignmentAction(
request_id=f"delegate-concurrent-{suffix}",
expected_task_version=1,
reason="并发幂等委托验证",
target_employee_id=delegate_ids[0],
expires_at=datetime.now(UTC) + timedelta(days=1),
)
def delegate_once():
with factory() as db:
return ApprovalTaskActionService(db).assign(
task_id,
manager,
action="delegate",
payload=payload,
)
with ThreadPoolExecutor(max_workers=2) as pool:
futures = (pool.submit(delegate_once), pool.submit(delegate_once))
responses = [future.result(timeout=10) for future in futures]
assert sorted(response.replayed for response in responses) == [False, True]
assert responses[0].task.model_dump(mode="json") == responses[1].task.model_dump(
mode="json"
)
with factory() as db:
task = db.get(ApprovalTask, task_id)
assert task is not None and task.version == 2
assert task.assignee_employee_id == delegate_ids[0]
assert (
db.scalar(
select(func.count())
.select_from(ApprovalTaskEvent)
.where(
ApprovalTaskEvent.task_id == task_id,
ApprovalTaskEvent.request_id == payload.request_id,
)
)
== 1
)
finally:
engine.dispose()
def test_concurrent_distinct_delegations_allow_only_expected_version_winner() -> None:
database_url = _require_disposable_database_url()
engine = create_engine(database_url, pool_pre_ping=True)
Base.metadata.create_all(bind=engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
suffix = uuid.uuid4().hex[:12]
try:
task_id, manager, delegate_ids = _seed_case(factory, suffix=suffix, delegates=2)
def delegate_once(index: int):
with factory() as db:
try:
return ApprovalTaskActionService(db).assign(
task_id,
manager,
action="delegate",
payload=ApprovalTaskAssignmentAction(
request_id=f"delegate-race-{suffix}-{index}",
expected_task_version=1,
reason="并发版本竞争验证",
target_employee_id=delegate_ids[index],
expires_at=datetime.now(UTC) + timedelta(days=1),
),
)
except ApprovalTaskVersionConflictError as error:
return error
with ThreadPoolExecutor(max_workers=2) as pool:
futures = (pool.submit(delegate_once, 0), pool.submit(delegate_once, 1))
responses = [future.result(timeout=10) for future in futures]
assert sum(not isinstance(item, Exception) for item in responses) == 1
assert sum(isinstance(item, ApprovalTaskVersionConflictError) for item in responses) == 1
with factory() as db:
task = db.get(ApprovalTask, task_id)
assert task is not None and task.version == 2
assert task.assignee_employee_id in set(delegate_ids)
assert (
db.scalar(
select(func.count())
.select_from(ApprovalTaskEvent)
.where(
ApprovalTaskEvent.task_id == task_id,
ApprovalTaskEvent.event_type == "task_delegated",
)
)
== 1
)
finally:
engine.dispose()
def _seed_case(
factory: sessionmaker[Session],
*,
suffix: str,
delegates: int,
) -> tuple[str, CurrentUserContext, list[str]]:
with factory() as db:
role = db.scalar(select(Role).where(Role.role_code == "manager"))
if role is None:
role = Role(
id="role-appr-task-concur-manager",
role_code="manager",
name="并发审批经理",
)
manager = Employee(
id=f"manager-{suffix}",
employee_no=f"M-{suffix}",
name="并发审批经理",
email=f"manager-{suffix}@example.com",
roles=[role],
)
delegate_rows = [
Employee(
id=f"delegate-{suffix}-{index}",
employee_no=f"D-{suffix}-{index}",
name=f"委托审批人{index + 1}",
email=f"delegate-{suffix}-{index}@example.com",
roles=[role],
)
for index in range(delegates)
]
claimant = Employee(
id=f"claimant-{suffix}",
employee_no=f"E-{suffix}",
name="并发报销申请人",
email=f"claimant-{suffix}@example.com",
manager=manager,
)
occurred_at = datetime.now(UTC) - timedelta(hours=1)
claim = ExpenseClaim(
id=f"claim-{suffix}",
claim_no=f"RE-CONCURRENT-{suffix}",
employee=claimant,
employee_name=claimant.name,
department_name="并发验证部",
expense_type="transport",
reason="审批任务并发验证",
location="上海",
amount=Decimal("88.00"),
currency="CNY",
invoice_count=1,
occurred_at=occurred_at,
submitted_at=occurred_at,
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
db.add_all([claim, *delegate_rows])
db.flush()
_, business_event = ExpenseCaseService(db).record_claim_event(
claim,
event_type="claim_submitted",
actor_id=claimant.email,
tenant_id=TENANT_ID,
idempotency_key=f"submit-{suffix}",
previous_status="draft",
previous_approval_stage="待提交",
)
task = ApprovalTaskLifecycleService(db).ensure_root_task(
claim,
tenant_id=TENANT_ID,
entered_at=business_event.occurred_at,
entered_at_source="workflow_event",
business_event=business_event,
request_id=f"node-enter-{suffix}",
)
assert task is not None
db.commit()
return (
task.id,
CurrentUserContext(
username=manager.email,
name=manager.name,
role_codes=["manager"],
is_admin=False,
tenant_id=TENANT_ID,
employee_id=manager.id,
employee_no=manager.employee_no,
),
[row.id for row in delegate_rows],
)
def _require_disposable_database_url() -> str:
if not DATABASE_URL:
pytest.skip("仅在显式配置 MIGRATION_TEST_DATABASE_URL 时运行 PostgreSQL 并发测试")
parsed = make_url(DATABASE_URL)
host = str(parsed.host or "").replace("_", "-").lower()
database = str(parsed.database or "").replace("_", "-").lower()
if not host.startswith(("migration-probe", "disposable-probe")):
raise RuntimeError("并发测试数据库主机必须使用 disposable 前缀")
if not database.startswith(("migration-probe", "disposable-probe")):
raise RuntimeError("并发测试数据库名必须使用 disposable 前缀")
return DATABASE_URL

View File

@@ -0,0 +1,485 @@
from __future__ import annotations
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.models.approval_task import ApprovalTask
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.risk_disposition import RiskDisposition
from app.models.risk_observation import RiskObservation
from app.services.approval_task_projection import ApprovalTaskProjectionService
from app.services.approval_task_projection_refresh import (
ApprovalTaskProjectionRefreshService,
)
from app.services.risk_observations import RiskObservationService
def _claim(
*,
claim_id: str = "claim-projection-1",
claim_no: str = "RE-PROJECTION-1",
amount: str = "888.00",
invoice_count: int = 1,
risk_flags: list[dict] | None = None,
) -> ExpenseClaim:
occurred_at = datetime(2026, 7, 15, 9, 0, tzinfo=UTC)
claim = ExpenseClaim(
id=claim_id,
claim_no=claim_no,
employee_name="张三",
department_name="市场部",
project_code="PRJ-PROJECTION",
expense_type="application" if claim_no.startswith("AP-") else "travel",
reason="客户现场差旅",
location="上海",
amount=Decimal(amount),
currency="CNY",
invoice_count=invoice_count,
occurred_at=occurred_at,
submitted_at=occurred_at + timedelta(hours=1),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=list(risk_flags or []),
created_at=occurred_at,
updated_at=occurred_at + timedelta(hours=1),
)
claim.items = [
ExpenseClaimItem(
id=f"item-{claim_id}",
claim_id=claim_id,
item_date=date(2026, 7, 15),
item_type="hotel",
item_reason="住宿",
item_location="上海",
item_note="",
item_amount=Decimal(amount),
invoice_id="INV-PROJECTION" if invoice_count else None,
created_at=occurred_at,
updated_at=occurred_at,
)
]
return claim
def test_projection_materializes_explainable_priority_and_safe_batch_blocks() -> None:
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
claim = _claim(
amount="60000.00",
risk_flags=[
{
"source": "ai_pre_review",
"severity": "high",
"disposition": "review",
"route_decision": {"budget_result": {"metrics": {"after_usage_rate": "0.96"}}},
}
],
)
projection = ApprovalTaskProjectionService(None).build( # type: ignore[arg-type]
claim,
tenant_id="tenant-a",
entered_at=now - timedelta(hours=26),
now=now,
observation_rows=[],
)
reason_codes = {item["code"] for item in projection.priority_reasons_json}
assert projection.priority_score >= 85
assert projection.priority_tier == "urgent"
assert projection.risk_level == "high"
assert projection.open_risk_count == 1
assert projection.due_at == now - timedelta(hours=2)
assert projection.next_escalation_at == projection.due_at
assert reason_codes >= {
"open_risk",
"sla_overdue",
"budget_pressure",
"large_amount",
}
assert projection.batch_eligible is False
assert set(projection.batch_block_reasons_json) >= {
"open_risk",
"amount_requires_individual_review",
"budget_pressure",
"sla_overdue",
}
def test_resolved_materialized_risk_suppresses_stale_raw_flag_and_application_invoice() -> None:
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
claim = _claim(
claim_id="claim-projection-resolved",
claim_no="AP-PROJECTION-1",
invoice_count=0,
risk_flags=[
{
"severity": "critical",
"triggered": True,
"observation_key": "risk:projection:resolved",
}
],
)
observation = RiskObservation(
id="observation-projection-resolved",
tenant_id="tenant-a",
observation_key="risk:projection:resolved",
subject_type="expense_claim",
subject_key=f"claim:{claim.id}",
subject_label=claim.claim_no,
claim_id=claim.id,
claim_no=claim.claim_no,
risk_type="duplicate_invoice",
risk_signal="duplicate_invoice",
title="重复票据",
description="已人工复核完成。",
risk_score=98,
risk_level="critical",
confidence_score=0.99,
control_stage="expense_application",
control_mode="risk_observation",
automation_mode="manual",
source="financial_risk_graph",
algorithm_version="v1",
status="resolved",
feedback_status="confirmed",
)
disposition = RiskDisposition(
id="disposition-projection-resolved",
tenant_id="tenant-a",
observation_id=observation.id,
adjudication="confirmed",
lifecycle_status="resolved",
version=2,
)
projection = ApprovalTaskProjectionService(None).build( # type: ignore[arg-type]
claim,
tenant_id="tenant-a",
entered_at=now - timedelta(hours=1),
now=now,
observation_rows=[(observation, disposition)],
)
assert projection.risk_level == "low"
assert projection.open_risk_count == 0
assert projection.evidence_completeness == Decimal("1.0000")
assert projection.priority_score == 0
assert projection.priority_reasons_json[0]["code"] == "routine"
assert projection.batch_eligible is True
assert projection.batch_block_reasons_json == ()
def test_projection_marks_missing_evidence_and_apply_updates_task_without_persistence() -> None:
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
claim = _claim(invoice_count=0)
claim.location = ""
claim.items = []
projection = ApprovalTaskProjectionService(None).build( # type: ignore[arg-type]
claim,
tenant_id="tenant-a",
entered_at=now - timedelta(hours=1),
now=now,
observation_rows=[],
)
task = ApprovalTask()
returned = ApprovalTaskProjectionService.apply(task, projection)
assert returned is task
assert task.evidence_completeness == Decimal("0.2500")
assert task.batch_eligible is False
assert "evidence_incomplete" in task.batch_block_reasons_json
assert {item["code"] for item in task.priority_reasons_json} >= {"evidence_gap"}
def test_projection_rejects_ambiguous_time_and_invalid_sla() -> None:
claim = _claim()
service = ApprovalTaskProjectionService(None) # type: ignore[arg-type]
with pytest.raises(ValueError, match="entered_at.*timezone"):
service.build(
claim,
tenant_id="tenant-a",
entered_at=datetime(2026, 7, 16, 10, 0),
observation_rows=[],
)
with pytest.raises(ValueError, match="sla_hours"):
service.build(
claim,
tenant_id="tenant-a",
entered_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
sla_hours=0,
observation_rows=[],
)
def test_apply_convenience_form_uses_claim_session_for_tenant_risks() -> None:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
db_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
with db_factory() as db:
claim = _claim(claim_id="claim-projection-session")
observation = RiskObservation(
id="observation-projection-session",
tenant_id="tenant-a",
observation_key="risk:projection:session",
subject_type="expense_claim",
subject_key=f"claim:{claim.id}",
subject_label=claim.claim_no,
claim_id=claim.id,
claim_no=claim.claim_no,
risk_type="policy_violation",
risk_signal="policy_violation",
title="政策风险",
description="等待人工处置。",
risk_score=80,
risk_level="high",
confidence_score=0.9,
control_stage="reimbursement",
control_mode="risk_observation",
automation_mode="manual",
source="financial_risk_graph",
algorithm_version="v1",
status="pending_review",
feedback_status="unreviewed",
)
db.add_all([claim, observation])
db.flush()
task = ApprovalTask(
tenant_id="tenant-a",
entered_at=now - timedelta(hours=1),
sla_hours_snapshot=12,
)
returned = ApprovalTaskProjectionService.apply(task, claim=claim, now=now)
assert returned is task
assert task.risk_level == "high"
assert task.open_risk_count == 1
assert task.due_at == now + timedelta(hours=11)
def test_projection_filters_other_business_stage_and_only_honors_active_waiver() -> None:
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
claim = _claim(
claim_id="claim-projection-waiver",
claim_no="AP-PROJECTION-WAIVER",
risk_flags=[
{
"severity": "critical",
"triggered": True,
"business_stage": "reimbursement",
}
],
)
other_stage = RiskObservation(
id="observation-other-stage",
tenant_id="tenant-a",
observation_key="risk:projection:other-stage",
subject_type="expense_claim",
subject_key=f"claim:{claim.id}",
subject_label=claim.claim_no,
claim_id=claim.id,
claim_no=claim.claim_no,
risk_type="invoice",
risk_signal="invoice",
risk_level="critical",
control_stage="reimbursement",
status="pending_review",
feedback_status="unreviewed",
)
waived = RiskObservation(
id="observation-active-waiver",
tenant_id="tenant-a",
observation_key="risk:projection:active-waiver",
subject_type="expense_claim",
subject_key=f"claim:{claim.id}",
subject_label=claim.claim_no,
claim_id=claim.id,
claim_no=claim.claim_no,
risk_type="policy",
risk_signal="policy",
risk_level="high",
control_stage="expense_application",
status="pending_review",
feedback_status="confirmed",
)
disposition = RiskDisposition(
tenant_id="tenant-a",
observation_id=waived.id,
adjudication="confirmed",
lifecycle_status="waived",
waiver_expires_at=now + timedelta(days=1),
waiver_decision="approved",
)
service = ApprovalTaskProjectionService(None) # type: ignore[arg-type]
active = service.build(
claim,
tenant_id="tenant-a",
entered_at=now - timedelta(hours=1),
now=now,
observation_rows=[(other_stage, None), (waived, disposition)],
)
disposition.waiver_expires_at = now - timedelta(seconds=1)
expired = service.build(
claim,
tenant_id="tenant-a",
entered_at=now - timedelta(hours=1),
now=now,
observation_rows=[(other_stage, None), (waived, disposition)],
)
assert active.risk_level == "low"
assert active.open_risk_count == 0
assert active.batch_eligible is True
assert expired.risk_level == "high"
assert expired.open_risk_count == 1
assert expired.batch_eligible is False
def test_risk_upsert_refreshes_open_task_projection_without_resetting_sla_window() -> None:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
db_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
with db_factory() as db:
claim = _claim(claim_id="claim-projection-refresh")
task = ApprovalTask(
id="task-projection-refresh",
tenant_id="default",
claim_id=claim.id,
expense_case_id=None,
node_instance_id="node-projection-refresh",
node_entry_key="node-entry-projection-refresh",
parent_task_id=None,
task_kind="root",
node_key="direct_manager",
node_label="直属领导审批",
node_sequence=1,
sequence_order=0,
coordination_mode="single",
owner_kind="role",
owner_key="manager",
owner_name="审批经理",
assignee_kind="role",
assignee_key="manager",
assignee_name="审批经理",
status="pending",
version=1,
claim_status_snapshot="submitted",
claim_stage_snapshot="直属领导审批",
entered_at=now - timedelta(hours=1),
entered_at_source="workflow_event",
activated_at=now - timedelta(hours=1),
sla_hours_snapshot=24,
due_at=now + timedelta(hours=23),
escalation_level=1,
next_escalation_at=now + timedelta(hours=2),
priority_score=10,
priority_tier="normal",
priority_reasons_json=[
{
"code": "sla_escalated_l1",
"label": "审批超时已升级至 L1",
"weight": 10,
"tone": "danger",
}
],
risk_level="low",
open_risk_count=0,
evidence_completeness=Decimal("1.0000"),
batch_eligible=True,
batch_block_reasons_json=[],
projection_updated_at=now,
created_at=now,
updated_at=now,
)
db.add_all([claim, task])
db.commit()
observation = RiskObservationService(db).upsert_observation(
{
"observation_key": "risk:projection:refresh",
"subject_type": "expense_claim",
"subject_key": f"claim:{claim.id}",
"subject_label": claim.claim_no,
"claim_id": claim.id,
"claim_no": claim.claim_no,
"risk_type": "policy_violation",
"risk_signal": "policy_violation",
"title": "政策风险",
"description": "等待人工处置。",
"risk_score": 90,
"risk_level": "high",
"control_stage": "reimbursement",
"status": "pending_review",
},
tenant_id="default",
)
db.refresh(task)
assert task.risk_level == "high"
assert task.open_risk_count == 1
assert task.batch_eligible is False
next_escalation_at = task.next_escalation_at
assert next_escalation_at is not None
if next_escalation_at.tzinfo is None:
next_escalation_at = next_escalation_at.replace(tzinfo=UTC)
assert next_escalation_at == now + timedelta(hours=2)
assert task.escalation_level == 1
assert any(
reason["code"] == "sla_escalated_l1"
for reason in task.priority_reasons_json
)
db.add(
RiskDisposition(
tenant_id="default",
observation_id=observation.id,
adjudication="confirmed",
lifecycle_status="waived",
waiver_requester_id="requester-projection-refresh",
waiver_requester_name="风险申请人",
waiver_requested_at=now - timedelta(hours=1),
waiver_reason="业务连续性需要",
waiver_scope="本次报销单",
waiver_decision="approved",
waiver_expires_at=now + timedelta(days=1),
waiver_conditions_json=["补充主管确认"],
waiver_decider_id="finance-projection-refresh",
waiver_decider_name="财务复核人",
waiver_decided_at=now,
waiver_decision_reason="风险受控且有补偿措施",
version=1,
)
)
db.flush()
ApprovalTaskProjectionRefreshService(db).refresh_claim(
tenant_id="default",
claim_id=claim.id,
now=now,
)
assert task.risk_level == "low"
assert task.open_risk_count == 0
assert task.batch_eligible is True
next_escalation_at = task.next_escalation_at
assert next_escalation_at is not None
if next_escalation_at.tzinfo is None:
next_escalation_at = next_escalation_at.replace(tzinfo=UTC)
assert next_escalation_at == now + timedelta(hours=2)

View File

@@ -0,0 +1,600 @@
from __future__ import annotations
from collections.abc import Generator
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
import pytest
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext, get_db
from app.db.base import Base
from app.main import create_app
from app.models.approval_action import ApprovalActionLedger
from app.models.approval_task import ApprovalTask, ApprovalTaskEvent
from app.models.employee import Employee
from app.models.expense_case import ExpenseCase, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.role import Role
from app.schemas.approval_task import (
ApprovalTaskBatchApproveCreate,
ApprovalTaskBatchApproveItem,
)
from app.services.approval_task_batch import ApprovalTaskBatchService
from app.services.approval_task_query import ApprovalTaskQueryService
from app.services.expense_claim_workflow_constants import (
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
)
def _session_factory() -> sessionmaker[Session]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
return sessionmaker(
bind=engine,
autoflush=False,
autocommit=False,
expire_on_commit=False,
)
def _user(
*,
tenant_id: str = "tenant-a",
employee_id: str = "approver-1",
username: str = "approver@example.com",
role_codes: list[str] | None = None,
is_admin: bool = False,
) -> CurrentUserContext:
return CurrentUserContext(
username=username,
name="审批人",
role_codes=list(role_codes or ["manager"]),
is_admin=is_admin,
tenant_id=tenant_id,
employee_id=employee_id,
employee_no=employee_id,
)
def _task_status_fields(
status: str,
*,
now: datetime,
) -> dict[str, object]:
if status == "completed":
return {
"decision": "approved",
"completed_at": now,
"cancelled_at": None,
}
if status == "returned":
return {
"decision": "returned",
"completed_at": now,
"cancelled_at": None,
}
if status in {"cancelled", "superseded"}:
return {
"decision": None,
"completed_at": None,
"cancelled_at": now,
}
return {"decision": None, "completed_at": None, "cancelled_at": None}
def _build_queue_task(
*,
tenant_id: str,
case_id: str,
index: int,
now: datetime,
assignee_id: str,
status: str = "pending",
risk_level: str | None = None,
node_key: str | None = None,
due_at: datetime | None = None,
reason: str | None = None,
) -> tuple[ExpenseClaim, ExpenseCaseLink, ApprovalTask]:
claim_id = f"queue-claim-{tenant_id[-1]}-{index:03d}"
task_id = f"queue-task-{tenant_id[-1]}-{index:03d}"
selected_node = node_key or ("direct_manager" if index % 2 == 0 else "finance")
stage = (
DIRECT_MANAGER_APPROVAL_STAGE
if selected_node == "direct_manager"
else FINANCE_APPROVAL_STAGE
)
# 任务进入节点后才会产生 SLA 截止时间;即使构造“已逾期”样本,
# due_at 也必须晚于 entered_at保持测试数据满足真实表约束。
entered_at = now - timedelta(hours=12 + index % 5, minutes=index % 7)
selected_due_at = due_at or (
now - timedelta(hours=2)
if index % 3 == 0
else now + timedelta(hours=2)
if index % 3 == 1
else now + timedelta(hours=8)
)
selected_risk = risk_level or ("high" if index % 5 == 0 else "low")
claim = ExpenseClaim(
id=claim_id,
claim_no=f"RE-QUEUE-{tenant_id[-1].upper()}-{index:03d}",
employee_id=None,
employee_name=f"申请人{index:03d}",
department_id=None,
department_name="市场部",
project_code="PRJ-QUEUE",
expense_type="misc",
reason=reason or f"队列分页测试费用 {index:03d}",
location="上海",
amount=Decimal("888.00") + Decimal(index),
currency="CNY",
invoice_count=1,
occurred_at=now - timedelta(days=1),
submitted_at=entered_at,
status="submitted",
approval_stage=stage,
risk_flags_json=[],
created_at=entered_at - timedelta(hours=1),
updated_at=entered_at,
)
link = ExpenseCaseLink(
id=f"queue-link-{tenant_id[-1]}-{index:03d}",
tenant_id=tenant_id,
expense_case_id=case_id,
resource_type="expense_claim",
resource_id=claim_id,
relation_type="claim",
created_at=entered_at,
)
lifecycle = _task_status_fields(status, now=now)
task = ApprovalTask(
id=task_id,
tenant_id=tenant_id,
claim_id=claim_id,
expense_case_id=case_id,
node_instance_id=f"queue-node-{tenant_id[-1]}-{index:03d}",
node_entry_key=f"queue-entry-{tenant_id[-1]}-{index:03d}",
parent_task_id=None,
task_kind="root",
node_key=selected_node,
node_label=stage,
node_sequence=1,
sequence_order=0,
coordination_mode="single",
owner_kind="employee",
owner_key=assignee_id,
owner_employee_id=assignee_id,
owner_name="审批人",
assignee_kind="employee",
assignee_key=assignee_id,
assignee_employee_id=assignee_id,
assignee_name="审批人张经理",
status=status,
decision=lifecycle["decision"],
version=2 if status != "pending" else 1,
claim_status_snapshot="submitted",
claim_stage_snapshot=stage,
entered_at=entered_at,
entered_at_source="submitted_at",
activated_at=entered_at,
sla_hours_snapshot=24,
due_at=selected_due_at,
completed_at=lifecycle["completed_at"],
cancelled_at=lifecycle["cancelled_at"],
escalation_level=0,
next_escalation_at=selected_due_at,
priority_score=(index % 4) * 25,
priority_tier="urgent" if index % 4 == 3 else "high" if index % 4 == 2 else "normal",
priority_reasons_json=[],
risk_level=selected_risk,
open_risk_count=1 if selected_risk != "low" else 0,
evidence_completeness=Decimal("1.0000"),
batch_eligible=(status == "pending" and selected_risk == "low"),
batch_block_reasons_json=[] if selected_risk == "low" else ["open_risk"],
projection_updated_at=now,
created_at=entered_at,
updated_at=now,
)
return claim, link, task
def _seed_queue(
db: Session,
*,
tenant_id: str = "tenant-a",
pending_count: int = 27,
assignee_id: str = "approver-1",
include_processed: bool = False,
) -> tuple[datetime, list[ApprovalTask]]:
now = datetime.now(UTC).replace(microsecond=0)
case_id = f"queue-case-{tenant_id[-1]}"
expense_case = ExpenseCase(
id=case_id,
tenant_id=tenant_id,
case_no=f"CASE-QUEUE-{tenant_id[-1].upper()}",
scene_code="misc",
title="审批队列测试",
current_stage="reviewing",
status="active",
created_at=now - timedelta(days=2),
updated_at=now,
)
db.add(expense_case)
tasks: list[ApprovalTask] = []
for index in range(pending_count):
claim, link, task = _build_queue_task(
tenant_id=tenant_id,
case_id=case_id,
index=index,
now=now,
assignee_id=assignee_id,
reason=("火星专项客户拜访" if index == 7 else None),
)
db.add_all([claim, link, task])
tasks.append(task)
if include_processed:
for offset, status in enumerate(
("completed", "returned", "cancelled", "superseded"),
start=pending_count,
):
claim, link, task = _build_queue_task(
tenant_id=tenant_id,
case_id=case_id,
index=offset,
now=now,
assignee_id=assignee_id,
status=status,
)
db.add_all([claim, link, task])
tasks.append(task)
db.commit()
return now, tasks
def test_query_uses_real_database_pagination_and_stable_persisted_sorting() -> None:
factory = _session_factory()
with factory() as db:
_now, tasks = _seed_queue(db)
expected = sorted(
tasks,
key=lambda task: (
-task.priority_score,
task.due_at,
task.entered_at,
task.id,
),
)
page = ApprovalTaskQueryService(db).list_tasks(
_user(),
page=2,
page_size=10,
)
repeated = ApprovalTaskQueryService(db).list_tasks(
_user(),
page=2,
page_size=10,
)
expected_ids = [task.id for task in expected[10:20]]
assert page.total == 27
assert page.page == 2
assert page.page_size == 10
assert page.total_pages == 3
assert [item.task.id for item in page.items] == expected_ids
assert [item.task.id for item in repeated.items] == expected_ids
def test_query_supports_status_risk_sla_node_and_keyword_filters() -> None:
factory = _session_factory()
with factory() as db:
_now, tasks = _seed_queue(db, include_processed=True)
tasks[0].escalation_level = 1
db.commit()
service = ApprovalTaskQueryService(db)
user = _user()
assert service.list_tasks(user, status="processed").total == 4
assert service.list_tasks(user, status="completed").total == 1
assert service.list_tasks(user, risk_level="high").total == 6
assert service.list_tasks(user, sla_state="overdue").total == 9
assert service.list_tasks(user, sla_state="due_soon").total == 9
assert service.list_tasks(user, sla_state="on_track").total == 9
assert service.list_tasks(user, sla_state="escalated").total == 1
assert service.list_tasks(user, node_key="finance").total == 13
keyword = service.list_tasks(user, keyword="火星专项")
assert keyword.total == 1
assert keyword.items[0].claim.reason == "火星专项客户拜访"
with pytest.raises(ValueError, match="SLA"):
service.list_tasks(user, sla_state="unknown")
def test_query_enforces_tenant_scope_and_admin_visibility_without_action_authority() -> None:
factory = _session_factory()
with factory() as db:
_seed_queue(db, tenant_id="tenant-a", pending_count=3)
_seed_queue(
db,
tenant_id="tenant-b",
pending_count=2,
assignee_id="other-approver",
)
service = ApprovalTaskQueryService(db)
personal = service.list_tasks(_user(tenant_id="tenant-a"))
admin_a = service.list_tasks(
_user(
tenant_id="tenant-a",
employee_id="admin-a",
username="admin-a@example.com",
role_codes=["admin"],
is_admin=True,
)
)
admin_b = service.list_tasks(
_user(
tenant_id="tenant-b",
employee_id="admin-b",
username="admin-b@example.com",
role_codes=["admin"],
is_admin=True,
)
)
assert personal.total == 3
assert all(item.task.can_act for item in personal.items)
assert admin_a.total == 3
assert all(item.task.can_act is False for item in admin_a.items)
assert all("approve" not in item.task.available_actions for item in admin_a.items)
assert admin_b.total == 2
assert {item.task.tenant_id for item in admin_b.items} == {"tenant-b"}
def _seed_batch(
db: Session,
) -> tuple[CurrentUserContext, ApprovalTask, ApprovalTask]:
now = datetime.now(UTC).replace(microsecond=0)
finance_role = Role(
id="batch-role-finance",
role_code="finance",
name="批处理财务",
)
finance = Employee(
id="batch-finance",
employee_no="BATCH-FINANCE",
name="财务审批人",
email="batch-finance@example.com",
roles=[finance_role],
)
owner = Employee(
id="batch-owner",
employee_no="BATCH-OWNER",
name="批处理申请人",
email="batch-owner@example.com",
)
expense_case = ExpenseCase(
id="batch-case",
tenant_id="tenant-a",
case_no="CASE-BATCH-APPROVAL",
scene_code="misc",
title="批量审批事务测试",
owner_employee_id=owner.id,
current_stage="reviewing",
status="active",
created_at=now - timedelta(days=1),
updated_at=now,
)
db.add_all([finance_role, finance, owner, expense_case])
tasks: list[ApprovalTask] = []
for index, eligible in enumerate((True, False), start=1):
claim_id = f"batch-claim-{index}"
claim = ExpenseClaim(
id=claim_id,
claim_no=f"RE-BATCH-{index:03d}",
employee=owner,
employee_name=owner.name,
department_name="市场部",
project_code="PRJ-BATCH",
expense_type="misc",
reason=f"批量审批测试 {index}",
location="上海",
amount=Decimal("88.00"),
currency="CNY",
invoice_count=1,
occurred_at=now - timedelta(days=1),
submitted_at=now - timedelta(hours=1),
status="submitted",
approval_stage=FINANCE_APPROVAL_STAGE,
risk_flags_json=[],
created_at=now - timedelta(days=1),
updated_at=now,
)
claim.items = [
ExpenseClaimItem(
id=f"batch-item-{index}",
claim_id=claim_id,
item_date=date.today(),
item_type="misc",
item_reason="测试",
item_location="上海",
item_note="",
item_amount=Decimal("88.00"),
invoice_id=f"BATCH-INVOICE-{index}",
created_at=now,
updated_at=now,
)
]
link = ExpenseCaseLink(
id=f"batch-link-{index}",
tenant_id="tenant-a",
expense_case_id=expense_case.id,
resource_type="expense_claim",
resource_id=claim_id,
relation_type="claim",
created_at=now,
)
task = ApprovalTask(
id=f"batch-task-{index}",
tenant_id="tenant-a",
claim_id=claim_id,
expense_case_id=expense_case.id,
node_instance_id=f"batch-node-{index}",
node_entry_key=f"batch-entry-{index}",
parent_task_id=None,
task_kind="root",
node_key="finance",
node_label=FINANCE_APPROVAL_STAGE,
node_sequence=1,
sequence_order=0,
coordination_mode="single",
owner_kind="employee",
owner_key=finance.id,
owner_employee_id=finance.id,
owner_name=finance.name,
assignee_kind="employee",
assignee_key=finance.id,
assignee_employee_id=finance.id,
assignee_name=finance.name,
status="pending",
decision=None,
version=1,
claim_status_snapshot="submitted",
claim_stage_snapshot=FINANCE_APPROVAL_STAGE,
entered_at=now - timedelta(hours=1),
entered_at_source="submitted_at",
activated_at=now - timedelta(hours=1),
sla_hours_snapshot=8,
due_at=now + timedelta(hours=7),
escalation_level=0,
next_escalation_at=now + timedelta(hours=7),
priority_score=0,
priority_tier="normal",
priority_reasons_json=[],
risk_level="low" if eligible else "high",
open_risk_count=0 if eligible else 1,
evidence_completeness=Decimal("1.0000"),
batch_eligible=eligible,
batch_block_reasons_json=[] if eligible else ["open_risk"],
projection_updated_at=now,
created_at=now,
updated_at=now,
)
db.add_all([claim, link, task])
tasks.append(task)
db.commit()
return (
_user(
tenant_id="tenant-a",
employee_id=finance.id,
username=finance.email,
role_codes=["finance"],
),
tasks[0],
tasks[1],
)
def test_batch_items_use_independent_transactions_and_retry_replays_success() -> None:
factory = _session_factory()
with factory() as db:
user, successful_task, blocked_task = _seed_batch(db)
payload = ApprovalTaskBatchApproveCreate(
batch_request_id="batch-request-001",
items=[
ApprovalTaskBatchApproveItem(
task_id=successful_task.id,
expected_task_version=1,
expected_status="submitted",
expected_approval_stage=FINANCE_APPROVAL_STAGE,
opinion="批量审批同意",
),
ApprovalTaskBatchApproveItem(
task_id=blocked_task.id,
expected_task_version=1,
expected_status="submitted",
expected_approval_stage=FINANCE_APPROVAL_STAGE,
opinion="批量审批同意",
),
],
)
first = ApprovalTaskBatchService(factory).approve(payload, user)
repeated = ApprovalTaskBatchService(factory).approve(payload, user)
assert first.status == "partial"
assert first.succeeded_count == 1
assert first.blocked_count == 1
assert [item.status for item in first.items] == ["succeeded", "blocked"]
assert repeated.status == "partial"
assert repeated.replayed_count == 1
assert repeated.blocked_count == 1
assert [item.status for item in repeated.items] == ["replayed", "blocked"]
with factory() as db:
successful_claim = db.get(ExpenseClaim, successful_task.claim_id)
blocked_claim = db.get(ExpenseClaim, blocked_task.claim_id)
persisted_success = db.get(ApprovalTask, successful_task.id)
persisted_blocked = db.get(ApprovalTask, blocked_task.id)
assert successful_claim.status == "pending_payment"
assert successful_claim.approval_stage == "待付款"
assert persisted_success.status == "completed"
assert persisted_success.decision == "approved"
assert blocked_claim.status == "submitted"
assert persisted_blocked.status == "pending"
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 1
assert db.scalar(select(func.count()).select_from(ApprovalTaskEvent)) == 1
def test_approval_task_list_http_contract_preserves_pagination_metadata() -> None:
factory = _session_factory()
with factory() as db:
_now, tasks = _seed_queue(db)
expected = sorted(
tasks,
key=lambda task: (
-task.priority_score,
task.due_at,
task.entered_at,
task.id,
),
)
app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]:
with factory() as db:
yield db
app.dependency_overrides[get_db] = override_db
client = TestClient(app)
try:
response = client.get(
"/api/v1/approval-tasks",
params={"page": 3, "page_size": 10, "status": "pending"},
headers={
"X-Auth-Username": "approver@example.com",
"X-Auth-Employee-Id": "approver-1",
"X-Auth-Role-Codes": "manager",
"X-Auth-Tenant-Id": "tenant-a",
},
)
finally:
client.close()
app.dependency_overrides.clear()
body = response.json()
assert response.status_code == 200
assert body["total"] == 27
assert body["page"] == 3
assert body["page_size"] == 10
assert body["total_pages"] == 3
assert len(body["items"]) == 7
assert body["items"][0]["task"]["id"] == expected[20].id

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from decimal import Decimal
import pytest
@@ -52,6 +52,48 @@ def test_high_risk_requires_false_positive_or_resolved_disposition() -> None:
gate.ensure_approvable(claim, tenant_id="default")
def test_only_unexpired_approved_waiver_bypasses_risk_gate() -> None:
with _session() as db:
claim = _claim(claim_id="claim-risk-waiver-gate")
observation = _observation(claim, observation_id="risk-waiver-gate")
now = datetime.now(UTC)
disposition = RiskDisposition(
tenant_id="default",
observation_id=observation.id,
adjudication="confirmed",
lifecycle_status="waived",
waiver_requester_id="waiver-requester",
waiver_requester_name="豁免申请人",
waiver_requested_at=now - timedelta(days=2),
waiver_reason="临时无法取得纸质材料。",
waiver_scope="仅限本次客户拜访。",
waiver_expires_at=now + timedelta(days=2),
waiver_conditions_json=["到期前补件"],
waiver_decision="approved",
waiver_decider_id="waiver-decider",
waiver_decider_name="豁免决定人",
waiver_decided_at=now - timedelta(days=1),
waiver_decision_reason="补偿控制充分,同意限时豁免。",
)
db.add_all([claim, observation, disposition])
db.commit()
gate = ExpenseClaimRiskGate(db)
gate.ensure_approvable(claim, tenant_id="default")
disposition.waiver_expires_at = now - timedelta(hours=1)
db.commit()
with pytest.raises(ExpenseClaimRiskBlockedError):
gate.ensure_approvable(claim, tenant_id="default")
disposition.lifecycle_status = "waiver_rejected"
disposition.waiver_decision = "rejected"
disposition.waiver_decision_reason = "补偿控制不足,拒绝豁免。"
db.commit()
with pytest.raises(ExpenseClaimRiskBlockedError):
gate.ensure_approvable(claim, tenant_id="default")
def test_medium_and_foreign_tenant_risks_do_not_block_claim() -> None:
with _session() as db:
claim = _claim(claim_id="claim-risk-nonblocking")

View File

@@ -149,6 +149,14 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set(
"20260716_0012",
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0012"] - {"risk_disposition_events"},
),
(
"20260716_0013",
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0013"] - {"approval_task_events"},
),
(
"20260716_0014",
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0014"] - {"risk_disposition_events"},
),
],
)
def test_known_revision_with_missing_or_unexpected_owned_tables_is_rejected(

View File

@@ -0,0 +1,616 @@
from __future__ import annotations
from collections.abc import Generator
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
import pytest
from auth_helpers import install_legacy_header_auth_override
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext, get_db
from app.api.v1.endpoints.risk_observations import router as risk_observations_router
from app.db.base import Base
from app.models.approval_task import ApprovalTask
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.risk_disposition import RiskDisposition
from app.models.risk_observation import RiskObservation
from app.schemas.risk_disposition import RiskDispositionActionCreate
from app.services.approval_task_projection_refresh import (
ApprovalTaskProjectionRefreshService,
)
from app.services.risk_disposition_read_projection import RiskDispositionActionPolicy
from app.services.risk_dispositions import RiskDispositionService
from app.services.risk_observations import RiskObservationService
def test_available_actions_enforce_decider_separation_tenant_state_and_expiry(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
client, factory = _build_client()
client.close()
with factory() as db:
seeded = _seed_waiver_request(db)
policy = RiskDispositionActionPolicy(db)
decider_actions = set(
policy.evaluate(
seeded.observation,
seeded.disposition,
seeded.decider,
).available_actions
)
assert {"approve_waiver", "reject_waiver"} <= decider_actions
for user in (seeded.requester, seeded.admin, seeded.inactive_finance):
actions = set(
policy.evaluate(
seeded.observation,
seeded.disposition,
user,
).available_actions
)
assert "approve_waiver" not in actions
assert "reject_waiver" not in actions
foreign_permission = policy.evaluate(
seeded.observation,
seeded.disposition,
_user(seeded.decider_employee, roles=["finance"], tenant_id="tenant-b"),
)
assert foreign_permission.available_actions == ()
assert "租户" in foreign_permission.read_only_reason
expires_at = seeded.disposition.waiver_expires_at
assert expires_at is not None
expired_actions = set(
policy.evaluate(
seeded.observation,
seeded.disposition,
seeded.decider,
now=_as_utc(expires_at) + timedelta(seconds=1),
).available_actions
)
assert "approve_waiver" not in expired_actions
assert "reject_waiver" in expired_actions
seeded.disposition.lifecycle_status = "waived"
seeded.disposition.waiver_decision = "approved"
waived_actions = set(
policy.evaluate(
seeded.observation,
seeded.disposition,
seeded.decider,
).available_actions
)
assert "approve_waiver" not in waived_actions
assert "reject_waiver" not in waived_actions
seeded.disposition.lifecycle_status = "open"
seeded.disposition.waiver_decision = None
seeded.disposition.waiver_requester_id = None
open_actions = set(
policy.evaluate(
seeded.observation,
seeded.disposition,
seeded.decider,
).available_actions
)
assert "request_waiver" in open_actions
def test_read_and_mutation_endpoints_return_server_projected_actions(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
client, factory = _build_client()
with factory() as db:
seeded = _seed_waiver_request(db)
unreviewed = RiskObservationService(db).upsert_observation(
_observation_payload("risk:projection:unreviewed")
)
db.commit()
observation_id = seeded.observation.id
unreviewed_id = unreviewed.id
decider_headers = _headers(seeded.decider_employee, roles=["finance"])
claim_response = client.get(
"/api/v1/risk-observations/claim/claim-risk-projection",
headers=decider_headers,
)
assert claim_response.status_code == 200
by_id = {item["id"]: item for item in claim_response.json()}
requested_item = by_id[observation_id]
assert {"approve_waiver", "reject_waiver"} <= set(
requested_item["available_actions"]
)
assert requested_item["read_only_reason"] == ""
assert requested_item["disposition"]["available_actions"] == requested_item[
"available_actions"
]
assert requested_item["disposition"]["read_only_reason"] == ""
assert by_id[unreviewed_id]["disposition"] is None
assert {"confirm", "false_positive"} <= set(
by_id[unreviewed_id]["available_actions"]
)
for path in (
"/api/v1/risk-observations",
f"/api/v1/risk-observations/{observation_id}",
f"/api/v1/risk-observations/{observation_id}/disposition",
):
response = client.get(path, headers=decider_headers)
assert response.status_code == 200
payload = response.json()
if path.endswith("/risk-observations"):
payload = next(item for item in payload["items"] if item["id"] == observation_id)
if not path.endswith("/disposition"):
payload = payload["disposition"]
assert {"approve_waiver", "reject_waiver"} <= set(payload["available_actions"])
assert payload["read_only_reason"] == ""
for employee, roles, is_admin in (
(seeded.requester_employee, ["finance"], False),
(seeded.admin_employee, [], True),
):
response = client.get(
f"/api/v1/risk-observations/{observation_id}/disposition",
headers=_headers(employee, roles=roles, is_admin=is_admin),
)
assert response.status_code == 200
assert "approve_waiver" not in response.json()["available_actions"]
assert "reject_waiver" not in response.json()["available_actions"]
mutation_response = client.post(
f"/api/v1/risk-observations/{observation_id}/disposition/actions",
headers=decider_headers,
json={
"action": "reject_waiver",
"expected_version": 2,
"request_id": "projection-reject-waiver",
"comment": "当前证据不足,不同意风险豁免。",
},
)
assert mutation_response.status_code == 200
mutation_disposition = mutation_response.json()["disposition"]
assert "available_actions" in mutation_disposition
assert "read_only_reason" in mutation_disposition
assert "approve_waiver" not in mutation_disposition["available_actions"]
assert "reject_waiver" not in mutation_disposition["available_actions"]
def test_disposition_mutations_refresh_task_risk_without_resetting_sla(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
client, factory = _build_client()
client.close()
with factory() as db:
owner = _employee("projection-owner")
requester_employee = _employee("projection-requester")
decider_employee = _employee("projection-decider")
claim = _claim(owner.id)
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:projection:task-refresh")
)
now = datetime.now(UTC)
task = _approval_task(claim, now=now)
db.add_all([owner, requester_employee, decider_employee, claim, task])
db.commit()
original_sla = (
_as_utc(task.entered_at),
task.sla_hours_snapshot,
_as_utc(task.due_at),
_as_utc(task.next_escalation_at),
task.escalation_level,
)
requester = _user(requester_employee, roles=["finance"])
decider = _user(decider_employee, roles=["finance"])
service = RiskDispositionService(db)
service.execute_action(
observation.id,
RiskDispositionActionCreate(
action="confirm",
expected_version=0,
request_id="task-refresh-confirm-risk",
),
tenant_id="default",
actor_id=requester_employee.id,
actor_name=requester_employee.name,
current_user=requester,
)
db.refresh(task)
assert task.risk_level == "high"
assert task.open_risk_count == 1
assert task.batch_eligible is False
assert "open_risk" in task.batch_block_reasons_json
_assert_sla_window(task, original_sla)
service.execute_action(
observation.id,
_waiver_request(version=1, request_id="task-refresh-waiver-request-1"),
tenant_id="default",
actor_id=requester_employee.id,
actor_name=requester_employee.name,
current_user=requester,
)
service.execute_action(
observation.id,
RiskDispositionActionCreate(
action="reject_waiver",
expected_version=2,
request_id="task-refresh-reject-waiver",
comment="补偿措施不足,拒绝豁免。",
),
tenant_id="default",
actor_id=decider_employee.id,
actor_name=decider_employee.name,
current_user=decider,
)
db.refresh(task)
assert task.open_risk_count == 1
assert task.batch_eligible is False
_assert_sla_window(task, original_sla)
service.execute_action(
observation.id,
_waiver_request(version=3, request_id="task-refresh-waiver-request-2"),
tenant_id="default",
actor_id=requester_employee.id,
actor_name=requester_employee.name,
current_user=requester,
)
service.execute_action(
observation.id,
RiskDispositionActionCreate(
action="approve_waiver",
expected_version=4,
request_id="task-refresh-approve-waiver",
comment="补偿措施完整,同意限时豁免。",
),
tenant_id="default",
actor_id=decider_employee.id,
actor_name=decider_employee.name,
current_user=decider,
)
db.refresh(task)
assert task.risk_level == "low"
assert task.open_risk_count == 0
assert task.evidence_completeness == Decimal("1.0000")
assert task.batch_eligible is True
_assert_sla_window(task, original_sla)
disposition = service.get_disposition(observation.id, tenant_id="default")
assert disposition is not None
refresh_at = datetime.now(UTC)
disposition.waiver_requested_at = refresh_at - timedelta(days=2)
disposition.waiver_expires_at = refresh_at - timedelta(hours=1)
db.flush()
ApprovalTaskProjectionRefreshService(db).refresh_claim(
tenant_id="default",
claim_id=claim.id,
now=refresh_at,
)
db.commit()
db.refresh(task)
assert task.risk_level == "high"
assert task.open_risk_count == 1
assert task.batch_eligible is False
_assert_sla_window(task, original_sla)
@dataclass(slots=True)
class _SeededWaiver:
observation: RiskObservation
disposition: RiskDisposition
requester_employee: Employee
decider_employee: Employee
admin_employee: Employee
requester: CurrentUserContext
decider: CurrentUserContext
admin: CurrentUserContext
inactive_finance: CurrentUserContext
def _seed_waiver_request(db: Session) -> _SeededWaiver:
owner = _employee("owner")
requester_employee = _employee("requester")
decider_employee = _employee("decider")
admin_employee = _employee("admin")
inactive_employee = _employee("inactive", employment_status="离职")
db.add_all(
[
owner,
requester_employee,
decider_employee,
admin_employee,
inactive_employee,
_claim(owner.id),
]
)
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:projection:waiver")
)
db.commit()
requester = _user(requester_employee, roles=["finance"])
service = RiskDispositionService(db)
service.execute_action(
observation.id,
RiskDispositionActionCreate(
action="confirm",
expected_version=0,
request_id="projection-confirm-risk",
),
tenant_id="default",
actor_id=requester_employee.id,
actor_name=requester_employee.name,
current_user=requester,
)
requested = service.execute_action(
observation.id,
RiskDispositionActionCreate(
action="request_waiver",
expected_version=1,
request_id="projection-request-waiver",
waiver_reason="客户现场暂时无法补齐原件。",
waiver_scope="仅限本次报销单的当前风险观察。",
waiver_expires_at=datetime.now(UTC) + timedelta(days=2),
waiver_conditions=["到期前补齐原件"],
),
tenant_id="default",
actor_id=requester_employee.id,
actor_name=requester_employee.name,
current_user=requester,
)
disposition = service.get_disposition(observation.id, tenant_id="default")
assert disposition is not None
assert requested.disposition.version == 2
return _SeededWaiver(
observation=observation,
disposition=disposition,
requester_employee=requester_employee,
decider_employee=decider_employee,
admin_employee=admin_employee,
requester=requester,
decider=_user(decider_employee, roles=["finance"]),
admin=_user(admin_employee, roles=[], is_admin=True),
inactive_finance=_user(inactive_employee, roles=["finance"]),
)
def _build_client() -> tuple[TestClient, sessionmaker[Session]]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
factory = sessionmaker(
bind=engine,
autoflush=False,
autocommit=False,
expire_on_commit=False,
)
app = FastAPI()
app.include_router(risk_observations_router, prefix="/api/v1")
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]:
with factory() as db:
yield db
app.dependency_overrides[get_db] = override_db
return TestClient(app), factory
def _employee(suffix: str, *, employment_status: str = "在职") -> Employee:
return Employee(
id=f"emp-{suffix}",
employee_no=f"E-{suffix.upper()}",
name=f"员工-{suffix}",
email=f"{suffix}@risk-projection.example.com",
position="财务专员",
grade="P6",
employment_status=employment_status,
)
def _user(
employee: Employee,
*,
roles: list[str],
is_admin: bool = False,
tenant_id: str = "default",
) -> CurrentUserContext:
return CurrentUserContext(
username=employee.email,
name=employee.name,
role_codes=roles,
is_admin=is_admin,
tenant_id=tenant_id,
employee_no=employee.employee_no,
employee_id=employee.id,
)
def _headers(
employee: Employee,
*,
roles: list[str],
is_admin: bool = False,
) -> dict[str, str]:
return {
"X-Auth-Username": employee.email,
"X-Auth-Name": employee.id,
"X-Auth-Role-Codes": ",".join(roles),
"X-Auth-Is-Admin": str(is_admin).lower(),
"X-Auth-Employee-No": employee.employee_no,
"X-Auth-Employee-Id": employee.id,
"X-Auth-Tenant-Id": "default",
}
def _waiver_request(*, version: int, request_id: str) -> RiskDispositionActionCreate:
return RiskDispositionActionCreate(
action="request_waiver",
expected_version=version,
request_id=request_id,
waiver_reason="客户现场暂时无法补齐原件。",
waiver_scope="仅限当前报销单的当前风险观察。",
waiver_expires_at=datetime.now(UTC) + timedelta(days=2),
waiver_conditions=["到期前补齐原件"],
)
def _claim(owner_id: str) -> ExpenseClaim:
now = datetime.now(UTC)
claim = ExpenseClaim(
id="claim-risk-projection",
claim_no="BX-RISK-PROJECTION",
employee_id=owner_id,
employee_name="风险申请人",
department_id="dept-risk",
department_name="风控部",
expense_type="travel",
reason="客户拜访",
location="上海",
amount=Decimal("1200"),
currency="CNY",
invoice_count=1,
occurred_at=now,
submitted_at=now,
status="submitted",
approval_stage="财务审批",
risk_flags_json=[],
)
claim.items = [
ExpenseClaimItem(
id="item-risk-projection",
claim_id=claim.id,
item_date=date.today(),
item_type="travel",
item_reason="客户拜访交通费",
item_location="上海",
item_note="",
item_amount=claim.amount,
invoice_id="INV-RISK-PROJECTION",
created_at=now,
updated_at=now,
)
]
return claim
def _approval_task(claim: ExpenseClaim, *, now: datetime) -> ApprovalTask:
return ApprovalTask(
id="task-risk-projection",
tenant_id="default",
claim_id=claim.id,
expense_case_id=None,
node_instance_id="node-risk-projection",
node_entry_key="node-entry-risk-projection",
parent_task_id=None,
task_kind="root",
node_key="finance",
node_label="财务审批",
node_sequence=3,
sequence_order=0,
coordination_mode="single",
owner_kind="role",
owner_key="finance",
owner_name="财务审批人",
assignee_kind="role",
assignee_key="finance",
assignee_name="财务审批人",
status="pending",
version=1,
claim_status_snapshot="submitted",
claim_stage_snapshot="财务审批",
entered_at=now - timedelta(hours=1),
entered_at_source="workflow_event",
activated_at=now - timedelta(hours=1),
sla_hours_snapshot=24,
due_at=now + timedelta(hours=23),
escalation_level=1,
next_escalation_at=now + timedelta(hours=7),
priority_score=10,
priority_tier="normal",
priority_reasons_json=[
{
"code": "sla_escalated_l1",
"label": "审批已升级至 L1",
"weight": 10,
"tone": "danger",
}
],
risk_level="low",
open_risk_count=0,
evidence_completeness=Decimal("1.0000"),
batch_eligible=True,
batch_block_reasons_json=[],
projection_updated_at=now,
created_at=now,
updated_at=now,
)
def _assert_sla_window(
task: ApprovalTask,
expected: tuple[datetime, int, datetime, datetime, int],
) -> None:
actual = (
_as_utc(task.entered_at),
task.sla_hours_snapshot,
_as_utc(task.due_at),
_as_utc(task.next_escalation_at),
task.escalation_level,
)
assert actual == expected
def _observation_payload(observation_key: str) -> dict[str, object]:
return {
"observation_key": observation_key,
"subject_type": "expense_claim",
"subject_key": "claim:claim-risk-projection",
"subject_label": "BX-RISK-PROJECTION",
"claim_id": "claim-risk-projection",
"claim_no": "BX-RISK-PROJECTION",
"risk_type": "duplicate_invoice",
"risk_signal": "duplicate_invoice",
"title": "重复票据风险",
"description": "同一票据可能重复报销。",
"risk_score": 86,
"risk_level": "high",
"confidence_score": 0.91,
"control_stage": "reimbursement",
"control_mode": "risk_observation",
"automation_mode": "semi_auto_review",
"source": "financial_risk_graph",
"algorithm_version": "financial_risk_graph.v1",
"contribution_scores": {},
"baseline": {},
"evidence": [],
"graph_node_keys": [],
"graph_edge_keys": [],
"policy_refs": [],
"similar_case_claim_ids": [],
"ontology_json": {},
"decision_trace": {},
}
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)

View File

@@ -26,6 +26,7 @@ from app.services.risk_dispositions import (
RiskDispositionPermissionError,
RiskDispositionService,
RiskDispositionVersionConflictError,
RiskWaiverDecisionPermissionError,
)
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
from app.services.risk_observations import RiskObservationService
@@ -467,7 +468,9 @@ def test_risk_observation_api_enforces_pool_claim_and_typed_action_permissions(
"action": "request_waiver",
"expected_version": 0,
"request_id": "api-stale-request-001",
"comment": "申请风险豁免复核",
"waiver_reason": "客户现场条件导致合规材料暂缺",
"waiver_scope": "仅限本次客户拜访的行程材料",
"waiver_expires_at": (datetime.now(UTC) + timedelta(days=3)).isoformat(),
},
)
changed_replay = client.post(
@@ -637,9 +640,315 @@ def test_disposition_rechecks_current_approver_after_claim_stage_changes() -> No
assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 0
def test_waiver_approval_enforces_separation_of_duties_and_replays_snapshot(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
with _build_session() as db:
requester = Employee(
id="waiver-requester",
employee_no="W-REQUESTER",
name="豁免申请人",
email="waiver.requester@example.com",
employment_status="在职",
)
decider = Employee(
id="waiver-decider",
employee_no="W-DECIDER",
name="豁免决定人",
email="waiver.decider@example.com",
employment_status="在职",
)
admin_viewer = Employee(
id="waiver-admin",
employee_no="W-ADMIN",
name="仅管理员",
email="waiver.admin@example.com",
employment_status="在职",
)
inactive_decider = Employee(
id="waiver-inactive",
employee_no="W-INACTIVE",
name="离职财务",
email="waiver.inactive@example.com",
employment_status="离职",
)
db.add_all([_employee(), _claim(), requester, decider, admin_viewer, inactive_decider])
db.flush()
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:waiver:sod")
)
observation_id = observation.id
db.commit()
service = RiskDispositionService(db)
requester_user = _user(requester, roles=["finance"])
decider_user = _user(decider, roles=["finance"])
service.execute_action(
observation_id,
_action("confirm", version=0, request_id="waiver-confirm-request-001"),
tenant_id="default",
actor_id=requester.id,
actor_name=requester.name,
current_user=requester_user,
)
requested = service.execute_action(
observation_id,
RiskDispositionActionCreate(
action="request_waiver",
expected_version=1,
request_id="waiver-application-request-001",
waiver_reason="客户现场临时封控,无法在审批前取得原件。",
waiver_scope="仅限本次客户拜访的纸质行程确认材料。",
waiver_expires_at=datetime.now(UTC) + timedelta(days=5),
waiver_conditions=["三日内补交扫描件", "到期前由财务复核"],
),
tenant_id="default",
actor_id=requester.id,
actor_name=requester.name,
current_user=requester_user,
)
assert requested.disposition.lifecycle_status == "waiver_requested"
assert requested.disposition.waiver_requester_id == requester.id
assert requested.disposition.waiver_reason == "客户现场临时封控,无法在审批前取得原件。"
assert requested.disposition.waiver_conditions_json == [
"三日内补交扫描件",
"到期前由财务复核",
]
decision = RiskDispositionActionCreate(
action="approve_waiver",
expected_version=2,
request_id="waiver-approve-request-001",
comment="条件明确且有效期受控,同意本次例外。",
)
with pytest.raises(RiskWaiverDecisionPermissionError, match="不能批准或拒绝自己的"):
service.execute_action(
observation_id,
decision,
tenant_id="default",
actor_id=requester.id,
actor_name=requester.name,
current_user=requester_user,
)
with pytest.raises(RiskWaiverDecisionPermissionError, match="明确角色"):
service.execute_action(
observation_id,
decision,
tenant_id="default",
actor_id=admin_viewer.id,
actor_name=admin_viewer.name,
current_user=_user(admin_viewer, roles=[], is_admin=True),
)
with pytest.raises(RiskWaiverDecisionPermissionError, match="不是在职员工"):
service.execute_action(
observation_id,
decision,
tenant_id="default",
actor_id=inactive_decider.id,
actor_name=inactive_decider.name,
current_user=_user(inactive_decider, roles=["finance"]),
)
with pytest.raises(RiskWaiverDecisionPermissionError, match="跨租户"):
service.execute_action(
observation_id,
decision,
tenant_id="default",
actor_id=decider.id,
actor_name=decider.name,
current_user=_user(decider, roles=["finance"], tenant_id="tenant-b"),
)
approved = service.execute_action(
observation_id,
decision,
tenant_id="default",
actor_id=decider.id,
actor_name=decider.name,
current_user=decider_user,
)
replayed = service.execute_action(
observation_id,
decision,
tenant_id="default",
actor_id=decider.id,
actor_name=decider.name,
current_user=decider_user,
)
assert approved.disposition.lifecycle_status == "waived"
assert approved.disposition.waiver_decision == "approved"
assert approved.disposition.waiver_decider_id == decider.id
assert approved.disposition.waiver_decision_reason == decision.comment
assert replayed.replayed is True
assert replayed.event.id == approved.event.id
assert replayed.disposition.waiver_requester_id == requester.id
assert replayed.disposition.waiver_decider_id == decider.id
assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 3
def test_waiver_rejection_is_explicit_and_decisions_require_requested_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
with _build_session() as db:
requester = Employee(
id="waiver-reject-requester",
employee_no="W-REJECT-REQ",
name="拒绝流程申请人",
email="waiver.reject.requester@example.com",
employment_status="在职",
)
decider = Employee(
id="waiver-reject-decider",
employee_no="W-REJECT-DEC",
name="拒绝流程决定人",
email="waiver.reject.decider@example.com",
employment_status="在职",
)
db.add_all([_employee(), _claim(), requester, decider])
db.flush()
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:waiver:rejected")
)
observation_id = observation.id
db.commit()
service = RiskDispositionService(db)
requester_user = _user(requester, roles=["finance"])
decider_user = _user(decider, roles=["executive"])
with pytest.raises(RiskDispositionConflictError, match="只有待决定"):
service.execute_action(
observation_id,
RiskDispositionActionCreate(
action="reject_waiver",
expected_version=0,
request_id="waiver-reject-without-request",
comment="当前没有可拒绝的申请。",
),
tenant_id="default",
actor_id=decider.id,
actor_name=decider.name,
current_user=decider_user,
)
service.execute_action(
observation_id,
_action("confirm", version=0, request_id="waiver-reject-confirm"),
tenant_id="default",
actor_id=requester.id,
actor_name=requester.name,
current_user=requester_user,
)
service.execute_action(
observation_id,
RiskDispositionActionCreate(
action="request_waiver",
expected_version=1,
request_id="waiver-reject-application",
waiver_reason="希望免除本次高风险拦截。",
waiver_scope="本次重复票据风险。",
waiver_expires_at=datetime.now(UTC) + timedelta(days=2),
waiver_conditions=[],
),
tenant_id="default",
actor_id=requester.id,
actor_name=requester.name,
current_user=requester_user,
)
rejected = service.execute_action(
observation_id,
RiskDispositionActionCreate(
action="reject_waiver",
expected_version=2,
request_id="waiver-reject-decision",
comment="缺少足够补偿控制,拒绝豁免。",
),
tenant_id="default",
actor_id=decider.id,
actor_name=decider.name,
current_user=decider_user,
)
assert rejected.disposition.lifecycle_status == "waiver_rejected"
assert rejected.disposition.waiver_decision == "rejected"
assert rejected.disposition.waiver_decider_id == decider.id
assert rejected.event.action == "reject_waiver"
def test_waiver_self_decision_endpoint_returns_typed_forbidden_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
client, session_factory = _build_client()
requester = Employee(
id="waiver-api-requester",
employee_no="W-API-REQ",
name="接口豁免申请人",
email="waiver.api.requester@example.com",
employment_status="在职",
)
requester_user = _user(requester, roles=["finance"])
requester_headers = {
"X-Auth-Username": requester.email,
"X-Auth-Name": "Waiver Requester",
"X-Auth-Employee-No": requester.employee_no,
"X-Auth-Employee-Id": requester.id,
"X-Auth-Role-Codes": "finance",
}
with session_factory() as db:
db.add_all([_employee(), _claim(), requester])
db.flush()
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:waiver:api-sod")
)
observation_id = observation.id
db.commit()
service = RiskDispositionService(db)
service.execute_action(
observation_id,
_action("confirm", version=0, request_id="waiver-api-confirm"),
tenant_id="default",
actor_id=requester.id,
actor_name=requester.name,
current_user=requester_user,
)
service.execute_action(
observation_id,
RiskDispositionActionCreate(
action="request_waiver",
expected_version=1,
request_id="waiver-api-application",
waiver_reason="接口申请职责分离验证。",
waiver_scope="仅限当前风险观察。",
waiver_expires_at=datetime.now(UTC) + timedelta(days=2),
),
tenant_id="default",
actor_id=requester.id,
actor_name=requester.name,
current_user=requester_user,
)
response = client.post(
f"/api/v1/risk-observations/{observation_id}/disposition/actions",
headers=requester_headers,
json={
"action": "approve_waiver",
"expected_version": 2,
"request_id": "waiver-api-self-approve",
"comment": "尝试自批。",
},
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "RISK_WAIVER_DECISION_FORBIDDEN"
assert "不能批准或拒绝自己的" in response.json()["detail"]["message"]
@pytest.mark.parametrize(
"action",
["false_positive", "request_supplement", "request_waiver"],
["false_positive", "request_supplement"],
)
def test_evidence_sensitive_actions_require_server_side_comment(action: str) -> None:
with pytest.raises(ValueError, match="必须填写 comment"):
@@ -650,21 +959,39 @@ def test_evidence_sensitive_actions_require_server_side_comment(action: str) ->
)
def test_waiver_request_requires_reason_scope_and_expiry() -> None:
with pytest.raises(ValueError, match="必须填写原因、范围和有效期"):
RiskDispositionActionCreate(
action="request_waiver",
expected_version=0,
request_id="request-waiver-fields-missing",
)
def _action(
action: str,
*,
version: int,
request_id: str,
) -> RiskDispositionActionCreate:
waiver_fields: dict[str, object] = {}
if action == "request_waiver":
waiver_fields = {
"waiver_reason": "客户现场条件导致合规材料暂缺",
"waiver_scope": "仅限本次客户拜访的行程材料",
"waiver_expires_at": datetime.now(UTC) + timedelta(days=3),
"waiver_conditions": ["补齐客户确认函", "到期前完成复核"],
}
return RiskDispositionActionCreate(
action=action,
expected_version=version,
request_id=request_id,
comment=(
"风险处置说明"
if action in {"false_positive", "request_supplement", "request_waiver"}
if action in {"false_positive", "request_supplement"}
else None
),
**waiver_fields,
)
@@ -713,6 +1040,24 @@ def _employee() -> Employee:
)
def _user(
employee: Employee,
*,
roles: list[str],
is_admin: bool = False,
tenant_id: str = "default",
) -> CurrentUserContext:
return CurrentUserContext(
username=employee.email,
name=employee.name,
role_codes=roles,
is_admin=is_admin,
tenant_id=tenant_id,
employee_no=employee.employee_no,
employee_id=employee.id,
)
def _claim() -> ExpenseClaim:
now = datetime(2026, 7, 16, tzinfo=UTC)
return ExpenseClaim(

View File

@@ -20,6 +20,8 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None:
"ai_decision_feedback",
"ai_decisions",
"approval_action_ledgers",
"approval_task_events",
"approval_tasks",
"auth_sessions",
"attachment_association_jobs",
"business_events",