222 lines
6.9 KiB
Python
222 lines
6.9 KiB
Python
|
|
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"
|