from __future__ import annotations from collections.abc import Generator from datetime import UTC, datetime, timedelta from decimal import Decimal from types import SimpleNamespace import pytest from auth_helpers import install_legacy_header_auth_override from fastapi import FastAPI from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool from app.algorithem.risk_graph.replay import AlgorithmReplaySetBuilder from app.api.deps import 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.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 def test_risk_observation_service_upserts_and_summarizes_dashboard() -> None: with _build_session() as db: db.add(_employee_orm()) db.add_all([_claim_orm("c1", "BX-001"), _claim_orm("c2", "BX-002")]) db.flush() service = RiskObservationService(db) service.upsert_observation(_observation_payload("risk:c1:duplicate_invoice")) service.upsert_observation( { **_observation_payload("risk:c2:preapproval_absent"), "claim_id": "c2", "claim_no": "BX-002", "risk_signal": "preapproval_absent", "risk_type": "preapproval_absent", "risk_score": 72, "risk_level": "high", } ) db.commit() feedback = service.create_feedback( "risk:c1:duplicate_invoice", RiskObservationFeedbackCreate(feedback_type="confirm", actor="auditor"), ) dashboard = service.summarize_dashboard(window_days=30) history = service.build_history_stats(risk_signals={"duplicate_invoice"}) refreshed = service.get_observation("risk:c1:duplicate_invoice") assert feedback.feedback_type == "confirm" assert refreshed is not None assert refreshed.status == "confirmed" assert refreshed.source == "financial_risk_graph" assert refreshed.algorithm_version == "financial_risk_graph.v1" assert refreshed.sampling_strategy["strategy"] == "focused_review" assert refreshed.evaluation_case_id == "case-duplicate-invoice" assert refreshed.ontology_parse_id == "parse-1" assert refreshed.ontology_version == "ontology.v1" assert refreshed.domain == "expense" assert refreshed.scenario == "reimbursement" assert refreshed.intent == "risk_check" assert refreshed.ontology_entities_json == [{"type": "claim", "value": "c1"}] assert refreshed.risk_signals_json == [{"code": "duplicate_invoice"}] assert refreshed.canonical_subject_key == "claim:c1" assert dashboard.total_observations == 2 assert dashboard.high_or_above_count == 2 assert dashboard.risk_clue_count == 1 assert dashboard.confirmed_count == 1 assert dashboard.feedback_sample_count == 1 assert dashboard.total_amount == 2400.0 assert dashboard.level_distribution["high"] == 2 assert dashboard.signal_distribution["duplicate_invoice"] == 1 assert dashboard.department_distribution["风控部"] == 2 assert dashboard.expense_type_distribution["travel"] == 2 assert dashboard.employee_grade_distribution["P6"] == 2 assert dashboard.supplier_distribution["上海差旅供应商"] == 2 assert dashboard.top_departments[0]["name"] == "风控部" assert dashboard.top_departments[0]["amount"] == 2400.0 assert dashboard.top_employees[0]["name"] == "风险员工" assert dashboard.top_suppliers[0]["name"] == "上海差旅供应商" assert dashboard.top_expense_types[0]["name"] == "travel" assert dashboard.top_rules[0]["name"] == "policy.duplicate_invoice" assert dashboard.top_risk_signals[0]["name"] in { "duplicate_invoice", "preapproval_absent", } assert dashboard.daily_trend assert history[0].risk_signal == "duplicate_invoice" assert history[0].confirmed_count == 1 def test_platform_rule_flags_are_persisted_as_risk_observations() -> None: with _build_session() as db: claim = _claim_orm("c-platform", "BX-PLATFORM") db.add(claim) db.flush() observations = RiskObservationService(db).upsert_platform_risk_flags( claim, [ { "hit_source": "rule_center", "rule_type": "risk", "rule_code": "risk.invoice.duplicate_invoice", "rule_version": "v1.2.0", "severity": "critical", "action": "block", "label": "重复发票校验", "message": "票据号码已在其他报销单中出现。", "evidence": {"invoice_no": "INV-001"}, } ], ) db.commit() assert len(observations) == 1 persisted = db.query(RiskObservation).filter_by(claim_id="c-platform").one() assert persisted.risk_signal == "duplicate_invoice" assert persisted.risk_level == "critical" assert persisted.source == "rule_center" assert persisted.algorithm_version == "v1.2.0" 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() create_all_calls = [] original_create_all = Base.metadata.create_all def spy_create_all(*args, **kwargs): create_all_calls.append(kwargs.get("bind")) return original_create_all(*args, **kwargs) monkeypatch.setattr(Base.metadata, "create_all", spy_create_all) service = RiskObservationService(db) service.ensure_storage_ready() service.ensure_storage_ready() RiskObservationService(db).ensure_storage_ready() assert len(create_all_calls) == 1 RiskObservationService._storage_ready_cache.clear() def test_risk_observation_endpoints_return_list_detail_dashboard_and_feedback() -> None: client, session_factory = _build_client() with session_factory() as db: service = RiskObservationService(db) service.upsert_observation( _observation_payload("risk:c1:duplicate_invoice"), execution_log_id="exec-1", ) db.commit() list_response = client.get("/api/v1/risk-observations", params={"risk_level": "high"}) execution_log_response = client.get("/api/v1/risk-observations/execution-log/exec-1") detail_response = client.get("/api/v1/risk-observations/risk:c1:duplicate_invoice") dashboard_response = client.get("/api/v1/risk-observations/dashboard") feedback_response = client.post( "/api/v1/risk-observations/risk:c1:duplicate_invoice/feedback", json={"feedback_type": "false_positive", "actor": "auditor", "comment": "误报"}, ) assert list_response.status_code == 200 assert list_response.json()["total"] == 1 assert execution_log_response.status_code == 200 assert len(execution_log_response.json()) == 1 assert detail_response.status_code == 200 assert detail_response.json()["risk_signal"] == "duplicate_invoice" assert dashboard_response.status_code == 200 assert dashboard_response.json()["total_observations"] == 1 assert dashboard_response.json()["risk_clue_count"] == 1 assert dashboard_response.json()["feedback_sample_count"] == 0 assert "top_departments" in dashboard_response.json() assert feedback_response.status_code == 200 assert feedback_response.json()["feedback_type"] == "false_positive" assert feedback_response.json()["actor"] == "Test Admin" updated_detail_response = client.get("/api/v1/risk-observations/risk:c1:duplicate_invoice") assert updated_detail_response.status_code == 200 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() ) assert observation.status == "false_positive" assert observation.feedback_status == "false_positive" def test_risk_observation_endpoints_enforce_tenant_scope_and_authenticated_actor() -> None: client, session_factory = _build_client() with session_factory() as db: service = RiskObservationService(db) tenant_a = service.upsert_observation( { **_observation_payload("risk:tenant-a:duplicate_invoice"), "claim_id": "shared-claim", }, tenant_id="tenant-a", execution_log_id="shared-execution-log", ) tenant_b = service.upsert_observation( { **_observation_payload("risk:tenant-b:duplicate_invoice"), "claim_id": "shared-claim", }, tenant_id="tenant-b", execution_log_id="shared-execution-log", ) tenant_a_id = tenant_a.id tenant_b_id = tenant_b.id db.commit() 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", } list_response = client.get("/api/v1/risk-observations", headers=tenant_a_headers) detail_response = client.get( f"/api/v1/risk-observations/{tenant_a_id}", headers=tenant_a_headers, ) foreign_detail_response = client.get( f"/api/v1/risk-observations/{tenant_b_id}", headers=tenant_a_headers, ) claim_response = client.get( "/api/v1/risk-observations/claim/shared-claim", headers=tenant_a_headers, ) execution_log_response = client.get( "/api/v1/risk-observations/execution-log/shared-execution-log", headers=tenant_a_headers, ) dashboard_response = client.get( "/api/v1/risk-observations/dashboard", headers=tenant_a_headers, ) foreign_feedback_response = client.post( f"/api/v1/risk-observations/{tenant_b_id}/feedback", headers=tenant_a_headers, json={"feedback_type": "confirm", "actor": "伪造管理员"}, ) own_feedback_response = client.post( f"/api/v1/risk-observations/{tenant_a_id}/feedback", headers=tenant_a_headers, json={"feedback_type": "confirm", "actor": "伪造管理员"}, ) assert list_response.status_code == 200 assert list_response.json()["total"] == 1 assert list_response.json()["items"][0]["tenant_id"] == "tenant-a" assert detail_response.status_code == 200 assert detail_response.json()["tenant_id"] == "tenant-a" assert foreign_detail_response.status_code == 404 # 单据风险入口必须先通过单据自身可见范围;不存在的历史 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 assert dashboard_response.json()["total_observations"] == 1 assert foreign_feedback_response.status_code == 404 assert own_feedback_response.status_code == 200 assert own_feedback_response.json()["actor"] == "Tenant A Auditor" tenant_b_detail = client.get( f"/api/v1/risk-observations/{tenant_b_id}", headers=tenant_b_headers, ) assert tenant_b_detail.status_code == 200 assert tenant_b_detail.json()["status"] == "pending_review" assert tenant_b_detail.json()["feedback_items"] == [] def test_risk_observation_service_scopes_history_and_same_key_upserts_by_tenant() -> None: with _build_session() as db: service = RiskObservationService(db) tenant_a = service.upsert_observation( _observation_payload("risk:shared:duplicate_invoice"), tenant_id="tenant-a", ) tenant_b = service.upsert_observation( _observation_payload("risk:shared:duplicate_invoice"), tenant_id="tenant-b", ) service.create_feedback( tenant_a.id, RiskObservationFeedbackCreate(feedback_type="confirm", actor="untrusted"), tenant_id="tenant-a", actor="trusted-auditor", ) tenant_a_items, tenant_a_total = service.list_observations(tenant_id="tenant-a") tenant_b_items, tenant_b_total = service.list_observations(tenant_id="tenant-b") tenant_a_history = service.build_history_stats( tenant_id="tenant-a", risk_signals={"duplicate_invoice"}, ) tenant_b_history = service.build_history_stats( tenant_id="tenant-b", risk_signals={"duplicate_invoice"}, ) assert tenant_a.id != tenant_b.id assert tenant_a_total == tenant_b_total == 1 assert [item.tenant_id for item in tenant_a_items] == ["tenant-a"] assert [item.tenant_id for item in tenant_b_items] == ["tenant-b"] assert tenant_a_history[0].confirmed_count == 1 assert tenant_b_history[0].confirmed_count == 0 assert tenant_a.feedback_items[0].actor == "trusted-auditor" assert service.get_observation(tenant_b.id, tenant_id="tenant-a") is None def test_risk_observation_rejects_explicit_tenant_mismatching_claim_link() -> None: with _build_session() as db: claim = _claim_orm("claim-tenant-boundary", "BX-TENANT-BOUNDARY") expense_case = ExpenseCase( id="case-tenant-boundary", tenant_id="tenant-a", case_no="CASE-TENANT-BOUNDARY", scene_code="reimbursement", title="租户边界测试", current_stage="claiming", status="active", ) link = ExpenseCaseLink( id="link-tenant-boundary", tenant_id="tenant-a", expense_case_id=expense_case.id, resource_type="expense_claim", resource_id=claim.id, relation_type="claim", ) db.add_all([claim, expense_case, link]) db.flush() with pytest.raises(PermissionError, match="tenant does not match"): RiskObservationService(db).upsert_observation( { **_observation_payload("risk:tenant-boundary"), "claim_id": claim.id, }, tenant_id="tenant-b", ) 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( monkeypatch: pytest.MonkeyPatch, ) -> None: with _build_session() as db: claims = [ _claim_orm("claim-tenant-a", "BX-TENANT-A"), _claim_orm("claim-tenant-b", "BX-TENANT-B"), ] cases = [ ExpenseCase( id=f"case-tenant-{suffix}", tenant_id=f"tenant-{suffix}", case_no=f"CASE-TENANT-{suffix.upper()}", scene_code="reimbursement", title="租户隔离图扫描", current_stage="claiming", status="active", ) for suffix in ("a", "b") ] links = [ ExpenseCaseLink( id=f"link-tenant-{suffix}", tenant_id=f"tenant-{suffix}", expense_case_id=cases[index].id, resource_type="expense_claim", resource_id=claims[index].id, relation_type="claim", ) for index, suffix in enumerate(("a", "b")) ] db.add_all([*claims, *cases, *links]) db.flush() evaluated_claim_sets: list[set[str]] = [] history_tenants: list[str] = [] def fake_evaluate(context): evaluated_claim_sets.append(set(context.target_claim_ids)) return SimpleNamespace(observations=[], nodes=[], edges=[]) def fake_history(_self, *, tenant_id=None, **_kwargs): history_tenants.append(str(tenant_id)) return [] scanner = HermesRiskScannerService(db) monkeypatch.setattr(scanner, "_fetch_unscanned_claims", lambda: claims) monkeypatch.setattr( "app.services.hermes_risk_scanner.evaluate_financial_risk_graph", fake_evaluate, ) monkeypatch.setattr(RiskObservationService, "build_history_stats", fake_history) summary = scanner.scan_global_risks() assert evaluated_claim_sets == [ {"claim-tenant-a"}, {"claim-tenant-b"}, ] assert history_tenants == ["tenant-a", "tenant-b"] 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) service.upsert_observation(_observation_payload("risk:c1:duplicate_invoice")) db.commit() feedback = service.create_feedback( "risk:c1:duplicate_invoice", RiskObservationFeedbackCreate( feedback_type="comment", action="rewrite", actor="auditor", comment="建议生成候选规则", payload_json={ "decision": "candidate_rule_rewrite", "candidate_rule_source": "risk_observation_feedback", "confidence_score": 0.76, "escalation_target": "finance_manager", "supplement_required": True, }, ), ) observation = service.get_observation("risk:c1:duplicate_invoice") assert observation is not None replay_set = AlgorithmReplaySetBuilder().build_from_observations( "replay-set-1", [ { "observation_key": observation.observation_key, "claim_id": observation.claim_id, "risk_signal": observation.risk_signal, "risk_score": observation.risk_score, "risk_level": observation.risk_level, "algorithm_version": observation.algorithm_version, "feedback_status": observation.feedback_status, "ontology_json": observation.ontology_json, "decision_trace": observation.decision_trace_json, } ], created_at=datetime(2026, 5, 30, tzinfo=UTC), ) assert feedback.decision == "candidate_rule_rewrite" assert feedback.candidate_rule_source == "risk_observation_feedback" assert feedback.confidence_score == 0.76 assert feedback.escalation_target == "finance_manager" assert feedback.supplement_required is True assert replay_set.replay_set_id == "replay-set-1" assert replay_set.cases[0].claim_id == "c1" assert replay_set.cases[0].ontology_version == "ontology.v1" assert replay_set.cases[0].algorithm_version == "financial_risk_graph.v1" def _build_session() -> Session: engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(bind=engine) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) return session_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) session_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 = session_factory() try: yield db finally: db.close() app.dependency_overrides[get_db] = override_db return TestClient(app), session_factory def _observation_payload(observation_key: str) -> dict: return { "observation_key": observation_key, "subject_type": "expense_claim", "subject_key": "claim:c1", "subject_label": "BX-001", "claim_id": "c1", "claim_no": "BX-001", "risk_type": "duplicate_invoice", "risk_signal": "duplicate_invoice", "title": "Duplicate invoice risk", "description": "Same invoice appears in multiple claims.", "risk_score": 86, "risk_level": "high", "confidence_score": "0.81", "control_stage": "reimbursement", "control_mode": "risk_observation", "automation_mode": "semi_auto_review", "source": "financial_risk_graph", "algorithm_version": "financial_risk_graph.v1", "contribution_scores": {"S_rule": 82, "S_graph": 95}, "baseline": {"scope": "expense_type", "sample_size": 4}, "evidence": [ { "code": "duplicate_invoice_graph", "source": "graph", "metadata": {"vendor_name": "上海差旅供应商"}, } ], "graph_node_keys": ["claim:c1", "vendor:上海差旅供应商"], "graph_edge_keys": [], "policy_refs": ["policy.duplicate_invoice"], "similar_case_claim_ids": ["c2"], "ontology_json": { "gate": "review", "ontology_parse_id": "parse-1", "ontology_version": "ontology.v1", "domain": "expense", "scenario": "reimbursement", "intent": "risk_check", "ontology_entities_json": [{"type": "claim", "value": "c1"}], "risk_signals_json": [{"code": "duplicate_invoice"}], "canonical_subject_key": "claim:c1", }, "decision_trace": { "formula": "weighted", "sampling_strategy": {"strategy": "focused_review", "threshold": 70}, "evaluation_case_id": "case-duplicate-invoice", }, } def _employee_orm() -> Employee: return Employee( id="emp-risk", employee_no="E-RISK", name="风险员工", email="risk.employee@example.com", position="高级专员", grade="P6", ) def _claim_orm(claim_id: str, claim_no: str) -> ExpenseClaim: now = datetime(2026, 5, 20, tzinfo=UTC) return ExpenseClaim( id=claim_id, claim_no=claim_no, 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="manager_review", risk_flags_json=[], )