197 lines
6.5 KiB
Python
197 lines
6.5 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import UTC, date, datetime
|
|
from decimal import Decimal
|
|
|
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
|
from app.services.expense_claim_pre_review_decision import (
|
|
build_pre_review_decision,
|
|
pre_review_identity_matches,
|
|
)
|
|
|
|
|
|
def build_claim(*, risk_flags: list[dict] | None = None) -> ExpenseClaim:
|
|
claim = ExpenseClaim(
|
|
id="claim-pre-review-decision",
|
|
claim_no="RE-PRE-REVIEW-DECISION",
|
|
employee_id="employee-pre-review",
|
|
employee_name="张三",
|
|
department_id="department-pre-review",
|
|
department_name="市场部",
|
|
project_code="PRJ-PRE-REVIEW",
|
|
expense_type="travel",
|
|
reason="客户现场差旅",
|
|
location="上海",
|
|
amount=Decimal("88.00"),
|
|
currency="CNY",
|
|
invoice_count=1,
|
|
occurred_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
|
status="draft",
|
|
approval_stage="待提交",
|
|
risk_flags_json=list(risk_flags or []),
|
|
)
|
|
claim.items = [
|
|
ExpenseClaimItem(
|
|
id="item-pre-review-decision",
|
|
claim_id=claim.id,
|
|
item_date=date(2026, 7, 16),
|
|
item_type="travel",
|
|
item_reason="客户现场交通",
|
|
item_location="上海",
|
|
item_note="",
|
|
item_amount=Decimal("88.00"),
|
|
invoice_id="receipt.png",
|
|
)
|
|
]
|
|
return claim
|
|
|
|
|
|
def test_pre_review_identity_is_stable_when_risk_input_order_changes() -> None:
|
|
risk_flags = [
|
|
{"source": "manual_a", "severity": "low", "message": "A"},
|
|
{"source": "manual_b", "severity": "medium", "message": "B"},
|
|
]
|
|
first = build_pre_review_decision(
|
|
build_claim(risk_flags=risk_flags),
|
|
risk_flags=risk_flags,
|
|
business_stage="reimbursement",
|
|
platform_rule_set_fingerprint="rules-v1",
|
|
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
|
)
|
|
repeated = build_pre_review_decision(
|
|
build_claim(risk_flags=list(reversed(risk_flags))),
|
|
risk_flags=list(reversed(risk_flags)),
|
|
business_stage="reimbursement",
|
|
platform_rule_set_fingerprint="rules-v1",
|
|
reviewed_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
|
|
)
|
|
|
|
assert repeated["review_id"] == first["review_id"]
|
|
assert repeated["input_fingerprint"] == first["input_fingerprint"]
|
|
|
|
|
|
def test_pre_review_identity_is_stable_when_nested_risk_ids_change_order() -> None:
|
|
first_flags = [
|
|
{
|
|
"source": "manual_risk",
|
|
"severity": "medium",
|
|
"message": "多条明细需复核。",
|
|
"item_ids": ["item-b", "item-a"],
|
|
"basic_rule_refs": ["rule-b", "rule-a"],
|
|
}
|
|
]
|
|
repeated_flags = [
|
|
{
|
|
**first_flags[0],
|
|
"item_ids": ["item-a", "item-b"],
|
|
"basic_rule_refs": ["rule-a", "rule-b"],
|
|
}
|
|
]
|
|
|
|
first = build_pre_review_decision(
|
|
build_claim(risk_flags=first_flags),
|
|
risk_flags=first_flags,
|
|
business_stage="reimbursement",
|
|
platform_rule_set_fingerprint="rules-v1",
|
|
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
|
)
|
|
repeated = build_pre_review_decision(
|
|
build_claim(risk_flags=repeated_flags),
|
|
risk_flags=repeated_flags,
|
|
business_stage="reimbursement",
|
|
platform_rule_set_fingerprint="rules-v1",
|
|
reviewed_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
|
|
)
|
|
|
|
assert repeated["review_id"] == first["review_id"]
|
|
assert repeated["review_context_fingerprint"] == first[
|
|
"review_context_fingerprint"
|
|
]
|
|
|
|
|
|
def test_pre_review_identity_changes_when_dynamic_findings_change() -> None:
|
|
claim = build_claim()
|
|
ready = build_pre_review_decision(
|
|
claim,
|
|
risk_flags=[],
|
|
business_stage="reimbursement",
|
|
platform_rule_set_fingerprint="rules-v1",
|
|
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
|
)
|
|
changed = build_pre_review_decision(
|
|
claim,
|
|
risk_flags=[
|
|
{
|
|
"source": "submission_review",
|
|
"severity": "high",
|
|
"actionability": "fixable_by_submitter",
|
|
"message": "预审后发现同一发票已被其他单据使用。",
|
|
}
|
|
],
|
|
business_stage="reimbursement",
|
|
platform_rule_set_fingerprint="rules-v1",
|
|
reviewed_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
|
|
)
|
|
|
|
assert changed["input_fingerprint"] == ready["input_fingerprint"]
|
|
assert changed["review_context_fingerprint"] != ready[
|
|
"review_context_fingerprint"
|
|
]
|
|
assert changed["review_id"] != ready["review_id"]
|
|
assert changed["decision"] == "needs_fix"
|
|
|
|
|
|
def test_pre_review_blocks_high_fixable_risk_and_normalizes_string_item_id() -> None:
|
|
claim = build_claim()
|
|
decision = build_pre_review_decision(
|
|
claim,
|
|
risk_flags=[
|
|
{
|
|
"source": "manual_risk",
|
|
"severity": "high",
|
|
"actionability": "fixable_by_submitter",
|
|
"message": "住宿金额超过标准,请修正。",
|
|
"item_ids": "item-pre-review-decision",
|
|
}
|
|
],
|
|
business_stage="reimbursement",
|
|
platform_rule_set_fingerprint="rules-v1",
|
|
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
|
)
|
|
|
|
assert decision["decision"] == "needs_fix"
|
|
assert decision["blocking_count"] == 1
|
|
assert decision["findings"][0]["item_ids"] == ["item-pre-review-decision"]
|
|
assert (
|
|
decision["findings"][0]["remediation"]["alternative_action"]
|
|
== "accept_standard_limit"
|
|
)
|
|
|
|
|
|
def test_pre_review_routes_budget_governance_risk_to_approval_instead_of_blocking() -> None:
|
|
claim = build_claim()
|
|
decision = build_pre_review_decision(
|
|
claim,
|
|
risk_flags=[
|
|
{
|
|
"source": "budget_control",
|
|
"severity": "critical",
|
|
"risk_domain": "budget",
|
|
"actionability": "budget_governance",
|
|
"message": "预算使用率超过治理阈值。",
|
|
}
|
|
],
|
|
business_stage="reimbursement",
|
|
platform_rule_set_fingerprint="rules-v1",
|
|
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
|
)
|
|
|
|
assert decision["decision"] == "ready_with_review"
|
|
assert decision["blocking_count"] == 0
|
|
assert decision["findings"][0]["disposition"] == "review"
|
|
assert pre_review_identity_matches(
|
|
{**decision},
|
|
review_id=decision["review_id"],
|
|
input_fingerprint=decision["input_fingerprint"],
|
|
)
|