feat(approval): add safe risk disposition workflow

This commit is contained in:
caoxiaozhu
2026-07-16 15:34:58 +08:00
parent ee88a36baf
commit 4940ebc419
64 changed files with 6602 additions and 631 deletions

View File

@@ -28,7 +28,7 @@ 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_0009"
HEAD_REVISION = "20260716_0011"
SERVER_DIR = Path(__file__).resolve().parents[1]
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
@@ -357,12 +357,150 @@ def _assert_head_schema(engine: Engine) -> None:
"uq_risk_observations_tenant_key",
("tenant_id", "observation_key"),
)
_assert_unique_constraint(
engine,
"risk_observations",
"uq_risk_observations_tenant_id",
("tenant_id", "id"),
)
_assert_unique_constraint(
engine,
"few_shot_samples",
"uq_few_shot_samples_tenant_key",
("tenant_id", "sample_key"),
)
_assert_unique_constraint(
engine,
"approval_action_ledgers",
"uq_approval_action_ledger_request",
("tenant_id", "actor_id", "request_id"),
)
_assert_indexes(
engine,
"approval_action_ledgers",
{
"ix_approval_action_ledger_claim_action": (
"tenant_id",
"claim_id",
"action",
)
},
)
_assert_check_constraint(
engine,
"approval_action_ledgers",
"ck_approval_action_ledger_action",
)
_assert_check_constraint(
engine,
"approval_action_ledgers",
"ck_approval_action_ledger_completion",
)
_assert_unique_constraint(
engine,
"risk_dispositions",
"uq_risk_dispositions_tenant_observation",
("tenant_id", "observation_id"),
)
_assert_unique_constraint(
engine,
"risk_dispositions",
"uq_risk_dispositions_tenant_id",
("tenant_id", "id"),
)
_assert_composite_foreign_key(
engine,
"risk_dispositions",
("tenant_id", "observation_id"),
"risk_observations",
)
_assert_indexes(
engine,
"risk_dispositions",
{
"ix_risk_dispositions_tenant_lifecycle_due": (
"tenant_id",
"lifecycle_status",
"due_at",
),
"ix_risk_dispositions_assignee": ("tenant_id", "assignee"),
},
)
_assert_check_constraint(
engine,
"risk_dispositions",
"ck_risk_dispositions_adjudication",
)
_assert_check_constraint(
engine,
"risk_dispositions",
"ck_risk_dispositions_lifecycle",
)
_assert_check_constraint(
engine,
"risk_dispositions",
"ck_risk_dispositions_version",
)
_assert_unique_constraint(
engine,
"risk_disposition_events",
"uq_risk_disposition_events_tenant_request",
("tenant_id", "request_id"),
)
_assert_unique_constraint(
engine,
"risk_disposition_events",
"uq_risk_disposition_events_version",
("disposition_id", "version"),
)
_assert_indexes(
engine,
"risk_disposition_events",
{
"ix_risk_disposition_events_disposition_id": ("disposition_id",),
"ix_risk_disposition_events_tenant_observation_time": (
"tenant_id",
"observation_id",
"created_at",
),
},
)
_assert_check_constraint(
engine,
"risk_disposition_events",
"ck_risk_disposition_events_action",
)
_assert_check_constraint(
engine,
"risk_disposition_events",
"ck_risk_disposition_events_version",
)
_assert_composite_foreign_key(
engine,
"risk_disposition_events",
("tenant_id", "disposition_id"),
"risk_dispositions",
)
_assert_composite_foreign_key(
engine,
"risk_disposition_events",
("tenant_id", "observation_id"),
"risk_observations",
)
with engine.connect() as connection:
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 = 'risk_disposition_events' "
"AND trigger.tgname = 'trg_risk_disposition_events_append_only' "
"AND NOT trigger.tgisinternal"
)
)
or 0
)
assert append_only_trigger_count == 1
_assert_check_constraint(
engine,
"memory_entries",
@@ -972,12 +1110,15 @@ def _create_hierarchical_memory_downgrade_probe(engine: Engine) -> None:
"""
)
)
assert connection.scalar(
text(
"SELECT COUNT(*) FROM memory_entries "
"WHERE id = 'hierarchical-memory-downgrade-probe'"
assert (
connection.scalar(
text(
"SELECT COUNT(*) FROM memory_entries "
"WHERE id = 'hierarchical-memory-downgrade-probe'"
)
)
) == 1
== 1
)
def _create_duplicate_active_organization_memory_probe(engine: Engine) -> None:
@@ -1056,10 +1197,7 @@ def _create_enriched_few_shot_downgrade_probe(engine: Engine) -> None:
def _delete_enriched_few_shot_downgrade_probe(engine: Engine) -> None:
with engine.begin() as connection:
connection.execute(
text(
"DELETE FROM few_shot_samples "
"WHERE id = 'enriched-few-shot-downgrade-probe'"
)
text("DELETE FROM few_shot_samples WHERE id = 'enriched-few-shot-downgrade-probe'")
)
@@ -1116,12 +1254,8 @@ def _create_historical_case_downgrade_probe(engine: Engine) -> None:
def _assert_historical_case_downgrade_probe(engine: Engine) -> None:
inspector = inspect(engine)
risk_columns = {
str(item["name"]) for item in inspector.get_columns("risk_observations")
}
sample_columns = {
str(item["name"]) for item in inspector.get_columns("few_shot_samples")
}
risk_columns = {str(item["name"]) for item in inspector.get_columns("risk_observations")}
sample_columns = {str(item["name"]) for item in inspector.get_columns("few_shot_samples")}
assert "tenant_id" not in risk_columns
assert {"tenant_id", "policy_ref", "rule_version"}.isdisjoint(sample_columns)
assert not any(
@@ -1131,24 +1265,32 @@ def _assert_historical_case_downgrade_probe(engine: Engine) -> None:
for item in inspector.get_foreign_keys("risk_observations")
)
with engine.connect() as connection:
assert connection.scalar(
text(
"SELECT COUNT(*) FROM risk_observations "
"WHERE id = 'historical-downgrade-observation'"
assert (
connection.scalar(
text(
"SELECT COUNT(*) FROM risk_observations "
"WHERE id = 'historical-downgrade-observation'"
)
)
) == 1
assert connection.scalar(
text(
"SELECT COUNT(*) FROM risk_observation_feedback "
"WHERE id = 'historical-downgrade-feedback'"
== 1
)
assert (
connection.scalar(
text(
"SELECT COUNT(*) FROM risk_observation_feedback "
"WHERE id = 'historical-downgrade-feedback'"
)
)
) == 1
assert connection.scalar(
text(
"SELECT COUNT(*) FROM few_shot_samples "
"WHERE id = 'historical-downgrade-sample'"
== 1
)
assert (
connection.scalar(
text(
"SELECT COUNT(*) FROM few_shot_samples WHERE id = 'historical-downgrade-sample'"
)
)
) == 1
== 1
)
def _assert_legacy_sentinel(engine: Engine) -> None:
@@ -1179,6 +1321,10 @@ def _assert_base_schema(engine: Engine) -> None:
("20260716_0008_tenant_safe_historical_cases.py", "downgrade"),
("20260716_0009_organization_memory_idempotency.py", "upgrade"),
("20260716_0009_organization_memory_idempotency.py", "downgrade"),
("20260716_0010_approval_action_protocol.py", "upgrade"),
("20260716_0010_approval_action_protocol.py", "downgrade"),
("20260716_0011_risk_disposition.py", "upgrade"),
("20260716_0011_risk_disposition.py", "downgrade"),
],
)
def test_postgresql_only_migrations_reject_other_dialects_before_mutation(

View File

@@ -0,0 +1,225 @@
from __future__ import annotations
import os
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime
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_action import ApprovalActionLedger
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.models.risk_disposition import RiskDisposition
from app.models.risk_observation import RiskObservation
from app.schemas.risk_disposition import RiskDispositionActionCreate
from app.services.expense_claim_risk_gate import ExpenseClaimRiskBlockedError
from app.services.expense_claims import ExpenseClaimService
from app.services.risk_dispositions import RiskDispositionService
DATABASE_URL = os.environ.get("MIGRATION_TEST_DATABASE_URL", "").strip()
def test_disposition_reopen_and_approval_share_claim_lock(
monkeypatch: pytest.MonkeyPatch,
) -> None:
database_url = _require_disposable_database_url()
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
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]
claim_id = f"claim-risk-lock-{suffix}"
observation_id = f"risk-lock-{suffix}"
disposition_id = f"disposition-lock-{suffix}"
manager_email = f"manager-{suffix}@example.com"
manager_user = CurrentUserContext(
username=manager_email,
name="并发审批经理",
role_codes=["manager"],
is_admin=False,
)
with factory() as db:
_seed_locked_risk_case(
db,
claim_id=claim_id,
observation_id=observation_id,
disposition_id=disposition_id,
manager_email=manager_email,
suffix=suffix,
)
claim_locked = threading.Event()
release_disposition = threading.Event()
approval_started = threading.Event()
from app.services import risk_dispositions as risk_disposition_module
original_apply_action = risk_disposition_module._apply_action
def pause_after_claim_lock(*args, **kwargs):
claim_locked.set()
if not release_disposition.wait(timeout=5):
raise TimeoutError("test did not release risk disposition")
return original_apply_action(*args, **kwargs)
monkeypatch.setattr(risk_disposition_module, "_apply_action", pause_after_claim_lock)
def reopen_risk() -> str:
with factory() as db:
result = RiskDispositionService(db).execute_action(
observation_id,
RiskDispositionActionCreate(
action="confirm",
expected_version=1,
request_id=f"request-risk-reopen-{suffix}",
comment="复核后确认风险成立",
),
tenant_id="default",
actor_id=manager_email,
actor_name="并发审批经理",
current_user=manager_user,
)
return result.disposition.adjudication
def approve_claim() -> str:
approval_started.set()
with factory() as db:
try:
ExpenseClaimService(db).approve_claim(
claim_id,
manager_user,
opinion="同意",
request_id=f"request-approve-after-risk-{suffix}",
expected_status="submitted",
expected_approval_stage="直属领导审批",
)
except ExpenseClaimRiskBlockedError:
return "blocked"
return "approved"
try:
with ThreadPoolExecutor(max_workers=2) as pool:
risk_future = pool.submit(reopen_risk)
assert claim_locked.wait(timeout=5)
approval_future = pool.submit(approve_claim)
assert approval_started.wait(timeout=5)
time.sleep(0.2)
assert not approval_future.done()
release_disposition.set()
assert risk_future.result(timeout=5) == "confirmed"
assert approval_future.result(timeout=5) == "blocked"
with factory() as db:
claim = db.get(ExpenseClaim, claim_id)
disposition = db.get(RiskDisposition, disposition_id)
assert claim is not None and claim.approval_stage == "直属领导审批"
assert disposition is not None and disposition.adjudication == "confirmed"
assert (
db.scalar(
select(func.count())
.select_from(ApprovalActionLedger)
.where(ApprovalActionLedger.claim_id == claim_id)
)
== 0
)
finally:
release_disposition.set()
engine.dispose()
def _seed_locked_risk_case(
db: Session,
*,
claim_id: str,
observation_id: str,
disposition_id: str,
manager_email: str,
suffix: str,
) -> None:
manager = Employee(
id=f"manager-risk-lock-{suffix}",
employee_no=f"M-RISK-LOCK-{suffix}",
name="并发审批经理",
email=manager_email,
)
employee = Employee(
id=f"employee-risk-lock-{suffix}",
employee_no=f"E-RISK-LOCK-{suffix}",
name="并发风险员工",
email=f"employee-{suffix}@example.com",
manager=manager,
)
now = datetime.now(UTC)
claim = ExpenseClaim(
id=claim_id,
claim_no=f"EXP-RISK-LOCK-{suffix}",
employee=employee,
employee_name=employee.name,
department_name="风控部",
expense_type="travel",
reason="客户拜访",
location="上海",
amount=Decimal("1200.00"),
currency="CNY",
invoice_count=1,
occurred_at=now,
submitted_at=now,
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
observation = RiskObservation(
id=observation_id,
tenant_id="default",
observation_key=f"risk:claim-lock:{suffix}",
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=92,
risk_level="high",
confidence_score=0.95,
control_stage="reimbursement",
control_mode="risk_observation",
automation_mode="semi_auto_review",
source="financial_risk_graph",
algorithm_version="financial_risk_graph.v1",
status="false_positive",
feedback_status="false_positive",
)
disposition = RiskDisposition(
id=disposition_id,
tenant_id="default",
observation_id=observation_id,
adjudication="false_positive",
lifecycle_status="open",
version=1,
)
db.add_all([manager, employee, claim, observation, disposition])
db.commit()
def _require_disposable_database_url() -> str:
if not DATABASE_URL:
pytest.skip("仅在显式配置 MIGRATION_TEST_DATABASE_URL 时运行 PostgreSQL 并发测试")
parsed = make_url(DATABASE_URL)
host = str(parsed.host or "").replace("_", "-").lower()
database = str(parsed.database or "").replace("_", "-").lower()
if not host.startswith(("migration-probe", "disposable-probe")):
raise RuntimeError("并发测试数据库主机必须使用 disposable 前缀")
if not database.startswith(("migration-probe", "disposable-probe")):
raise RuntimeError("并发测试数据库名必须使用 disposable 前缀")
return DATABASE_URL

View File

@@ -0,0 +1,221 @@
from __future__ import annotations
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
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_workbench import ApprovalWorkbenchService
def _claim(
*,
claim_no: str = "RE-WORKBENCH-1",
amount: str = "888.00",
submitted_at: datetime,
risk_flags: list[dict] | None = None,
invoice_count: int = 1,
) -> ExpenseClaim:
claim = ExpenseClaim(
id=f"claim-{claim_no.lower()}",
claim_no=claim_no,
employee_id=None,
employee_name="张三",
department_id=None,
department_name="市场部",
project_code="PRJ-WORKBENCH",
expense_type="travel",
reason="客户现场差旅",
location="上海",
amount=Decimal(amount),
currency="CNY",
invoice_count=invoice_count,
occurred_at=submitted_at,
submitted_at=submitted_at,
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=list(risk_flags or []),
created_at=submitted_at,
updated_at=submitted_at,
)
claim.items = [
ExpenseClaimItem(
id=f"item-{claim_no.lower()}",
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-WORKBENCH" if invoice_count else None,
created_at=submitted_at,
updated_at=submitted_at,
)
]
return claim
def test_priority_queue_explains_risk_budget_sla_amount_and_history() -> None:
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
claim = _claim(
amount="60000.00",
submitted_at=now - timedelta(hours=26),
risk_flags=[
{
"source": "ai_pre_review",
"severity": "high",
"disposition": "review",
"resolution_status": "unresolved",
"route_decision": {"budget_result": {"metrics": {"after_usage_rate": "96.5"}}},
"historical_case_evidence": [{"label": "confirmed", "sample_id": "must-not-leak"}],
}
],
)
item = ApprovalWorkbenchService.build_item(claim, now=now)
assert item.priority_score >= 85
assert item.priority_tier == "urgent"
assert item.risk_level == "high"
assert item.sla_overdue is True
assert item.budget_usage_rate == 96.5
assert item.suggestion.action == "manual_review"
assert item.evidence.historical_labels == ["历史已确认,仅供复核"]
assert "must-not-leak" not in repr(item.model_dump())
assert {reason.code for reason in item.priority_reasons} >= {
"open_risk",
"sla_overdue",
"budget_pressure",
"large_amount",
}
def test_application_evidence_does_not_require_invoice_and_resolved_risk_is_ignored() -> None:
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
claim = _claim(
claim_no="AP-WORKBENCH-1",
submitted_at=now - timedelta(hours=1),
invoice_count=0,
risk_flags=[
{
"source": "ai_pre_review",
"severity": "critical",
"resolution_status": "resolved",
}
],
)
item = ApprovalWorkbenchService.build_item(claim, now=now)
assert item.evidence.completeness == 1
assert item.evidence.missing_labels == []
assert item.risk_level == "low"
assert item.open_risk_count == 0
assert item.suggestion.action == "approve_candidate"
assert item.priority_score == 0
def test_persisted_disposition_is_authoritative_over_stale_claim_risk_flags() -> None:
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
claim = _claim(
submitted_at=now - timedelta(hours=1),
risk_flags=[
{
"severity": "critical",
"triggered": True,
"observation_key": "risk:workbench:resolved",
}
],
)
observation = RiskObservation(
id="risk-workbench-resolved",
tenant_id="default",
observation_key="risk:workbench: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=95,
risk_level="critical",
confidence_score=0.96,
control_stage="reimbursement",
control_mode="risk_observation",
automation_mode="semi_auto_review",
source="financial_risk_graph",
algorithm_version="v1",
status="resolved",
feedback_status="confirmed",
)
disposition = RiskDisposition(
tenant_id="default",
observation_id=observation.id,
adjudication="confirmed",
lifecycle_status="resolved",
version=2,
)
item = ApprovalWorkbenchService.build_item(
claim,
now=now,
observation_rows=[(observation, disposition)],
)
assert item.risk_level == "low"
assert item.open_risk_count == 0
assert item.suggestion.action == "approve_candidate"
def test_persisted_low_risk_does_not_hide_unmaterialized_raw_high_risk() -> None:
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
claim = _claim(
submitted_at=now - timedelta(hours=1),
risk_flags=[
{
"source": "attachment_analysis",
"severity": "high",
"label": "票据金额异常",
"triggered": True,
}
],
)
observation = RiskObservation(
id="risk-workbench-low",
tenant_id="default",
observation_key="risk:workbench:low",
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="minor_notice",
risk_signal="minor_notice",
title="普通提醒",
description="普通提醒。",
risk_score=30,
risk_level="low",
confidence_score=0.8,
control_stage="reimbursement",
control_mode="risk_observation",
automation_mode="manual_review",
source="financial_risk_graph",
algorithm_version="v1",
status="pending_review",
feedback_status="unreviewed",
)
item = ApprovalWorkbenchService.build_item(
claim,
now=now,
observation_rows=[(observation, None)],
)
assert item.risk_level == "high"
assert item.open_risk_count == 2
assert item.suggestion.action == "manual_review"

View File

@@ -0,0 +1,322 @@
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime
from decimal import Decimal
import pytest
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from app.api.deps import CurrentUserContext
from app.db.base import Base
from app.models.approval_action import ApprovalActionLedger
from app.models.audit_log import AuditLog
from app.models.employee import Employee
from app.models.expense_case import BusinessEvent
from app.models.financial_record import ExpenseClaim
from app.services.approval_action_protocol import (
ApprovalActionConflictError,
ApprovalActionProtocol,
)
from app.services.expense_claims import ExpenseClaimService
def _manager_user() -> CurrentUserContext:
return CurrentUserContext(
username="manager-action@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
def _finance_user() -> CurrentUserContext:
return CurrentUserContext(
username="finance-action@example.com",
name="王财务",
role_codes=["finance"],
is_admin=False,
)
def _seed_claim(
db: Session,
*,
claim_id: str = "claim-action-1",
manager_email: str = "manager-action@example.com",
) -> ExpenseClaim:
manager = Employee(
id=f"manager-{claim_id}",
employee_no=f"M-{claim_id}",
name="李经理",
email=manager_email,
)
employee = Employee(
id=f"employee-{claim_id}",
employee_no=f"E-{claim_id}",
name="张三",
email=f"employee-{claim_id}@example.com",
manager=manager,
)
claim = ExpenseClaim(
id=claim_id,
claim_no=f"EXP-{claim_id}",
employee=employee,
employee_name="张三",
department_name="市场部",
expense_type="transport",
reason="客户拜访",
location="上海",
amount=Decimal("88.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 7, 16, tzinfo=UTC),
submitted_at=datetime(2026, 7, 16, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
db.add(claim)
db.commit()
return claim
@pytest.fixture
def session_factory() -> sessionmaker[Session]:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
try:
yield factory
finally:
engine.dispose()
def test_approve_replay_persists_one_ledger_event_and_audit(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
claim = _seed_claim(db)
service = ExpenseClaimService(db)
first = service.approve_claim(
claim.id,
_manager_user(),
opinion="同意",
request_id="approve-retry-1",
expected_status="submitted",
expected_approval_stage="直属领导审批",
)
replay = service.approve_claim(
claim.id,
_manager_user(),
opinion="同意",
request_id="approve-retry-1",
expected_status="submitted",
expected_approval_stage="直属领导审批",
)
assert first is not None and replay is not None
assert replay.approval_stage == "财务审批"
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 1
assert db.scalar(select(func.count()).select_from(BusinessEvent)) == 1
assert (
db.scalar(
select(func.count())
.select_from(AuditLog)
.where(AuditLog.request_id == "approve-retry-1")
)
== 1
)
def test_request_id_payload_mismatch_and_stale_preconditions_return_conflict(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
claim = _seed_claim(db, claim_id="claim-action-conflict")
service = ExpenseClaimService(db)
with pytest.raises(ApprovalActionConflictError, match="单据状态已从"):
service.approve_claim(
claim.id,
_manager_user(),
opinion="同意",
request_id="approve-stale-1",
expected_status="draft",
expected_approval_stage="直属领导审批",
)
service.approve_claim(
claim.id,
_manager_user(),
opinion="同意",
request_id="approve-conflict-1",
expected_status="submitted",
expected_approval_stage="直属领导审批",
)
with pytest.raises(ApprovalActionConflictError, match="已用于另一项"):
service.approve_claim(
claim.id,
_manager_user(),
opinion="改为有条件通过",
request_id="approve-conflict-1",
expected_status="submitted",
expected_approval_stage="直属领导审批",
)
assert (
db.scalar(
select(func.count())
.select_from(ApprovalActionLedger)
.where(ApprovalActionLedger.request_id == "approve-stale-1")
)
== 0
)
def test_action_failure_rolls_back_ledger_claim_event_and_audit(
session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
) -> None:
with session_factory() as db:
claim = _seed_claim(db, claim_id="claim-action-rollback")
service = ExpenseClaimService(db)
def fail_completion(*args, **kwargs):
raise RuntimeError("ledger completion failed")
monkeypatch.setattr(ApprovalActionProtocol, "complete", fail_completion)
with pytest.raises(RuntimeError, match="ledger completion failed"):
service.approve_claim(
claim.id,
_manager_user(),
opinion="同意",
request_id="approve-rollback-1",
expected_status="submitted",
expected_approval_stage="直属领导审批",
)
db.expire_all()
persisted = db.get(ExpenseClaim, claim.id)
assert persisted is not None
assert persisted.status == "submitted"
assert persisted.approval_stage == "直属领导审批"
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 0
assert db.scalar(select(func.count()).select_from(BusinessEvent)) == 0
assert db.scalar(select(func.count()).select_from(AuditLog)) == 0
def test_legacy_stage_repair_cannot_commit_inside_action_protocol(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
claim = _seed_claim(db, claim_id="claim-action-stage-repair")
claim.approval_stage = "预算管理者审批"
claim.risk_flags_json = [
{
"source": "manual_approval",
"event_type": "expense_claim_approval",
"previous_approval_stage": "直属领导审批",
"next_approval_stage": "预算管理者审批",
"operator": "李经理",
"next_approver_name": "李经理",
}
]
db.commit()
admin_user = CurrentUserContext(
username="admin-action@example.com",
name="审批管理员",
role_codes=["admin"],
is_admin=True,
)
with pytest.raises(ApprovalActionConflictError, match="审批节点已从"):
ExpenseClaimService(db).approve_claim(
claim.id,
admin_user,
opinion="同意",
request_id="approve-stage-repair-1",
expected_status="submitted",
expected_approval_stage="预算管理者审批",
)
db.expire_all()
persisted = db.get(ExpenseClaim, claim.id)
assert persisted is not None
assert persisted.approval_stage == "预算管理者审批"
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 0
def test_return_and_pay_actions_use_the_same_protocol(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
returned_claim = _seed_claim(db, claim_id="claim-action-return")
paid_claim = _seed_claim(
db,
claim_id="claim-action-pay",
manager_email="manager-pay-action@example.com",
)
paid_claim.status = "pending_payment"
paid_claim.approval_stage = "待付款"
db.commit()
returned = ExpenseClaimService(db).return_claim(
returned_claim.id,
_manager_user(),
reason="请补充材料",
request_id="return-action-1",
expected_status="submitted",
expected_approval_stage="直属领导审批",
)
paid = ExpenseClaimService(db).mark_claim_paid(
paid_claim.id,
_finance_user(),
request_id="pay-action-1",
expected_status="pending_payment",
expected_approval_stage="待付款",
)
assert returned is not None and returned.status == "returned"
assert paid is not None and paid.status == "paid"
ledgers = list(
db.scalars(select(ApprovalActionLedger).order_by(ApprovalActionLedger.action)).all()
)
assert [(item.action, item.result_status) for item in ledgers] == [
("pay", "paid"),
("return", "returned"),
]
def test_concurrent_identical_request_executes_once(tmp_path) -> None:
engine = create_engine(
f"sqlite+pysqlite:///{tmp_path / 'approval-action.db'}",
connect_args={"check_same_thread": False},
)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
with factory() as db:
_seed_claim(db, claim_id="claim-action-concurrent")
def approve() -> str:
with factory() as db:
result = ExpenseClaimService(db).approve_claim(
"claim-action-concurrent",
_manager_user(),
opinion="同意",
request_id="approve-concurrent-1",
expected_status="submitted",
expected_approval_stage="直属领导审批",
)
assert result is not None
return str(result.approval_stage)
try:
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(lambda _: approve(), range(2)))
assert results == ["财务审批", "财务审批"]
with factory() as db:
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 1
assert db.scalar(select(func.count()).select_from(BusinessEvent)) == 1
finally:
engine.dispose()

View File

@@ -0,0 +1,222 @@
from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
import pytest
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.db.base import Base
from app.models.approval_action import ApprovalActionLedger
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.models.risk_disposition import RiskDisposition
from app.models.risk_observation import RiskObservation
from app.services.expense_claim_risk_gate import (
ExpenseClaimRiskBlockedError,
ExpenseClaimRiskGate,
)
from app.services.expense_claims import ExpenseClaimService
def test_high_risk_requires_false_positive_or_resolved_disposition() -> None:
with _session() as db:
claim = _claim()
observation = _observation(claim)
db.add_all([claim, observation])
db.commit()
gate = ExpenseClaimRiskGate(db)
with pytest.raises(ExpenseClaimRiskBlockedError):
gate.ensure_approvable(claim, tenant_id="default")
disposition = RiskDisposition(
tenant_id="default",
observation_id=observation.id,
adjudication="false_positive",
lifecycle_status="open",
)
db.add(disposition)
db.commit()
gate.ensure_approvable(claim, tenant_id="default")
disposition.adjudication = "confirmed"
db.commit()
with pytest.raises(ExpenseClaimRiskBlockedError):
gate.ensure_approvable(claim, tenant_id="default")
disposition.lifecycle_status = "resolved"
db.commit()
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")
medium = _observation(claim, observation_id="risk-medium", risk_level="medium")
foreign = _observation(
claim,
observation_id="risk-foreign",
risk_level="critical",
tenant_id="tenant-b",
)
db.add_all([claim, medium, foreign])
db.commit()
ExpenseClaimRiskGate(db).ensure_approvable(claim, tenant_id="default")
def test_unmaterialized_raw_high_risk_blocks_approval() -> None:
with _session() as db:
claim = _claim(claim_id="claim-risk-raw-only")
claim.risk_flags_json = [
{
"source": "attachment_analysis",
"severity": "high",
"label": "票据金额异常",
"message": "票据金额与申报金额不一致。",
"triggered": True,
}
]
db.add(claim)
db.commit()
with pytest.raises(ExpenseClaimRiskBlockedError) as captured:
ExpenseClaimRiskGate(db).ensure_approvable(claim, tenant_id="default")
assert captured.value.blockers[0].observation_id.startswith("raw:")
assert captured.value.blockers[0].risk_level == "high"
def test_persisted_observation_does_not_hide_another_raw_high_risk() -> None:
with _session() as db:
claim = _claim(claim_id="claim-risk-partial-materialization")
claim.risk_flags_json = [
{
"source": "attachment_analysis",
"severity": "critical",
"label": "另一条未物化风险",
"triggered": True,
}
]
low_observation = _observation(
claim,
observation_id="risk-low-materialized",
risk_level="low",
)
db.add_all([claim, low_observation])
db.commit()
with pytest.raises(ExpenseClaimRiskBlockedError) as captured:
ExpenseClaimRiskGate(db).ensure_approvable(claim, tenant_id="default")
assert [item.risk_level for item in captured.value.blockers] == ["critical"]
def test_blocked_approval_rolls_back_action_ledger_and_claim_mutation() -> None:
with _session() as db:
manager = Employee(
id="manager-risk-gate",
employee_no="M-RISK-GATE",
name="风险经理",
email="risk-gate-manager@example.com",
)
employee = Employee(
id="employee-risk-gate",
employee_no="E-RISK-GATE",
name="风险员工",
email="risk-gate-employee@example.com",
manager=manager,
)
claim = _claim(employee=employee, claim_id="claim-risk-blocked-approval")
db.add_all([manager, employee, claim, _observation(claim)])
db.commit()
with pytest.raises(ExpenseClaimRiskBlockedError):
ExpenseClaimService(db).approve_claim(
claim.id,
CurrentUserContext(
username=manager.email,
name=manager.name,
role_codes=["manager"],
is_admin=False,
),
opinion="同意",
request_id="risk-blocked-approval-001",
expected_status="submitted",
expected_approval_stage="直属领导审批",
)
db.expire_all()
persisted = db.get(ExpenseClaim, claim.id)
assert persisted is not None
assert persisted.status == "submitted"
assert persisted.approval_stage == "直属领导审批"
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 0
def _session() -> Session:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(bind=engine)
return Session(engine)
def _claim(
*,
claim_id: str = "claim-risk-gate",
employee: Employee | None = None,
) -> ExpenseClaim:
now = datetime(2026, 7, 16, tzinfo=UTC)
return ExpenseClaim(
id=claim_id,
claim_no=f"EXP-{claim_id}",
employee=employee,
employee_name=employee.name if employee else "风险员工",
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=[],
)
def _observation(
claim: ExpenseClaim,
*,
observation_id: str = "risk-gate-observation",
risk_level: str = "high",
tenant_id: str = "default",
) -> RiskObservation:
return RiskObservation(
id=f"{observation_id}-{claim.id}",
tenant_id=tenant_id,
observation_key=f"risk:{tenant_id}:{observation_id}:{claim.id}",
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=90,
risk_level=risk_level,
confidence_score=0.95,
control_stage="reimbursement",
control_mode="risk_observation",
automation_mode="semi_auto_review",
source="financial_risk_graph",
algorithm_version="financial_risk_graph.v1",
status="pending_review",
feedback_status="unreviewed",
)

View File

@@ -38,7 +38,6 @@ from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
)
from app.services.expense_claims import ExpenseClaimService
from app.services.ocr import OcrService
@@ -486,11 +485,13 @@ def test_upsert_draft_from_ontology_persists_linked_application_context() -> Non
)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-linked-1",
claim_no="AP-202605-001",
employee=employee,
))
db.add(
build_application_claim(
id="application-linked-1",
claim_no="AP-202605-001",
employee=employee,
)
)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
@@ -554,11 +555,13 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-linked-no-receipt",
claim_no="AP-202606-001",
employee=employee,
))
db.add(
build_application_claim(
id="application-linked-no-receipt",
claim_no="AP-202606-001",
employee=employee,
)
)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
@@ -623,7 +626,10 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
)
assert link_flag["application_claim_no"] == "AP-202606-001"
assert link_flag["application_detail"]["application_time"] == "2026-02-20 至 2026-02-23"
assert link_flag["application_detail"]["application_business_time"] == "2026-02-20 至 2026-02-23"
assert (
link_flag["application_detail"]["application_business_time"]
== "2026-02-20 至 2026-02-23"
)
assert link_flag["application_detail"]["application_date"] == "2026-06-02T00:58:00Z"
assert link_flag["application_detail"]["application_amount"] == "3000"
assert link_flag["application_detail"]["application_days"] == "4 天"
@@ -649,11 +655,13 @@ def test_upsert_linked_application_draft_clears_existing_placeholder_item() -> N
)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-linked-existing-placeholder",
claim_no="AP-202606-002",
employee=employee,
))
db.add(
build_application_claim(
id="application-linked-existing-placeholder",
claim_no="AP-202606-002",
employee=employee,
)
)
existing_claim = ExpenseClaim(
claim_no="RE-202606020001-PLACEHOLDER",
employee_id=employee.id,
@@ -738,12 +746,14 @@ def test_upsert_linked_application_requires_approved_application() -> None:
employee = Employee(employee_no="E5108", name="Linked Employee", email=user_id)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-returned-blocked",
claim_no="AP-202606-STATUS",
employee=employee,
status="returned",
))
db.add(
build_application_claim(
id="application-returned-blocked",
claim_no="AP-202606-STATUS",
employee=employee,
status="returned",
)
)
db.commit()
ontology = SemanticOntologyService(db).parse(
@@ -785,11 +795,13 @@ def test_upsert_linked_application_rejects_duplicate_reimbursement_draft() -> No
employee = Employee(employee_no="E5109", name="Linked Employee", email=user_id)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-duplicate-blocked",
claim_no="AP-202606-DUP",
employee=employee,
))
db.add(
build_application_claim(
id="application-duplicate-blocked",
claim_no="AP-202606-DUP",
employee=employee,
)
)
existing_claim = ExpenseClaim(
claim_no="RE-202606-DUP-DRAFT",
employee_id=employee.id,
@@ -995,11 +1007,7 @@ def test_unsaved_conversation_expires_after_retention_but_saved_conversation_sta
def test_resolve_expense_type_maps_office_supplies_review_value_to_office() -> None:
expense_type = ExpenseClaimService._resolve_expense_type(
[],
context_json={
"review_form_values": {
"expense_type": "办公用品"
}
},
context_json={"review_form_values": {"expense_type": "办公用品"}},
)
assert expense_type == "office"
@@ -1008,11 +1016,7 @@ def test_resolve_expense_type_maps_office_supplies_review_value_to_office() -> N
def test_resolve_expense_type_maps_riding_fare_review_value_to_transport() -> None:
expense_type = ExpenseClaimService._resolve_expense_type(
[],
context_json={
"review_form_values": {
"expense_type": "乘车费用"
}
},
context_json={"review_form_values": {"expense_type": "乘车费用"}},
)
assert expense_type == "transport"
@@ -1340,7 +1344,9 @@ def test_upsert_draft_from_ontology_supports_link_or_create_for_multi_documents(
"text": "停车费 合计 18 元",
"document_type": "parking_toll_receipt",
"scene_code": "transport",
"document_fields": [{"key": "total_amount", "label": "合计金额", "value": "18"}],
"document_fields": [
{"key": "total_amount", "label": "合计金额", "value": "18"}
],
},
],
}
@@ -2044,6 +2050,7 @@ def test_update_claim_item_reanalyzes_existing_attachment(monkeypatch, tmp_path)
assert refreshed_meta["requirement_check"]["matches"] is False
assert any("附件类型要求" in point for point in refreshed_meta["analysis"]["points"])
def test_upload_attachment_refreshes_claim_pre_review(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-1",
@@ -2518,15 +2525,13 @@ def test_upload_attachment_runs_rule_center_city_risk_from_origin_destination_fi
flags = payload["claim_risk_flags"]
assert any(
isinstance(flag, dict)
and flag.get("rule_code") == "risk.travel.high.city_mismatch"
isinstance(flag, dict) and flag.get("rule_code") == "risk.travel.high.city_mismatch"
for flag in flags
)
city_flag = next(
flag
for flag in flags
if isinstance(flag, dict)
and flag.get("rule_code") == "risk.travel.high.city_mismatch"
if isinstance(flag, dict) and flag.get("rule_code") == "risk.travel.high.city_mismatch"
)
assert city_flag.get("item_ids") == [claim.items[0].id]
@@ -2604,8 +2609,7 @@ def test_upload_attachment_uses_linked_application_business_time_for_date_risk(
flags = payload["claim_risk_flags"]
assert any(
isinstance(flag, dict)
and flag.get("rule_code") == "risk.travel.high.date_outside_trip"
isinstance(flag, dict) and flag.get("rule_code") == "risk.travel.high.date_outside_trip"
for flag in flags
)
@@ -2686,8 +2690,13 @@ def test_upload_hotel_attachment_audits_date_like_amount(monkeypatch, tmp_path)
)
assert uploaded_meta is not None
assert uploaded_meta["analysis"]["severity"] == "medium"
assert any("费用核算" in point and "828.00 元" in point for point in uploaded_meta["analysis"]["points"])
assert not any("2026.00 元与报销金额" in point for point in uploaded_meta["analysis"]["points"])
assert any(
"费用核算" in point and "828.00 元" in point
for point in uploaded_meta["analysis"]["points"]
)
assert not any(
"2026.00 元与报销金额" in point for point in uploaded_meta["analysis"]["points"]
)
def test_upload_hotel_attachment_flags_amount_over_travel_policy(monkeypatch, tmp_path) -> None:
@@ -2889,7 +2898,9 @@ def test_upload_hotel_attachment_does_not_add_generic_auto_review_summary(
)
def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypatch, tmp_path) -> None:
def test_delete_claim_item_attachment_removes_attachment_analysis_risk(
monkeypatch, tmp_path
) -> None:
current_user = CurrentUserContext(
username="emp-hotel-risk@example.com",
name="张三",
@@ -2964,7 +2975,8 @@ def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypat
assert upload_payload is not None
assert any(
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "attachment_analysis"
for flag in upload_payload["claim_risk_flags"]
)
@@ -2977,7 +2989,8 @@ def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypat
assert delete_payload is not None
assert delete_payload["invoice_id"] is None
assert not any(
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "attachment_analysis"
for flag in delete_payload["claim_risk_flags"]
)
assert not any(
@@ -2990,7 +3003,8 @@ def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypat
assert claim.invoice_count == 0
assert claim.items[0].invoice_id is None
assert not any(
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "attachment_analysis"
for flag in list(claim.risk_flags_json or [])
)
@@ -3278,7 +3292,9 @@ def test_applicant_can_delete_own_editable_draft_claim(monkeypatch, tmp_path) ->
assert db.get(ExpenseClaim, claim_id) is None
def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(monkeypatch, tmp_path) -> None:
def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(
monkeypatch, tmp_path
) -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
@@ -3316,7 +3332,9 @@ def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(mon
assert filename == "legacy-ticket.pdf"
def test_attachment_pdf_preview_falls_back_to_source_when_render_fonts_missing(monkeypatch, tmp_path) -> None:
def test_attachment_pdf_preview_falls_back_to_source_when_render_fonts_missing(
monkeypatch, tmp_path
) -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
@@ -3359,9 +3377,13 @@ def test_attachment_pdf_preview_falls_back_to_source_when_render_fonts_missing(m
def fake_render_pdf_first_page(*, pdf_path, preview_path, timeout_seconds):
raise RuntimeError("Missing language pack for 'Adobe-GB1' mapping")
monkeypatch.setattr(DocumentPreviewAssets, "render_pdf_first_page", fake_render_pdf_first_page)
monkeypatch.setattr(
DocumentPreviewAssets, "render_pdf_first_page", fake_render_pdf_first_page
)
resolved_path, media_type, filename = ExpenseClaimService(db).get_claim_item_attachment_preview_content(
resolved_path, media_type, filename = ExpenseClaimService(
db
).get_claim_item_attachment_preview_content(
claim_id=claim.id,
item_id=claim.items[0].id,
current_user=current_user,
@@ -3412,6 +3434,7 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
assert submitted.approval_stage == "直属领导审批"
assert submitted.submitted_at is not None
def test_submit_claim_refreshes_legacy_pre_review_without_fingerprint(monkeypatch) -> None:
current_user = CurrentUserContext(
username="emp-submit@example.com",
@@ -3470,13 +3493,10 @@ def test_submit_claim_refreshes_legacy_pre_review_without_fingerprint(monkeypatc
assert submitted.status == "submitted"
assert review_calls == 1
assert not any(
flag.get("label") == "upload-time-warning"
for flag in submitted.risk_flags_json
flag.get("label") == "upload-time-warning" for flag in submitted.risk_flags_json
)
pre_review_flag = next(
flag
for flag in submitted.risk_flags_json
if flag.get("source") == "ai_pre_review"
flag for flag in submitted.risk_flags_json if flag.get("source") == "ai_pre_review"
)
assert pre_review_flag["review_id"]
assert pre_review_flag["input_fingerprint"].startswith("sha256:")
@@ -3823,8 +3843,7 @@ def test_submit_claim_blocks_high_risk_attachment_until_submitter_fixes_it(
assert blocked.submitted_at is None
assert error_info.value.review["decision"] == "needs_fix"
assert any(
finding["severity"] == "high"
and finding["disposition"] == "fix"
finding["severity"] == "high" and finding["disposition"] == "fix"
for finding in error_info.value.review["findings"]
)
@@ -4000,10 +4019,7 @@ def test_submit_claim_blocks_travel_route_mismatch_until_submitter_explains_it(
if "多城市" in finding["message"] or "终点" in finding["message"]
]
assert route_findings
assert any(
"travel-item-2" in finding["item_ids"]
for finding in route_findings
)
assert any("travel-item-2" in finding["item_ids"] for finding in route_findings)
def test_submit_claim_allows_round_trip_ticket_origin_inferred_from_route(
@@ -4297,8 +4313,7 @@ def test_submit_claim_blocks_hotel_amount_over_policy_until_standard_adjustment(
assert blocked.status == "draft"
assert error_info.value.review["decision"] == "needs_fix"
assert any(
finding.get("remediation", {}).get("alternative_action")
== "accept_standard_limit"
finding.get("remediation", {}).get("alternative_action") == "accept_standard_limit"
for finding in error_info.value.review["findings"]
)
assert any(
@@ -5360,12 +5375,15 @@ def test_admin_delete_linked_reimbursement_resets_application_link_status() -> N
sync_flag = next(
flag
for flag in application_claim.risk_flags_json
if isinstance(flag, dict) and flag.get("event_type") == "expense_application_reimbursement_deleted"
if isinstance(flag, dict)
and flag.get("event_type") == "expense_application_reimbursement_deleted"
)
assert sync_flag["source"] == "application_link_sync"
assert sync_flag["severity"] == "info"
assert sync_flag["actionability"] == "system_trace"
assert sync_flag["deleted_reimbursement_claim_id"] == "reimbursement-delete-linked-application"
assert (
sync_flag["deleted_reimbursement_claim_id"] == "reimbursement-delete-linked-application"
)
assert sync_flag["deleted_reimbursement_claim_no"] == "RDELETE01"
assert sync_flag["next_approval_stage"] == APPLICATION_LINK_STATUS_STAGE
@@ -5414,7 +5432,9 @@ def test_direct_manager_can_return_subordinate_claim_to_pending_submission() ->
db.commit()
claim_id = claim.id
returned = ExpenseClaimService(db).return_claim(claim_id, current_user, reason="请补充行程说明")
returned = ExpenseClaimService(db).return_claim(
claim_id, current_user, reason="请补充行程说明"
)
assert returned is not None
assert returned.status == "returned"
@@ -5603,6 +5623,7 @@ def test_direct_manager_budget_monitor_routes_reimbursement_directly_to_finance(
{
"source": "submission_review",
"severity": "high",
"actionability": "route_review",
"label": "报销风险复核",
"message": "多城市行程和住宿超标需要预算管理者二次确认。",
}
@@ -5621,8 +5642,7 @@ def test_direct_manager_budget_monitor_routes_reimbursement_directly_to_finance(
assert approved.status == "submitted"
assert approved.approval_stage == "财务审批"
assert not any(
isinstance(flag, dict)
and flag.get("next_approval_stage") == "预算管理者审批"
isinstance(flag, dict) and flag.get("next_approval_stage") == "预算管理者审批"
for flag in approved.risk_flags_json
)
assert any(
@@ -5635,12 +5655,13 @@ def test_direct_manager_budget_monitor_routes_reimbursement_directly_to_finance(
and flag.get("next_status") == "submitted"
and flag.get("next_approval_stage") == "财务审批"
and flag.get("budget_approval_merged") is True
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
and flag.get("budget_approval_merged_reason")
== "direct_manager_is_department_budget_approver"
for flag in approved.risk_flags_json
)
def test_duplicate_budget_stage_from_legacy_reimbursement_is_repaired_on_read() -> None:
def test_legacy_duplicate_budget_stage_is_not_mutated_by_read() -> None:
admin_user = CurrentUserContext(
username="admin",
name="admin",
@@ -5706,20 +5727,25 @@ def test_duplicate_budget_stage_from_legacy_reimbursement_is_repaired_on_read()
db.add(claim)
db.commit()
repaired = ExpenseClaimService(db).get_claim(claim.id, admin_user)
result = ExpenseClaimService(db).get_claim(claim.id, admin_user)
assert repaired is not None
assert repaired.approval_stage == FINANCE_APPROVAL_STAGE
assert any(
assert result is not None
assert result.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
assert not any(
isinstance(flag, dict)
and flag.get("source") == "approval_flow_repair"
and flag.get("event_type") == "duplicate_budget_approval_stage_repaired"
and flag.get("next_approval_stage") == FINANCE_APPROVAL_STAGE
for flag in repaired.risk_flags_json
for flag in result.risk_flags_json
)
db.expire_all()
persisted = db.get(ExpenseClaim, claim.id)
assert persisted is not None
assert persisted.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch: pytest.MonkeyPatch) -> None:
def test_application_submit_skips_ai_review_and_receipt_requirements(
monkeypatch: pytest.MonkeyPatch,
) -> None:
current_user = CurrentUserContext(
username="application-owner@example.com",
name="张三",
@@ -5970,7 +5996,9 @@ def test_application_submit_skips_budget_for_non_demo_subject() -> None:
)
def test_direct_manager_can_route_application_claim_to_budget_approval_then_budget_manager_creates_draft() -> None:
def test_direct_manager_can_route_application_claim_to_budget_approval_then_budget_manager_creates_draft() -> (
None
):
manager_user = CurrentUserContext(
username="manager-application-approve@example.com",
name="李经理",
@@ -6053,9 +6081,10 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
{
"source": "submission_review",
"severity": "high",
"actionability": "route_review",
"label": "申请风险复核",
"message": "申请金额和行程安排需要预算管理者二次确认。",
}
},
],
)
db.add(claim)
@@ -6120,13 +6149,18 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
and flag.get("source") == "application_handoff"
and flag.get("event_type") == "expense_application_to_reimbursement_draft"
and flag.get("application_claim_no") == "APP-20260525-APPROVE"
and flag.get("application_detail", {}).get("application_content") == "差旅费用申请 / 上海"
and flag.get("application_detail", {}).get("application_reason") == "支撑国网服务器上线部署"
and flag.get("application_detail", {}).get("application_content")
== "差旅费用申请 / 上海"
and flag.get("application_detail", {}).get("application_reason")
== "支撑国网服务器上线部署"
and flag.get("application_detail", {}).get("application_days") == "3 天"
and flag.get("application_detail", {}).get("application_transport_mode") == "高铁"
and flag.get("application_detail", {}).get("application_lodging_daily_cap") == "600元/天"
and flag.get("application_detail", {}).get("application_subsidy_daily_cap") == "120元/天"
and flag.get("application_detail", {}).get("application_transport_policy") == "按真实票据复核"
and flag.get("application_detail", {}).get("application_lodging_daily_cap")
== "600元/天"
and flag.get("application_detail", {}).get("application_subsidy_daily_cap")
== "120元/天"
and flag.get("application_detail", {}).get("application_transport_policy")
== "按真实票据复核"
and flag.get("application_detail", {}).get("application_policy_estimate")
== "交通按真实票据 + 住宿 1,800元 + 补贴 360元"
and flag.get("application_detail", {}).get("application_rule_name") == "差旅标准规则"
@@ -6214,6 +6248,7 @@ def test_application_routes_to_department_p8_executive_with_approver_name() -> N
{
"source": "submission_review",
"severity": "high",
"actionability": "route_review",
"label": "Route risk",
"message": "Application requires budget confirmation.",
}
@@ -6306,6 +6341,7 @@ def test_direct_manager_cannot_route_application_to_missing_budget_approver() ->
{
"source": "submission_review",
"severity": "high",
"actionability": "route_review",
"label": "Route risk",
"message": "Application requires budget confirmation.",
}
@@ -6328,7 +6364,9 @@ def test_direct_manager_cannot_route_application_to_missing_budget_approver() ->
assert reimbursement_claim_query(db).count() == 0
def test_direct_manager_p8_executive_completes_application_without_duplicate_budget_approval() -> None:
def test_direct_manager_p8_executive_completes_application_without_duplicate_budget_approval() -> (
None
):
manager_user = CurrentUserContext(
username="manager-executive-merged@example.com",
name="P8 Manager",
@@ -6381,6 +6419,7 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud
{
"source": "submission_review",
"severity": "high",
"actionability": "route_review",
"label": "Route risk",
"message": "Application requires budget confirmation.",
}
@@ -6411,12 +6450,15 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud
and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == APPLICATION_LINK_STATUS_STAGE
and flag.get("budget_approval_merged") is True
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
and flag.get("budget_approval_merged_reason")
== "direct_manager_is_department_budget_approver"
for flag in approved.risk_flags_json
)
def test_direct_manager_budget_monitor_completes_application_claim_without_duplicate_budget_approval() -> None:
def test_direct_manager_budget_monitor_completes_application_claim_without_duplicate_budget_approval() -> (
None
):
manager_user = CurrentUserContext(
username="manager-budget-monitor-application@example.com",
name="李预算经理",
@@ -6469,6 +6511,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
{
"source": "submission_review",
"severity": "high",
"actionability": "route_review",
"label": "申请风险复核",
"message": "申请金额和行程安排需要预算管理者二次确认。",
}
@@ -6489,8 +6532,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
assert approved.approval_stage == "关联单据状态"
assert reimbursement_claim_query(db).count() == 1
assert not any(
isinstance(flag, dict)
and flag.get("next_approval_stage") == "预算管理者审批"
isinstance(flag, dict) and flag.get("next_approval_stage") == "预算管理者审批"
for flag in approved.risk_flags_json
)
assert any(
@@ -6503,7 +6545,8 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == "关联单据状态"
and flag.get("budget_approval_merged") is True
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
and flag.get("budget_approval_merged_reason")
== "direct_manager_is_department_budget_approver"
for flag in approved.risk_flags_json
)
generated_draft = reimbursement_claim_query(db).one()
@@ -6692,6 +6735,7 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
{
"source": "platform_risk",
"severity": "high",
"actionability": "route_review",
"label": "申请风险复核",
"message": "申请金额和行程安排需要预算管理者二次确认。",
}
@@ -6719,10 +6763,11 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
assert reservation.source_type == "claim"
assert reservation.source_id == generated_draft.id
assert reservation.source_no == generated_draft.claim_no
assert any(item.transaction_type == "transfer" for item in db.query(BudgetTransaction).all())
assert any(
isinstance(flag, dict)
and flag.get("event_type") == "budget_reservation_transferred"
item.transaction_type == "transfer" for item in db.query(BudgetTransaction).all()
)
assert any(
isinstance(flag, dict) and flag.get("event_type") == "budget_reservation_transferred"
for flag in generated_draft.risk_flags_json
)
@@ -6916,7 +6961,12 @@ def test_finance_approve_reimbursement_consumes_budget_reservation() -> None:
db.refresh(reservation)
assert reservation.source_status == "consumed"
assert reservation.consumed_amount == Decimal("12000.00")
assert db.query(BudgetTransaction).filter(BudgetTransaction.transaction_type == "consume").count() == 1
assert (
db.query(BudgetTransaction)
.filter(BudgetTransaction.transaction_type == "consume")
.count()
== 1
)
assert any(
isinstance(flag, dict)
and flag.get("source") == "budget_control"
@@ -7290,7 +7340,10 @@ def test_return_claim_records_each_return_event_with_stage_reason_and_counts() -
assert return_events[0]["stage_return_count"] == 1
assert return_events[0]["return_stage"] == "直属领导审批"
assert return_events[0]["reason_codes"] == ["invoice_mismatch", "business_explanation"]
assert return_events[0]["risk_points"] == ["票据类型/金额与明细不一致", "业务事由/地点/人员信息不完整"]
assert return_events[0]["risk_points"] == [
"票据类型/金额与明细不一致",
"业务事由/地点/人员信息不完整",
]
assert return_events[0]["reason"] == "发票金额与明细金额不一致,请重新核对。"
assert return_events[0]["operator_role_codes"] == ["manager"]
assert return_events[1]["return_count"] == 2
@@ -7624,14 +7677,16 @@ def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applica
email="budget-list-market@example.com",
organization_unit=market_department,
)
db.add_all([
delivery_department,
market_department,
budget_manager,
p8_without_budget_employee,
employee,
market_employee,
])
db.add_all(
[
delivery_department,
market_department,
budget_manager,
p8_without_budget_employee,
employee,
market_employee,
]
)
db.flush()
db.add_all(
[
@@ -7702,5 +7757,7 @@ def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applica
assert getattr(claims[0], "budget_approver_name", "") == "赵预算"
assert getattr(claims[0], "budget_approver_grade", "") == "P8"
assert getattr(claims[0], "budget_approver_role_code", "") == "budget_monitor"
claims_without_budget_role = ExpenseClaimService(db).list_approval_claims(p8_without_budget_role)
claims_without_budget_role = ExpenseClaimService(db).list_approval_claims(
p8_without_budget_role
)
assert [claim.claim_no for claim in claims_without_budget_role] == []

View File

@@ -137,6 +137,14 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set(
"20260716_0009",
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0009"] - {"memory_entries"},
),
(
"20260716_0010",
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0010"] - {"approval_action_ledgers"},
),
(
"20260716_0011",
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0011"] - {"risk_disposition_events"},
),
],
)
def test_known_revision_with_missing_or_unexpected_owned_tables_is_rejected(

View File

@@ -9,7 +9,7 @@ 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, select
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
@@ -17,6 +17,7 @@ from app.api.deps import get_db
from app.db.base import Base
from app.main import create_app
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
from app.models.approval_action import ApprovalActionLedger
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.employee import Employee
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
@@ -213,12 +214,15 @@ def test_claim_submit_returns_structured_pre_review_conflict() -> None:
assert claim is not None
assert claim.status == "draft"
assert claim.submitted_at is None
assert db.scalar(
select(BusinessEvent).where(
BusinessEvent.aggregate_id == claim.id,
BusinessEvent.event_type == "claim_submitted",
assert (
db.scalar(
select(BusinessEvent).where(
BusinessEvent.aggregate_id == claim.id,
BusinessEvent.event_type == "claim_submitted",
)
)
) is None
is None
)
def test_claim_submit_returns_changed_conflict_when_dynamic_review_changes(
@@ -402,7 +406,9 @@ def test_claim_read_attaches_finance_approver_name_for_finance_stage() -> None:
db.commit()
headers = {"x-auth-username": "qianqi@example.com"}
response = client.get("/api/v1/reimbursements/claims/claim-finance-stage-reader", headers=headers)
response = client.get(
"/api/v1/reimbursements/claims/claim-finance-stage-reader", headers=headers
)
assert response.status_code == 200
payload = response.json()
assert payload["finance_owner_name"] == "Wang Finance Group"
@@ -518,7 +524,9 @@ def test_claim_item_attachment_upload_preview_and_delete(monkeypatch, tmp_path)
meta_payload = meta_response.json()
assert meta_payload["media_type"] == "image/png"
assert meta_payload["preview_kind"] == "image"
assert meta_payload["preview_url"].endswith(f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview")
assert meta_payload["preview_url"].endswith(
f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview"
)
assert meta_payload["analysis"]["headline"]
assert meta_payload["document_info"]["fields"][0]["label"] == "金额"
@@ -550,7 +558,9 @@ def test_claim_item_attachment_upload_preview_and_delete(monkeypatch, tmp_path)
assert deleted_meta_response.status_code == 404
def test_claim_item_attachment_upload_flags_purpose_and_amount_mismatch(monkeypatch, tmp_path) -> None:
def test_claim_item_attachment_upload_flags_purpose_and_amount_mismatch(
monkeypatch, tmp_path
) -> None:
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
@@ -596,7 +606,9 @@ def test_claim_item_attachment_upload_flags_purpose_and_amount_mismatch(monkeypa
assert upload_response.json()["attachment"]["requirement_check"]["matches"] is False
def test_claim_item_attachment_upload_flags_non_invoice_image_as_high_risk(monkeypatch, tmp_path) -> None:
def test_claim_item_attachment_upload_flags_non_invoice_image_as_high_risk(
monkeypatch, tmp_path
) -> None:
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
@@ -679,14 +691,32 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review()
db.add_all([manager, employee, claim])
db.commit()
action_headers = {
"X-Auth-Username": "manager-approve-api@example.com",
"X-Auth-Name": "manager-approve-api@example.com",
"X-Auth-Role-Codes": "manager",
}
stale_response = client.post(
"/api/v1/reimbursements/claims/claim-approve-1/approve",
json={
"opinion": "情况属实,同意报销。",
"request_id": "approve-api-stale-1",
"expected_status": "draft",
"expected_approval_stage": "直属领导审批",
},
headers=action_headers,
)
assert stale_response.status_code == 409
response = client.post(
"/api/v1/reimbursements/claims/claim-approve-1/approve",
json={"opinion": "情况属实,同意报销。"},
headers={
"X-Auth-Username": "manager-approve-api@example.com",
"X-Auth-Name": "manager-approve-api@example.com",
"X-Auth-Role-Codes": "manager",
json={
"opinion": "情况属实,同意报销。",
"request_id": "approve-api-claim-1",
"expected_status": "submitted",
"expected_approval_stage": "直属领导审批",
},
headers=action_headers,
)
assert response.status_code == 200
@@ -701,13 +731,136 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review()
for item in payload["risk_flags_json"]
)
approval_events = [
item
for item in payload["risk_flags_json"]
if item["source"] == "manual_approval"
item for item in payload["risk_flags_json"] if item["source"] == "manual_approval"
]
assert approval_events[0]["operator"] == "李经理"
assert "manager-approve-api@example.com" not in approval_events[0]["message"]
replay_response = client.post(
"/api/v1/reimbursements/claims/claim-approve-1/approve",
json={
"opinion": "情况属实,同意报销。",
"request_id": "approve-api-claim-1",
"expected_status": "submitted",
"expected_approval_stage": "直属领导审批",
},
headers=action_headers,
)
assert replay_response.status_code == 200
assert replay_response.json()["approval_stage"] == "财务审批"
changed_payload_response = client.post(
"/api/v1/reimbursements/claims/claim-approve-1/approve",
json={
"opinion": "改为有条件通过",
"request_id": "approve-api-claim-1",
"expected_status": "submitted",
"expected_approval_stage": "直属领导审批",
},
headers=action_headers,
)
assert changed_payload_response.status_code == 409
with session_factory() as db:
ledgers = list(db.scalars(select(ApprovalActionLedger)).all())
assert len(ledgers) == 1
assert ledgers[0].completed_at is not None
def test_approve_claim_endpoint_blocks_open_high_risk_with_machine_readable_detail() -> None:
client, session_factory = build_client()
with session_factory() as db:
manager = Employee(
id="manager-risk-block-api",
employee_no="M-RISK-BLOCK-API",
name="风险经理",
email="manager-risk-block-api@example.com",
)
employee = Employee(
id="employee-risk-block-api",
employee_no="E-RISK-BLOCK-API",
name="风险员工",
email="employee-risk-block-api@example.com",
manager=manager,
)
claim = ExpenseClaim(
id="claim-risk-block-api",
claim_no="EXP-RISK-BLOCK-API",
employee=employee,
employee_name=employee.name,
department_name="风控部",
expense_type="travel",
reason="客户拜访",
location="上海",
amount=Decimal("1200.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 7, 16, tzinfo=UTC),
submitted_at=datetime(2026, 7, 16, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
observation = RiskObservation(
id="risk-block-api-observation",
tenant_id="default",
observation_key="risk:claim-risk-block-api:duplicate",
subject_type="expense_claim",
subject_key="claim:claim-risk-block-api",
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=92,
risk_level="high",
confidence_score=0.95,
control_stage="reimbursement",
control_mode="risk_observation",
automation_mode="semi_auto_review",
source="financial_risk_graph",
algorithm_version="financial_risk_graph.v1",
status="pending_review",
feedback_status="unreviewed",
)
db.add_all([manager, employee, claim, observation])
db.commit()
manager_email = manager.email
response = client.post(
"/api/v1/reimbursements/claims/claim-risk-block-api/approve",
headers={
"X-Auth-Username": manager_email,
"X-Auth-Name": "Risk Manager",
"X-Auth-Role-Codes": "manager",
},
json={
"opinion": "同意",
"request_id": "approve-risk-block-api-001",
"expected_status": "submitted",
"expected_approval_stage": "直属领导审批",
},
)
assert response.status_code == 409
detail = response.json()["detail"]
assert detail["code"] == "APPROVAL_BLOCKED_BY_OPEN_HIGH_RISK"
assert detail["observations"] == [
{
"id": "risk-block-api-observation",
"title": "重复票据风险",
"risk_level": "high",
"adjudication": "unreviewed",
"lifecycle_status": "open",
}
]
with session_factory() as db:
persisted = db.get(ExpenseClaim, "claim-risk-block-api")
assert persisted is not None
assert persisted.approval_stage == "直属领导审批"
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 0
def test_approve_application_endpoint_routes_direct_manager_review_to_budget_review() -> None:
client, session_factory = build_client()
@@ -766,10 +919,11 @@ def test_approve_application_endpoint_routes_direct_manager_review_to_budget_rev
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[
{
"source": "submission_review",
"severity": "high",
"label": "申请风险复核",
{
"source": "submission_review",
"severity": "high",
"actionability": "route_review",
"label": "申请风险复核",
"message": "申请金额和行程安排需要预算管理者二次确认。",
}
],
@@ -779,7 +933,12 @@ def test_approve_application_endpoint_routes_direct_manager_review_to_budget_rev
response = client.post(
"/api/v1/reimbursements/claims/claim-application-approve-1/approve",
json={"opinion": "业务必要,同意申请。"},
json={
"opinion": "业务必要,同意申请。",
"request_id": "approve-api-application-1",
"expected_status": "submitted",
"expected_approval_stage": "直属领导审批",
},
headers={
"X-Auth-Username": "manager-application-approve-api@example.com",
"X-Auth-Name": "manager-application-approve-api@example.com",
@@ -853,7 +1012,9 @@ def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch,
assert upload_response.status_code == 200
meta_payload = upload_response.json()["attachment"]
assert meta_payload["preview_kind"] == "image"
assert meta_payload["preview_url"].endswith(f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview")
assert meta_payload["preview_url"].endswith(
f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview"
)
meta_path = next(tmp_path.rglob("invoice.pdf.meta.json"))
stored_meta = json.loads(meta_path.read_text(encoding="utf-8"))
assert stored_meta["preview_rendered_with"] == DocumentPreviewAssets.PDF_RENDERER_ID
@@ -1015,7 +1176,9 @@ def test_claim_delete_allows_applicant_to_delete_own_draft(monkeypatch, tmp_path
assert db.get(ExpenseClaim, claim_id) is None
def test_claim_delete_allows_legacy_superadmin_without_is_admin_header(monkeypatch, tmp_path) -> None:
def test_claim_delete_allows_legacy_superadmin_without_is_admin_header(
monkeypatch, tmp_path
) -> None:
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
client, session_factory = build_client()
@@ -1157,8 +1320,7 @@ def test_application_preview_action_submits_without_orchestrator_run(monkeypatch
assert outcome.outcome_type == "application_submitted"
assert outcome.business_event_id == event.id
assert any(
isinstance(flag, dict)
and flag.get("event_type") == "expense_application_submission"
isinstance(flag, dict) and flag.get("event_type") == "expense_application_submission"
for flag in list(claim.risk_flags_json or [])
)
@@ -1212,7 +1374,9 @@ def test_application_direct_submit_rolls_back_budget_when_case_event_fails(
assert list(db.scalars(select(WorkflowOutcome)).all()) == []
def test_application_preview_action_saves_draft_with_detail_reference(monkeypatch, tmp_path) -> None:
def test_application_preview_action_saves_draft_with_detail_reference(
monkeypatch, tmp_path
) -> None:
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
client, session_factory = build_client()
@@ -1288,19 +1452,16 @@ def test_application_preview_action_saves_draft_with_detail_reference(monkeypatc
assert claim.approval_stage == "待提交"
assert claim.submitted_at is None
assert claim.employee_name == "张三"
assert db.scalar(
select(BudgetReservation).where(BudgetReservation.source_id == claim.id)
) is None
event = db.scalar(
select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id)
assert (
db.scalar(select(BudgetReservation).where(BudgetReservation.source_id == claim.id))
is None
)
event = db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id))
assert event is not None
assert event.event_type == "claim_draft_created"
assert event.payload_json["previous_status"] == ""
assert event.payload_json["next_status"] == "draft"
link = db.scalar(
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id)
)
link = db.scalar(select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id))
assert link is not None
assert link.relation_type == "application"
@@ -1372,9 +1533,10 @@ def test_application_preview_action_rejects_forged_identity_when_editing_other_c
assert persisted.reason == "其他员工原申请"
assert persisted.status == "returned"
assert persisted.approval_stage == "退回补充"
assert db.scalar(
select(BusinessEvent).where(BusinessEvent.aggregate_id == persisted.id)
) is None
assert (
db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_id == persisted.id))
is None
)
def test_application_preview_action_reuses_created_draft_for_identical_retry() -> None:
@@ -1420,17 +1582,13 @@ def test_application_preview_action_reuses_created_draft_for_identical_retry() -
with session_factory() as db:
application_claims = list(
db.scalars(
select(ExpenseClaim).where(
ExpenseClaim.expense_type == "travel_application"
)
select(ExpenseClaim).where(ExpenseClaim.expense_type == "travel_application")
).all()
)
assert len(application_claims) == 1
events = list(
db.scalars(
select(BusinessEvent).where(
BusinessEvent.aggregate_id == application_claims[0].id
)
select(BusinessEvent).where(BusinessEvent.aggregate_id == application_claims[0].id)
).all()
)
assert len(events) == 1

View File

@@ -0,0 +1,661 @@
from __future__ import annotations
from collections.abc import Generator
from datetime import UTC, 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, func, select
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.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.models.risk_disposition import RiskDispositionEvent
from app.models.risk_observation import RiskObservationFeedback
from app.schemas.risk_disposition import RiskDispositionActionCreate
from app.services.risk_dispositions import (
RiskDispositionConflictError,
RiskDispositionIdempotencyConflictError,
RiskDispositionPermissionError,
RiskDispositionService,
RiskDispositionVersionConflictError,
)
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
from app.services.risk_observations import RiskObservationService
def test_risk_disposition_separates_adjudication_and_lifecycle_with_audit_events(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
with _build_session() as db:
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:typed:duplicate")
)
db.commit()
service = RiskDispositionService(db)
confirmed = service.execute_action(
observation.id,
_action("confirm", version=0, request_id="request-confirm-001"),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
assert confirmed.disposition.adjudication == "confirmed"
assert confirmed.disposition.lifecycle_status == "open"
assert confirmed.disposition.version == 1
assert confirmed.event.before_json["adjudication"] == "unreviewed"
assert confirmed.event.after_json["adjudication"] == "confirmed"
supplemented = service.execute_action(
observation.id,
RiskDispositionActionCreate(
action="request_supplement",
expected_version=1,
request_id="request-supplement-001",
assignee="员工甲",
due_at=datetime.now(UTC) + timedelta(days=2),
comment="请补齐行程单",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
assert supplemented.disposition.adjudication == "confirmed"
assert supplemented.disposition.lifecycle_status == "supplement_requested"
assert supplemented.disposition.assignee == "员工甲"
assert supplemented.disposition.version == 2
resolved = service.execute_action(
observation.id,
RiskDispositionActionCreate(
action="resolve",
expected_version=2,
request_id="request-resolve-001",
resolution="补充材料已核验,风险关闭。",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
assert resolved.disposition.adjudication == "confirmed"
assert resolved.disposition.lifecycle_status == "resolved"
assert resolved.disposition.resolution == "补充材料已核验,风险关闭。"
assert resolved.disposition.version == 3
assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 3
assert db.scalar(select(func.count()).select_from(RiskObservationFeedback)) == 1
with pytest.raises(RiskDispositionConflictError, match="已解决"):
service.execute_action(
observation.id,
_action(
"start_remediation",
version=3,
request_id="request-remediation-after-resolve",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
with pytest.raises(RiskDispositionConflictError, match="已解决"):
service.execute_action(
observation.id,
_action(
"false_positive",
version=3,
request_id="request-readjudicate-after-resolve",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
def test_risk_disposition_requires_confirmation_before_remediation_or_resolution(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
with _build_session() as db:
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:typed:transition-guard")
)
db.commit()
service = RiskDispositionService(db)
with pytest.raises(RiskDispositionConflictError, match="必须先确认成立"):
service.execute_action(
observation.id,
RiskDispositionActionCreate(
action="resolve",
expected_version=0,
request_id="request-resolve-unreviewed",
resolution="不能跳过裁决直接关闭。",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
supplemented = service.execute_action(
observation.id,
RiskDispositionActionCreate(
action="request_supplement",
expected_version=0,
request_id="request-supplement-unreviewed",
comment="先补充材料再裁决。",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
assert supplemented.disposition.adjudication == "unreviewed"
assert supplemented.disposition.lifecycle_status == "supplement_requested"
def test_risk_disposition_imports_legacy_confirmation_before_first_typed_action(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
with _build_session() as db:
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:typed:legacy-confirmed")
)
observation.status = "confirmed"
observation.feedback_status = "confirmed"
db.commit()
resolved = RiskDispositionService(db).execute_action(
observation.id,
RiskDispositionActionCreate(
action="resolve",
expected_version=0,
request_id="request-resolve-legacy-confirmed",
resolution="历史确认风险已完成整改。",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
assert resolved.event.before_json["adjudication"] == "confirmed"
assert resolved.disposition.adjudication == "confirmed"
assert resolved.disposition.lifecycle_status == "resolved"
def test_risk_disposition_idempotency_and_optimistic_version_are_enforced(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
with _build_session() as db:
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:typed:idempotency")
)
second_observation = RiskObservationService(db).upsert_observation(
{
**_observation_payload("risk:typed:idempotency:second"),
"claim_id": None,
"claim_no": "",
"subject_key": "standalone:second",
}
)
db.commit()
service = RiskDispositionService(db)
payload = _action("confirm", version=0, request_id="request-idempotent-001")
first = service.execute_action(
observation.id,
payload,
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
replay = service.execute_action(
observation.id,
payload,
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
assert replay.replayed is True
assert replay.event.id == first.event.id
assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 1
with pytest.raises(RiskDispositionIdempotencyConflictError):
service.execute_action(
observation.id,
_action(
"false_positive",
version=0,
request_id="request-idempotent-001",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
with pytest.raises(RiskDispositionIdempotencyConflictError):
service.execute_action(
second_observation.id,
payload,
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
with pytest.raises(RiskDispositionIdempotencyConflictError):
service.execute_action(
observation.id,
payload,
tenant_id="default",
actor_id="finance-2",
actor_name="财务乙",
)
with pytest.raises(RiskDispositionVersionConflictError) as error:
service.execute_action(
observation.id,
_action("request_waiver", version=0, request_id="request-stale-001"),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
assert error.value.current_version == 1
def test_risk_observation_api_enforces_pool_claim_and_typed_action_permissions(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
client, session_factory = _build_client()
with session_factory() as db:
db.add(_employee())
db.add(_claim())
db.flush()
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:api:duplicate")
)
standalone_observation = RiskObservationService(db).upsert_observation(
{
**_observation_payload("risk:api:standalone"),
"claim_id": None,
"claim_no": "",
"subject_key": "default:standalone",
}
)
foreign_observation = RiskObservationService(db).upsert_observation(
{
**_observation_payload("risk:api:foreign"),
"claim_id": None,
"claim_no": "",
"subject_key": "tenant-b:standalone",
},
tenant_id="tenant-b",
)
observation_id = observation.id
standalone_observation_id = standalone_observation.id
foreign_observation_id = foreign_observation.id
db.commit()
employee_headers = {
"X-Auth-Username": "risk.employee@example.com",
"X-Auth-Name": "Risk Employee",
"X-Auth-Employee-No": "E-RISK",
}
finance_headers = {
"X-Auth-Username": "finance@example.com",
"X-Auth-Name": "Finance Reviewer",
"X-Auth-Role-Codes": "finance",
}
assert client.get("/api/v1/risk-observations", headers=employee_headers).status_code == 403
assert (
client.get(
f"/api/v1/risk-observations/{observation_id}",
headers=employee_headers,
).status_code
== 403
)
assert (
client.get(
"/api/v1/risk-observations/claim/claim-risk-1",
headers=employee_headers,
).status_code
== 404
)
assert (
client.post(
f"/api/v1/risk-observations/{observation_id}/feedback",
headers=employee_headers,
json={"feedback_type": "confirm"},
).status_code
== 403
)
first = client.post(
f"/api/v1/risk-observations/{observation_id}/disposition/actions",
headers=finance_headers,
json={
"action": "confirm",
"expected_version": 0,
"request_id": "api-confirm-request-001",
"comment": "人工复核确认",
},
)
replay = client.post(
f"/api/v1/risk-observations/{observation_id}/disposition/actions",
headers=finance_headers,
json={
"action": "confirm",
"expected_version": 0,
"request_id": "api-confirm-request-001",
"comment": "人工复核确认",
},
)
stale = client.post(
f"/api/v1/risk-observations/{observation_id}/disposition/actions",
headers=finance_headers,
json={
"action": "request_waiver",
"expected_version": 0,
"request_id": "api-stale-request-001",
"comment": "申请风险豁免复核",
},
)
changed_replay = client.post(
f"/api/v1/risk-observations/{observation_id}/disposition/actions",
headers=finance_headers,
json={
"action": "false_positive",
"expected_version": 0,
"request_id": "api-confirm-request-001",
"comment": "经核验属于误报",
},
)
unsafe_legacy = client.post(
f"/api/v1/risk-observations/{observation_id}/feedback",
headers=finance_headers,
json={"feedback_type": "comment", "payload_json": {"arbitrary": True}},
)
foreign_action = client.post(
f"/api/v1/risk-observations/{foreign_observation_id}/disposition/actions",
headers=finance_headers,
json={
"action": "confirm",
"expected_version": 0,
"request_id": "api-cross-tenant-001",
},
)
standalone_action = client.post(
f"/api/v1/risk-observations/{standalone_observation_id}/disposition/actions",
headers=finance_headers,
json={
"action": "confirm",
"expected_version": 0,
"request_id": "api-standalone-finance-001",
},
)
assert first.status_code == 200
assert first.json()["disposition"]["adjudication"] == "confirmed"
assert first.json()["disposition"]["lifecycle_status"] == "open"
assert replay.status_code == 200
assert replay.json()["replayed"] is True
assert replay.json()["event"]["id"] == first.json()["event"]["id"]
assert stale.status_code == 409
assert stale.json()["detail"]["code"] == "RISK_DISPOSITION_VERSION_CONFLICT"
assert stale.json()["detail"]["message"] == "风险处置状态已更新,请刷新证据链后重试。"
assert changed_replay.status_code == 409
assert unsafe_legacy.status_code == 410
assert foreign_action.status_code == 404
assert standalone_action.status_code == 403
detail = client.get(
f"/api/v1/risk-observations/{observation_id}",
headers=finance_headers,
)
assert detail.status_code == 200
assert detail.json()["disposition"]["version"] == 1
assert len(detail.json()["disposition"]["events"]) == 1
def test_current_claim_approver_can_manage_disposition_without_pool_access() -> None:
with _build_session() as db:
manager = Employee(
id="manager-risk",
employee_no="M-RISK",
name="风险主管",
email="risk.manager@example.com",
position="部门经理",
grade="P8",
)
employee = _employee()
employee.manager_id = manager.id
claim = _claim()
claim.approval_stage = "直属领导审批"
db.add_all([manager, employee, claim])
db.flush()
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:approver:duplicate")
)
db.commit()
current_user = CurrentUserContext(
username="risk.manager@example.com",
name="风险主管",
role_codes=["approver"],
is_admin=False,
employee_no="M-RISK",
)
policy = RiskObservationAccessPolicy(db)
assert policy.can_read_tenant_pool(current_user) is False
assert policy.can_read_claim_risks(claim.id, current_user) is True
assert policy.can_manage_disposition(observation, current_user) is True
unrelated_manager = CurrentUserContext(
username="unrelated.manager@example.com",
name="其他经理",
role_codes=["manager"],
is_admin=False,
)
finance_outside_stage = CurrentUserContext(
username="finance@example.com",
name="财务甲",
role_codes=["finance"],
is_admin=False,
)
assert policy.can_read_tenant_pool(unrelated_manager) is False
assert policy.can_read_claim_risks(claim.id, unrelated_manager) is False
assert policy.can_manage_disposition(observation, unrelated_manager) is False
assert policy.can_manage_disposition(observation, finance_outside_stage) is False
def test_disposition_rechecks_current_approver_after_claim_stage_changes() -> None:
with _build_session() as db:
manager = Employee(
id="manager-risk-stage-change",
employee_no="M-RISK-STAGE",
name="原审批主管",
email="risk.stage.manager@example.com",
)
employee = _employee()
employee.id = "emp-risk-stage-change"
employee.employee_no = "E-RISK-STAGE"
employee.email = "risk.stage.employee@example.com"
employee.manager_id = manager.id
claim = _claim()
claim.id = "claim-risk-stage-change"
claim.claim_no = "BX-RISK-STAGE"
claim.employee_id = employee.id
claim.approval_stage = "直属领导审批"
db.add_all([manager, employee, claim])
db.flush()
observation = RiskObservationService(db).upsert_observation(
{
**_observation_payload("risk:approver:stage-change"),
"claim_id": claim.id,
"claim_no": claim.claim_no,
"subject_key": f"claim:{claim.id}",
}
)
db.commit()
current_user = CurrentUserContext(
username=manager.email,
name=manager.name,
role_codes=["manager"],
is_admin=False,
employee_no=manager.employee_no,
)
claim.approval_stage = "财务审批"
db.commit()
with pytest.raises(RiskDispositionPermissionError, match="不再是"):
RiskDispositionService(db).execute_action(
observation.id,
_action("confirm", version=0, request_id="request-stage-changed-001"),
tenant_id="default",
actor_id=manager.id,
actor_name=manager.name,
current_user=current_user,
)
assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 0
@pytest.mark.parametrize(
"action",
["false_positive", "request_supplement", "request_waiver"],
)
def test_evidence_sensitive_actions_require_server_side_comment(action: str) -> None:
with pytest.raises(ValueError, match="必须填写 comment"):
RiskDispositionActionCreate(
action=action,
expected_version=0,
request_id=f"request-comment-{action}",
)
def _action(
action: str,
*,
version: int,
request_id: str,
) -> RiskDispositionActionCreate:
return RiskDispositionActionCreate(
action=action,
expected_version=version,
request_id=request_id,
comment=(
"风险处置说明"
if action in {"false_positive", "request_supplement", "request_waiver"}
else None
),
)
def _build_session() -> 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)
return factory()
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)
app = FastAPI()
app.include_router(risk_observations_router, prefix="/api/v1")
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]:
db = factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
return TestClient(app), factory
def _employee() -> Employee:
return Employee(
id="emp-risk",
employee_no="E-RISK",
name="风险员工",
email="risk.employee@example.com",
position="高级专员",
grade="P6",
)
def _claim() -> ExpenseClaim:
now = datetime(2026, 7, 16, tzinfo=UTC)
return ExpenseClaim(
id="claim-risk-1",
claim_no="BX-RISK-001",
employee_id="emp-risk",
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=[],
)
def _observation_payload(observation_key: str) -> dict[str, object]:
return {
"observation_key": observation_key,
"subject_type": "expense_claim",
"subject_key": "claim:claim-risk-1",
"subject_label": "BX-RISK-001",
"claim_id": "claim-risk-1",
"claim_no": "BX-RISK-001",
"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": {},
}

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Generator
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from types import SimpleNamespace
@@ -22,6 +22,7 @@ from app.models.expense_case import ExpenseCase, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim
from app.models.risk_observation import RiskObservation
from app.schemas.risk_observation import RiskObservationFeedbackCreate
from app.services.expense_claims import ExpenseClaimService
from app.services.hermes_risk_scanner import HermesRiskScannerService
from app.services.risk_observations import RiskObservationService
@@ -129,6 +130,47 @@ def test_platform_rule_flags_are_persisted_as_risk_observations() -> None:
assert persisted.contribution_scores_json == {"S_rule": 100}
def test_high_platform_risk_persistence_failure_is_fail_closed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
with _build_session() as db:
claim = _claim_orm("c-platform-fail-closed", "BX-PLATFORM-FAIL-CLOSED")
db.add(claim)
db.flush()
service = ExpenseClaimService(db)
monkeypatch.setattr(
service,
"evaluate_platform_risk_rules",
lambda _claim, **_kwargs: {
"flags": [
{
"source": "platform_risk",
"hit_source": "rule_center",
"rule_type": "risk",
"rule_code": "risk.invoice.blocking",
"severity": "high",
"action": "block",
"label": "高风险票据",
"message": "票据需要人工核验。",
}
],
"rule_set_fingerprint": "rules-v1",
},
)
def fail_persistence(*_args, **_kwargs):
raise RuntimeError("database unavailable")
monkeypatch.setattr(
RiskObservationService,
"upsert_platform_risk_flags",
fail_persistence,
)
with pytest.raises(RuntimeError, match="高风险观察持久化失败"):
service._run_ai_submission_review(claim)
def test_risk_observation_storage_ready_is_cached_per_bind(monkeypatch: pytest.MonkeyPatch) -> None:
with _build_session() as db:
RiskObservationService._storage_ready_cache.clear()
@@ -189,9 +231,9 @@ def test_risk_observation_endpoints_return_list_detail_dashboard_and_feedback()
assert updated_detail_response.json()["feedback_items"][0]["feedback_type"] == "false_positive"
with session_factory() as db:
observation = db.query(RiskObservation).filter_by(
observation_key="risk:c1:duplicate_invoice"
).one()
observation = (
db.query(RiskObservation).filter_by(observation_key="risk:c1:duplicate_invoice").one()
)
assert observation.status == "false_positive"
assert observation.feedback_status == "false_positive"
@@ -223,11 +265,15 @@ def test_risk_observation_endpoints_enforce_tenant_scope_and_authenticated_actor
tenant_a_headers = {
"X-Auth-Username": "auditor-a",
"X-Auth-Name": "Tenant A Auditor",
"X-Auth-Role-Codes": "finance",
"X-Auth-Is-Admin": "true",
"X-Auth-Tenant-Id": "tenant-a",
}
tenant_b_headers = {
"X-Auth-Username": "auditor-b",
"X-Auth-Name": "Tenant B Auditor",
"X-Auth-Role-Codes": "finance",
"X-Auth-Is-Admin": "true",
"X-Auth-Tenant-Id": "tenant-b",
}
@@ -269,8 +315,8 @@ def test_risk_observation_endpoints_enforce_tenant_scope_and_authenticated_actor
assert detail_response.status_code == 200
assert detail_response.json()["tenant_id"] == "tenant-a"
assert foreign_detail_response.status_code == 404
assert claim_response.status_code == 200
assert [item["tenant_id"] for item in claim_response.json()] == ["tenant-a"]
# 单据风险入口必须先通过单据自身可见范围;不存在的历史 claim 不再旁路读取。
assert claim_response.status_code == 404
assert execution_log_response.status_code == 200
assert [item["tenant_id"] for item in execution_log_response.json()] == ["tenant-a"]
assert dashboard_response.status_code == 200
@@ -359,9 +405,12 @@ def test_risk_observation_rejects_explicit_tenant_mismatching_claim_link() -> No
tenant_id="tenant-b",
)
assert db.query(RiskObservation).filter_by(
observation_key="risk:tenant-boundary"
).one_or_none() is None
assert (
db.query(RiskObservation)
.filter_by(observation_key="risk:tenant-boundary")
.one_or_none()
is None
)
def test_hermes_global_scan_builds_graphs_inside_each_tenant(
@@ -427,6 +476,37 @@ def test_hermes_global_scan_builds_graphs_inside_each_tenant(
assert summary["scanned_claim_count"] == 2
def test_risk_scan_discards_snapshot_after_claim_changes_during_evaluation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
with _build_session() as db:
claim = _claim_orm("claim-scan-stale", "BX-SCAN-STALE")
db.add(claim)
db.commit()
original_updated_at = claim.updated_at
def fake_evaluate(_context):
claim.status = "pending_payment"
claim.approval_stage = "待付款"
claim.updated_at = original_updated_at + timedelta(seconds=1)
db.flush()
return SimpleNamespace(observations=[], nodes=[], edges=[])
scanner = HermesRiskScannerService(db)
monkeypatch.setattr(scanner, "_fetch_unscanned_claims", lambda: [claim])
monkeypatch.setattr(
"app.services.hermes_risk_scanner.evaluate_financial_risk_graph",
fake_evaluate,
)
summary = scanner.scan_global_risks()
db.refresh(claim)
assert summary["scanned_claim_count"] == 0
assert claim.status == "pending_payment"
assert claim.hermes_scanned_at is None
def test_risk_observation_feedback_pool_fields_and_replay_set_contract() -> None:
with _build_session() as db:
service = RiskObservationService(db)

View File

@@ -19,6 +19,7 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None:
"ai_application_preview_decisions",
"ai_decision_feedback",
"ai_decisions",
"approval_action_ledgers",
"auth_sessions",
"attachment_association_jobs",
"business_events",
@@ -28,6 +29,8 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None:
"memory_evidence_links",
"risk_observations",
"risk_observation_feedback",
"risk_disposition_events",
"risk_dispositions",
"few_shot_samples",
"workflow_outcomes",
}