feat(approval): add safe risk disposition workflow
This commit is contained in:
221
server/tests/test_approval_workbench.py
Normal file
221
server/tests/test_approval_workbench.py
Normal file
@@ -0,0 +1,221 @@
|
||||
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"
|
||||
Reference in New Issue
Block a user