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

@@ -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)