feat(expenses): add authoritative pre-review workflow
This commit is contained in:
196
server/tests/test_expense_claim_pre_review_decision.py
Normal file
196
server/tests/test_expense_claim_pre_review_decision.py
Normal file
@@ -0,0 +1,196 @@
|
||||
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"],
|
||||
)
|
||||
Reference in New Issue
Block a user