feat(approval): add safe risk disposition workflow
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user