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