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", )