Files
X-Financial/server/tests/test_approval_workbench.py

222 lines
6.9 KiB
Python
Raw Permalink Normal View History

from __future__ import annotations
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.risk_disposition import RiskDisposition
from app.models.risk_observation import RiskObservation
from app.services.approval_workbench import ApprovalWorkbenchService
def _claim(
*,
claim_no: str = "RE-WORKBENCH-1",
amount: str = "888.00",
submitted_at: datetime,
risk_flags: list[dict] | None = None,
invoice_count: int = 1,
) -> ExpenseClaim:
claim = ExpenseClaim(
id=f"claim-{claim_no.lower()}",
claim_no=claim_no,
employee_id=None,
employee_name="张三",
department_id=None,
department_name="市场部",
project_code="PRJ-WORKBENCH",
expense_type="travel",
reason="客户现场差旅",
location="上海",
amount=Decimal(amount),
currency="CNY",
invoice_count=invoice_count,
occurred_at=submitted_at,
submitted_at=submitted_at,
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=list(risk_flags or []),
created_at=submitted_at,
updated_at=submitted_at,
)
claim.items = [
ExpenseClaimItem(
id=f"item-{claim_no.lower()}",
claim_id=claim.id,
item_date=date(2026, 7, 15),
item_type="hotel",
item_reason="住宿",
item_location="上海",
item_note="",
item_amount=Decimal(amount),
invoice_id="INV-WORKBENCH" if invoice_count else None,
created_at=submitted_at,
updated_at=submitted_at,
)
]
return claim
def test_priority_queue_explains_risk_budget_sla_amount_and_history() -> None:
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
claim = _claim(
amount="60000.00",
submitted_at=now - timedelta(hours=26),
risk_flags=[
{
"source": "ai_pre_review",
"severity": "high",
"disposition": "review",
"resolution_status": "unresolved",
"route_decision": {"budget_result": {"metrics": {"after_usage_rate": "96.5"}}},
"historical_case_evidence": [{"label": "confirmed", "sample_id": "must-not-leak"}],
}
],
)
item = ApprovalWorkbenchService.build_item(claim, now=now)
assert item.priority_score >= 85
assert item.priority_tier == "urgent"
assert item.risk_level == "high"
assert item.sla_overdue is True
assert item.budget_usage_rate == 96.5
assert item.suggestion.action == "manual_review"
assert item.evidence.historical_labels == ["历史已确认,仅供复核"]
assert "must-not-leak" not in repr(item.model_dump())
assert {reason.code for reason in item.priority_reasons} >= {
"open_risk",
"sla_overdue",
"budget_pressure",
"large_amount",
}
def test_application_evidence_does_not_require_invoice_and_resolved_risk_is_ignored() -> None:
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
claim = _claim(
claim_no="AP-WORKBENCH-1",
submitted_at=now - timedelta(hours=1),
invoice_count=0,
risk_flags=[
{
"source": "ai_pre_review",
"severity": "critical",
"resolution_status": "resolved",
}
],
)
item = ApprovalWorkbenchService.build_item(claim, now=now)
assert item.evidence.completeness == 1
assert item.evidence.missing_labels == []
assert item.risk_level == "low"
assert item.open_risk_count == 0
assert item.suggestion.action == "approve_candidate"
assert item.priority_score == 0
def test_persisted_disposition_is_authoritative_over_stale_claim_risk_flags() -> None:
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
claim = _claim(
submitted_at=now - timedelta(hours=1),
risk_flags=[
{
"severity": "critical",
"triggered": True,
"observation_key": "risk:workbench:resolved",
}
],
)
observation = RiskObservation(
id="risk-workbench-resolved",
tenant_id="default",
observation_key="risk:workbench:resolved",
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=95,
risk_level="critical",
confidence_score=0.96,
control_stage="reimbursement",
control_mode="risk_observation",
automation_mode="semi_auto_review",
source="financial_risk_graph",
algorithm_version="v1",
status="resolved",
feedback_status="confirmed",
)
disposition = RiskDisposition(
tenant_id="default",
observation_id=observation.id,
adjudication="confirmed",
lifecycle_status="resolved",
version=2,
)
item = ApprovalWorkbenchService.build_item(
claim,
now=now,
observation_rows=[(observation, disposition)],
)
assert item.risk_level == "low"
assert item.open_risk_count == 0
assert item.suggestion.action == "approve_candidate"
def test_persisted_low_risk_does_not_hide_unmaterialized_raw_high_risk() -> None:
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
claim = _claim(
submitted_at=now - timedelta(hours=1),
risk_flags=[
{
"source": "attachment_analysis",
"severity": "high",
"label": "票据金额异常",
"triggered": True,
}
],
)
observation = RiskObservation(
id="risk-workbench-low",
tenant_id="default",
observation_key="risk:workbench:low",
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="minor_notice",
risk_signal="minor_notice",
title="普通提醒",
description="普通提醒。",
risk_score=30,
risk_level="low",
confidence_score=0.8,
control_stage="reimbursement",
control_mode="risk_observation",
automation_mode="manual_review",
source="financial_risk_graph",
algorithm_version="v1",
status="pending_review",
feedback_status="unreviewed",
)
item = ApprovalWorkbenchService.build_item(
claim,
now=now,
observation_rows=[(observation, None)],
)
assert item.risk_level == "high"
assert item.open_risk_count == 2
assert item.suggestion.action == "manual_review"