265 lines
9.2 KiB
Python
265 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(
|
|
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",
|
|
)
|