feat(approval): add safe risk disposition workflow

This commit is contained in:
caoxiaozhu
2026-07-16 15:34:58 +08:00
parent ee88a36baf
commit 4940ebc419
64 changed files with 6602 additions and 631 deletions

View File

@@ -38,7 +38,6 @@ from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
)
from app.services.expense_claims import ExpenseClaimService
from app.services.ocr import OcrService
@@ -486,11 +485,13 @@ def test_upsert_draft_from_ontology_persists_linked_application_context() -> Non
)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-linked-1",
claim_no="AP-202605-001",
employee=employee,
))
db.add(
build_application_claim(
id="application-linked-1",
claim_no="AP-202605-001",
employee=employee,
)
)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
@@ -554,11 +555,13 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-linked-no-receipt",
claim_no="AP-202606-001",
employee=employee,
))
db.add(
build_application_claim(
id="application-linked-no-receipt",
claim_no="AP-202606-001",
employee=employee,
)
)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
@@ -623,7 +626,10 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
)
assert link_flag["application_claim_no"] == "AP-202606-001"
assert link_flag["application_detail"]["application_time"] == "2026-02-20 至 2026-02-23"
assert link_flag["application_detail"]["application_business_time"] == "2026-02-20 至 2026-02-23"
assert (
link_flag["application_detail"]["application_business_time"]
== "2026-02-20 至 2026-02-23"
)
assert link_flag["application_detail"]["application_date"] == "2026-06-02T00:58:00Z"
assert link_flag["application_detail"]["application_amount"] == "3000"
assert link_flag["application_detail"]["application_days"] == "4 天"
@@ -649,11 +655,13 @@ def test_upsert_linked_application_draft_clears_existing_placeholder_item() -> N
)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-linked-existing-placeholder",
claim_no="AP-202606-002",
employee=employee,
))
db.add(
build_application_claim(
id="application-linked-existing-placeholder",
claim_no="AP-202606-002",
employee=employee,
)
)
existing_claim = ExpenseClaim(
claim_no="RE-202606020001-PLACEHOLDER",
employee_id=employee.id,
@@ -738,12 +746,14 @@ def test_upsert_linked_application_requires_approved_application() -> None:
employee = Employee(employee_no="E5108", name="Linked Employee", email=user_id)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-returned-blocked",
claim_no="AP-202606-STATUS",
employee=employee,
status="returned",
))
db.add(
build_application_claim(
id="application-returned-blocked",
claim_no="AP-202606-STATUS",
employee=employee,
status="returned",
)
)
db.commit()
ontology = SemanticOntologyService(db).parse(
@@ -785,11 +795,13 @@ def test_upsert_linked_application_rejects_duplicate_reimbursement_draft() -> No
employee = Employee(employee_no="E5109", name="Linked Employee", email=user_id)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-duplicate-blocked",
claim_no="AP-202606-DUP",
employee=employee,
))
db.add(
build_application_claim(
id="application-duplicate-blocked",
claim_no="AP-202606-DUP",
employee=employee,
)
)
existing_claim = ExpenseClaim(
claim_no="RE-202606-DUP-DRAFT",
employee_id=employee.id,
@@ -995,11 +1007,7 @@ def test_unsaved_conversation_expires_after_retention_but_saved_conversation_sta
def test_resolve_expense_type_maps_office_supplies_review_value_to_office() -> None:
expense_type = ExpenseClaimService._resolve_expense_type(
[],
context_json={
"review_form_values": {
"expense_type": "办公用品"
}
},
context_json={"review_form_values": {"expense_type": "办公用品"}},
)
assert expense_type == "office"
@@ -1008,11 +1016,7 @@ def test_resolve_expense_type_maps_office_supplies_review_value_to_office() -> N
def test_resolve_expense_type_maps_riding_fare_review_value_to_transport() -> None:
expense_type = ExpenseClaimService._resolve_expense_type(
[],
context_json={
"review_form_values": {
"expense_type": "乘车费用"
}
},
context_json={"review_form_values": {"expense_type": "乘车费用"}},
)
assert expense_type == "transport"
@@ -1340,7 +1344,9 @@ def test_upsert_draft_from_ontology_supports_link_or_create_for_multi_documents(
"text": "停车费 合计 18 元",
"document_type": "parking_toll_receipt",
"scene_code": "transport",
"document_fields": [{"key": "total_amount", "label": "合计金额", "value": "18"}],
"document_fields": [
{"key": "total_amount", "label": "合计金额", "value": "18"}
],
},
],
}
@@ -2044,6 +2050,7 @@ def test_update_claim_item_reanalyzes_existing_attachment(monkeypatch, tmp_path)
assert refreshed_meta["requirement_check"]["matches"] is False
assert any("附件类型要求" in point for point in refreshed_meta["analysis"]["points"])
def test_upload_attachment_refreshes_claim_pre_review(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-1",
@@ -2518,15 +2525,13 @@ def test_upload_attachment_runs_rule_center_city_risk_from_origin_destination_fi
flags = payload["claim_risk_flags"]
assert any(
isinstance(flag, dict)
and flag.get("rule_code") == "risk.travel.high.city_mismatch"
isinstance(flag, dict) and flag.get("rule_code") == "risk.travel.high.city_mismatch"
for flag in flags
)
city_flag = next(
flag
for flag in flags
if isinstance(flag, dict)
and flag.get("rule_code") == "risk.travel.high.city_mismatch"
if isinstance(flag, dict) and flag.get("rule_code") == "risk.travel.high.city_mismatch"
)
assert city_flag.get("item_ids") == [claim.items[0].id]
@@ -2604,8 +2609,7 @@ def test_upload_attachment_uses_linked_application_business_time_for_date_risk(
flags = payload["claim_risk_flags"]
assert any(
isinstance(flag, dict)
and flag.get("rule_code") == "risk.travel.high.date_outside_trip"
isinstance(flag, dict) and flag.get("rule_code") == "risk.travel.high.date_outside_trip"
for flag in flags
)
@@ -2686,8 +2690,13 @@ def test_upload_hotel_attachment_audits_date_like_amount(monkeypatch, tmp_path)
)
assert uploaded_meta is not None
assert uploaded_meta["analysis"]["severity"] == "medium"
assert any("费用核算" in point and "828.00 元" in point for point in uploaded_meta["analysis"]["points"])
assert not any("2026.00 元与报销金额" in point for point in uploaded_meta["analysis"]["points"])
assert any(
"费用核算" in point and "828.00 元" in point
for point in uploaded_meta["analysis"]["points"]
)
assert not any(
"2026.00 元与报销金额" in point for point in uploaded_meta["analysis"]["points"]
)
def test_upload_hotel_attachment_flags_amount_over_travel_policy(monkeypatch, tmp_path) -> None:
@@ -2889,7 +2898,9 @@ def test_upload_hotel_attachment_does_not_add_generic_auto_review_summary(
)
def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypatch, tmp_path) -> None:
def test_delete_claim_item_attachment_removes_attachment_analysis_risk(
monkeypatch, tmp_path
) -> None:
current_user = CurrentUserContext(
username="emp-hotel-risk@example.com",
name="张三",
@@ -2964,7 +2975,8 @@ def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypat
assert upload_payload is not None
assert any(
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "attachment_analysis"
for flag in upload_payload["claim_risk_flags"]
)
@@ -2977,7 +2989,8 @@ def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypat
assert delete_payload is not None
assert delete_payload["invoice_id"] is None
assert not any(
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "attachment_analysis"
for flag in delete_payload["claim_risk_flags"]
)
assert not any(
@@ -2990,7 +3003,8 @@ def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypat
assert claim.invoice_count == 0
assert claim.items[0].invoice_id is None
assert not any(
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "attachment_analysis"
for flag in list(claim.risk_flags_json or [])
)
@@ -3278,7 +3292,9 @@ def test_applicant_can_delete_own_editable_draft_claim(monkeypatch, tmp_path) ->
assert db.get(ExpenseClaim, claim_id) is None
def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(monkeypatch, tmp_path) -> None:
def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(
monkeypatch, tmp_path
) -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
@@ -3316,7 +3332,9 @@ def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(mon
assert filename == "legacy-ticket.pdf"
def test_attachment_pdf_preview_falls_back_to_source_when_render_fonts_missing(monkeypatch, tmp_path) -> None:
def test_attachment_pdf_preview_falls_back_to_source_when_render_fonts_missing(
monkeypatch, tmp_path
) -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
@@ -3359,9 +3377,13 @@ def test_attachment_pdf_preview_falls_back_to_source_when_render_fonts_missing(m
def fake_render_pdf_first_page(*, pdf_path, preview_path, timeout_seconds):
raise RuntimeError("Missing language pack for 'Adobe-GB1' mapping")
monkeypatch.setattr(DocumentPreviewAssets, "render_pdf_first_page", fake_render_pdf_first_page)
monkeypatch.setattr(
DocumentPreviewAssets, "render_pdf_first_page", fake_render_pdf_first_page
)
resolved_path, media_type, filename = ExpenseClaimService(db).get_claim_item_attachment_preview_content(
resolved_path, media_type, filename = ExpenseClaimService(
db
).get_claim_item_attachment_preview_content(
claim_id=claim.id,
item_id=claim.items[0].id,
current_user=current_user,
@@ -3412,6 +3434,7 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
assert submitted.approval_stage == "直属领导审批"
assert submitted.submitted_at is not None
def test_submit_claim_refreshes_legacy_pre_review_without_fingerprint(monkeypatch) -> None:
current_user = CurrentUserContext(
username="emp-submit@example.com",
@@ -3470,13 +3493,10 @@ def test_submit_claim_refreshes_legacy_pre_review_without_fingerprint(monkeypatc
assert submitted.status == "submitted"
assert review_calls == 1
assert not any(
flag.get("label") == "upload-time-warning"
for flag in submitted.risk_flags_json
flag.get("label") == "upload-time-warning" for flag in submitted.risk_flags_json
)
pre_review_flag = next(
flag
for flag in submitted.risk_flags_json
if flag.get("source") == "ai_pre_review"
flag for flag in submitted.risk_flags_json if flag.get("source") == "ai_pre_review"
)
assert pre_review_flag["review_id"]
assert pre_review_flag["input_fingerprint"].startswith("sha256:")
@@ -3823,8 +3843,7 @@ def test_submit_claim_blocks_high_risk_attachment_until_submitter_fixes_it(
assert blocked.submitted_at is None
assert error_info.value.review["decision"] == "needs_fix"
assert any(
finding["severity"] == "high"
and finding["disposition"] == "fix"
finding["severity"] == "high" and finding["disposition"] == "fix"
for finding in error_info.value.review["findings"]
)
@@ -4000,10 +4019,7 @@ def test_submit_claim_blocks_travel_route_mismatch_until_submitter_explains_it(
if "多城市" in finding["message"] or "终点" in finding["message"]
]
assert route_findings
assert any(
"travel-item-2" in finding["item_ids"]
for finding in route_findings
)
assert any("travel-item-2" in finding["item_ids"] for finding in route_findings)
def test_submit_claim_allows_round_trip_ticket_origin_inferred_from_route(
@@ -4297,8 +4313,7 @@ def test_submit_claim_blocks_hotel_amount_over_policy_until_standard_adjustment(
assert blocked.status == "draft"
assert error_info.value.review["decision"] == "needs_fix"
assert any(
finding.get("remediation", {}).get("alternative_action")
== "accept_standard_limit"
finding.get("remediation", {}).get("alternative_action") == "accept_standard_limit"
for finding in error_info.value.review["findings"]
)
assert any(
@@ -5360,12 +5375,15 @@ def test_admin_delete_linked_reimbursement_resets_application_link_status() -> N
sync_flag = next(
flag
for flag in application_claim.risk_flags_json
if isinstance(flag, dict) and flag.get("event_type") == "expense_application_reimbursement_deleted"
if isinstance(flag, dict)
and flag.get("event_type") == "expense_application_reimbursement_deleted"
)
assert sync_flag["source"] == "application_link_sync"
assert sync_flag["severity"] == "info"
assert sync_flag["actionability"] == "system_trace"
assert sync_flag["deleted_reimbursement_claim_id"] == "reimbursement-delete-linked-application"
assert (
sync_flag["deleted_reimbursement_claim_id"] == "reimbursement-delete-linked-application"
)
assert sync_flag["deleted_reimbursement_claim_no"] == "RDELETE01"
assert sync_flag["next_approval_stage"] == APPLICATION_LINK_STATUS_STAGE
@@ -5414,7 +5432,9 @@ def test_direct_manager_can_return_subordinate_claim_to_pending_submission() ->
db.commit()
claim_id = claim.id
returned = ExpenseClaimService(db).return_claim(claim_id, current_user, reason="请补充行程说明")
returned = ExpenseClaimService(db).return_claim(
claim_id, current_user, reason="请补充行程说明"
)
assert returned is not None
assert returned.status == "returned"
@@ -5603,6 +5623,7 @@ def test_direct_manager_budget_monitor_routes_reimbursement_directly_to_finance(
{
"source": "submission_review",
"severity": "high",
"actionability": "route_review",
"label": "报销风险复核",
"message": "多城市行程和住宿超标需要预算管理者二次确认。",
}
@@ -5621,8 +5642,7 @@ def test_direct_manager_budget_monitor_routes_reimbursement_directly_to_finance(
assert approved.status == "submitted"
assert approved.approval_stage == "财务审批"
assert not any(
isinstance(flag, dict)
and flag.get("next_approval_stage") == "预算管理者审批"
isinstance(flag, dict) and flag.get("next_approval_stage") == "预算管理者审批"
for flag in approved.risk_flags_json
)
assert any(
@@ -5635,12 +5655,13 @@ def test_direct_manager_budget_monitor_routes_reimbursement_directly_to_finance(
and flag.get("next_status") == "submitted"
and flag.get("next_approval_stage") == "财务审批"
and flag.get("budget_approval_merged") is True
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
and flag.get("budget_approval_merged_reason")
== "direct_manager_is_department_budget_approver"
for flag in approved.risk_flags_json
)
def test_duplicate_budget_stage_from_legacy_reimbursement_is_repaired_on_read() -> None:
def test_legacy_duplicate_budget_stage_is_not_mutated_by_read() -> None:
admin_user = CurrentUserContext(
username="admin",
name="admin",
@@ -5706,20 +5727,25 @@ def test_duplicate_budget_stage_from_legacy_reimbursement_is_repaired_on_read()
db.add(claim)
db.commit()
repaired = ExpenseClaimService(db).get_claim(claim.id, admin_user)
result = ExpenseClaimService(db).get_claim(claim.id, admin_user)
assert repaired is not None
assert repaired.approval_stage == FINANCE_APPROVAL_STAGE
assert any(
assert result is not None
assert result.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
assert not any(
isinstance(flag, dict)
and flag.get("source") == "approval_flow_repair"
and flag.get("event_type") == "duplicate_budget_approval_stage_repaired"
and flag.get("next_approval_stage") == FINANCE_APPROVAL_STAGE
for flag in repaired.risk_flags_json
for flag in result.risk_flags_json
)
db.expire_all()
persisted = db.get(ExpenseClaim, claim.id)
assert persisted is not None
assert persisted.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch: pytest.MonkeyPatch) -> None:
def test_application_submit_skips_ai_review_and_receipt_requirements(
monkeypatch: pytest.MonkeyPatch,
) -> None:
current_user = CurrentUserContext(
username="application-owner@example.com",
name="张三",
@@ -5970,7 +5996,9 @@ def test_application_submit_skips_budget_for_non_demo_subject() -> None:
)
def test_direct_manager_can_route_application_claim_to_budget_approval_then_budget_manager_creates_draft() -> None:
def test_direct_manager_can_route_application_claim_to_budget_approval_then_budget_manager_creates_draft() -> (
None
):
manager_user = CurrentUserContext(
username="manager-application-approve@example.com",
name="李经理",
@@ -6053,9 +6081,10 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
{
"source": "submission_review",
"severity": "high",
"actionability": "route_review",
"label": "申请风险复核",
"message": "申请金额和行程安排需要预算管理者二次确认。",
}
},
],
)
db.add(claim)
@@ -6120,13 +6149,18 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
and flag.get("source") == "application_handoff"
and flag.get("event_type") == "expense_application_to_reimbursement_draft"
and flag.get("application_claim_no") == "APP-20260525-APPROVE"
and flag.get("application_detail", {}).get("application_content") == "差旅费用申请 / 上海"
and flag.get("application_detail", {}).get("application_reason") == "支撑国网服务器上线部署"
and flag.get("application_detail", {}).get("application_content")
== "差旅费用申请 / 上海"
and flag.get("application_detail", {}).get("application_reason")
== "支撑国网服务器上线部署"
and flag.get("application_detail", {}).get("application_days") == "3 天"
and flag.get("application_detail", {}).get("application_transport_mode") == "高铁"
and flag.get("application_detail", {}).get("application_lodging_daily_cap") == "600元/天"
and flag.get("application_detail", {}).get("application_subsidy_daily_cap") == "120元/天"
and flag.get("application_detail", {}).get("application_transport_policy") == "按真实票据复核"
and flag.get("application_detail", {}).get("application_lodging_daily_cap")
== "600元/天"
and flag.get("application_detail", {}).get("application_subsidy_daily_cap")
== "120元/天"
and flag.get("application_detail", {}).get("application_transport_policy")
== "按真实票据复核"
and flag.get("application_detail", {}).get("application_policy_estimate")
== "交通按真实票据 + 住宿 1,800元 + 补贴 360元"
and flag.get("application_detail", {}).get("application_rule_name") == "差旅标准规则"
@@ -6214,6 +6248,7 @@ def test_application_routes_to_department_p8_executive_with_approver_name() -> N
{
"source": "submission_review",
"severity": "high",
"actionability": "route_review",
"label": "Route risk",
"message": "Application requires budget confirmation.",
}
@@ -6306,6 +6341,7 @@ def test_direct_manager_cannot_route_application_to_missing_budget_approver() ->
{
"source": "submission_review",
"severity": "high",
"actionability": "route_review",
"label": "Route risk",
"message": "Application requires budget confirmation.",
}
@@ -6328,7 +6364,9 @@ def test_direct_manager_cannot_route_application_to_missing_budget_approver() ->
assert reimbursement_claim_query(db).count() == 0
def test_direct_manager_p8_executive_completes_application_without_duplicate_budget_approval() -> None:
def test_direct_manager_p8_executive_completes_application_without_duplicate_budget_approval() -> (
None
):
manager_user = CurrentUserContext(
username="manager-executive-merged@example.com",
name="P8 Manager",
@@ -6381,6 +6419,7 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud
{
"source": "submission_review",
"severity": "high",
"actionability": "route_review",
"label": "Route risk",
"message": "Application requires budget confirmation.",
}
@@ -6411,12 +6450,15 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud
and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == APPLICATION_LINK_STATUS_STAGE
and flag.get("budget_approval_merged") is True
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
and flag.get("budget_approval_merged_reason")
== "direct_manager_is_department_budget_approver"
for flag in approved.risk_flags_json
)
def test_direct_manager_budget_monitor_completes_application_claim_without_duplicate_budget_approval() -> None:
def test_direct_manager_budget_monitor_completes_application_claim_without_duplicate_budget_approval() -> (
None
):
manager_user = CurrentUserContext(
username="manager-budget-monitor-application@example.com",
name="李预算经理",
@@ -6469,6 +6511,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
{
"source": "submission_review",
"severity": "high",
"actionability": "route_review",
"label": "申请风险复核",
"message": "申请金额和行程安排需要预算管理者二次确认。",
}
@@ -6489,8 +6532,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
assert approved.approval_stage == "关联单据状态"
assert reimbursement_claim_query(db).count() == 1
assert not any(
isinstance(flag, dict)
and flag.get("next_approval_stage") == "预算管理者审批"
isinstance(flag, dict) and flag.get("next_approval_stage") == "预算管理者审批"
for flag in approved.risk_flags_json
)
assert any(
@@ -6503,7 +6545,8 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == "关联单据状态"
and flag.get("budget_approval_merged") is True
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
and flag.get("budget_approval_merged_reason")
== "direct_manager_is_department_budget_approver"
for flag in approved.risk_flags_json
)
generated_draft = reimbursement_claim_query(db).one()
@@ -6692,6 +6735,7 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
{
"source": "platform_risk",
"severity": "high",
"actionability": "route_review",
"label": "申请风险复核",
"message": "申请金额和行程安排需要预算管理者二次确认。",
}
@@ -6719,10 +6763,11 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
assert reservation.source_type == "claim"
assert reservation.source_id == generated_draft.id
assert reservation.source_no == generated_draft.claim_no
assert any(item.transaction_type == "transfer" for item in db.query(BudgetTransaction).all())
assert any(
isinstance(flag, dict)
and flag.get("event_type") == "budget_reservation_transferred"
item.transaction_type == "transfer" for item in db.query(BudgetTransaction).all()
)
assert any(
isinstance(flag, dict) and flag.get("event_type") == "budget_reservation_transferred"
for flag in generated_draft.risk_flags_json
)
@@ -6916,7 +6961,12 @@ def test_finance_approve_reimbursement_consumes_budget_reservation() -> None:
db.refresh(reservation)
assert reservation.source_status == "consumed"
assert reservation.consumed_amount == Decimal("12000.00")
assert db.query(BudgetTransaction).filter(BudgetTransaction.transaction_type == "consume").count() == 1
assert (
db.query(BudgetTransaction)
.filter(BudgetTransaction.transaction_type == "consume")
.count()
== 1
)
assert any(
isinstance(flag, dict)
and flag.get("source") == "budget_control"
@@ -7290,7 +7340,10 @@ def test_return_claim_records_each_return_event_with_stage_reason_and_counts() -
assert return_events[0]["stage_return_count"] == 1
assert return_events[0]["return_stage"] == "直属领导审批"
assert return_events[0]["reason_codes"] == ["invoice_mismatch", "business_explanation"]
assert return_events[0]["risk_points"] == ["票据类型/金额与明细不一致", "业务事由/地点/人员信息不完整"]
assert return_events[0]["risk_points"] == [
"票据类型/金额与明细不一致",
"业务事由/地点/人员信息不完整",
]
assert return_events[0]["reason"] == "发票金额与明细金额不一致,请重新核对。"
assert return_events[0]["operator_role_codes"] == ["manager"]
assert return_events[1]["return_count"] == 2
@@ -7624,14 +7677,16 @@ def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applica
email="budget-list-market@example.com",
organization_unit=market_department,
)
db.add_all([
delivery_department,
market_department,
budget_manager,
p8_without_budget_employee,
employee,
market_employee,
])
db.add_all(
[
delivery_department,
market_department,
budget_manager,
p8_without_budget_employee,
employee,
market_employee,
]
)
db.flush()
db.add_all(
[
@@ -7702,5 +7757,7 @@ def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applica
assert getattr(claims[0], "budget_approver_name", "") == "赵预算"
assert getattr(claims[0], "budget_approver_grade", "") == "P8"
assert getattr(claims[0], "budget_approver_role_code", "") == "budget_monitor"
claims_without_budget_role = ExpenseClaimService(db).list_approval_claims(p8_without_budget_role)
claims_without_budget_role = ExpenseClaimService(db).list_approval_claims(
p8_without_budget_role
)
assert [claim.claim_no for claim in claims_without_budget_role] == []