feat(approval): add safe risk disposition workflow
This commit is contained in:
@@ -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] == []
|
||||
|
||||
Reference in New Issue
Block a user