feat(approval): add task workflow and waiver decisions
This commit is contained in:
@@ -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",
|
||||
[
|
||||
|
||||
436
server/tests/test_approval_task_actions.py
Normal file
436
server/tests/test_approval_task_actions.py
Normal 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
|
||||
409
server/tests/test_approval_task_backfill.py
Normal file
409
server/tests/test_approval_task_backfill.py
Normal 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)
|
||||
237
server/tests/test_approval_task_concurrency_postgres.py
Normal file
237
server/tests/test_approval_task_concurrency_postgres.py
Normal 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
|
||||
485
server/tests/test_approval_task_projection.py
Normal file
485
server/tests/test_approval_task_projection.py
Normal 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)
|
||||
600
server/tests/test_approval_task_query_and_batch.py
Normal file
600
server/tests/test_approval_task_query_and_batch.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
616
server/tests/test_risk_disposition_read_projection.py
Normal file
616
server/tests/test_risk_disposition_read_projection.py
Normal 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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user