feat(expenses): add authoritative pre-review workflow

This commit is contained in:
caoxiaozhu
2026-07-16 11:42:08 +08:00
parent ae3f02c35a
commit 6bdf65bc24
52 changed files with 4300 additions and 1069 deletions

View 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"],
)