Files
X-Financial/server/tests/test_expense_claim_risk_gate.py
caoxiaozhu 787bc3a481 feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
2026-07-17 14:14:08 +08:00

266 lines
9.2 KiB
Python

from __future__ import annotations
from datetime import UTC, datetime, timedelta
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_only_unexpired_approved_waiver_bypasses_risk_gate() -> None:
with _session() as db:
claim = _claim(claim_id="claim-risk-waiver-gate")
observation = _observation(claim, observation_id="risk-waiver-gate")
now = datetime.now(UTC)
disposition = RiskDisposition(
tenant_id="default",
observation_id=observation.id,
adjudication="confirmed",
lifecycle_status="waived",
waiver_requester_id="waiver-requester",
waiver_requester_name="豁免申请人",
waiver_requested_at=now - timedelta(days=2),
waiver_reason="临时无法取得纸质材料。",
waiver_scope="仅限本次客户拜访。",
waiver_expires_at=now + timedelta(days=2),
waiver_conditions_json=["到期前补件"],
waiver_decision="approved",
waiver_decider_id="waiver-decider",
waiver_decider_name="豁免决定人",
waiver_decided_at=now - timedelta(days=1),
waiver_decision_reason="补偿控制充分,同意限时豁免。",
)
db.add_all([claim, observation, disposition])
db.commit()
gate = ExpenseClaimRiskGate(db)
gate.ensure_approvable(claim, tenant_id="default")
disposition.waiver_expires_at = now - timedelta(hours=1)
db.commit()
with pytest.raises(ExpenseClaimRiskBlockedError):
gate.ensure_approvable(claim, tenant_id="default")
disposition.lifecycle_status = "waiver_rejected"
disposition.waiver_decision = "rejected"
disposition.waiver_decision_reason = "补偿控制不足,拒绝豁免。"
db.commit()
with pytest.raises(ExpenseClaimRiskBlockedError):
gate.ensure_approvable(claim, tenant_id="default")
def test_medium_and_foreign_tenant_risks_do_not_block_claim() -> None:
with _session() as db:
claim = _claim(claim_id="claim-risk-nonblocking")
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(
tenant_id="default",
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",
)