test: 同步报销审批流与预算分析测试

- 新增预算审批合并、风险标记去重与占位条目校验用例
- 补充预算分析对当前审核人与财务的可见性断言
- 调整单据删除权限测试以匹配 admin 限制
This commit is contained in:
caoxiaozhu
2026-06-17 14:39:26 +08:00
parent 0fac8b615f
commit 4199feb681
10 changed files with 907 additions and 42 deletions

View File

@@ -313,15 +313,34 @@ def test_budget_analysis_endpoint_is_limited_to_budget_roles() -> None:
with session_factory() as db:
seed_budget_allocations(db)
budget_role, market_department = seed_market_budget_monitor(db)
manager_role = Role(role_code="manager", name="直属领导")
finance_role = Role(role_code="finance", name="财务")
manager = Employee(
employee_no="E-BUDGET-MANAGER",
name="预算领导",
email="budget-manager-review@example.com",
grade="P7",
organization_unit=market_department,
roles=[manager_role],
)
finance_user = Employee(
employee_no="E-BUDGET-FINANCE",
name="预算财务",
email="budget-finance-review@example.com",
grade="P6",
organization_unit=market_department,
roles=[finance_role],
)
p6_budget_monitor = Employee(
employee_no="E-BUDGET-MARKET-P6",
name="低级预算",
email="p6-budget-monitor@example.com",
grade="P6",
organization_unit=market_department,
manager=manager,
roles=[budget_role],
)
db.add(p6_budget_monitor)
db.add_all([manager, finance_user, p6_budget_monitor])
db.flush()
claim = ExpenseClaim(
claim_no="APP-BUDGET-ANALYSIS-001",
@@ -342,9 +361,49 @@ def test_budget_analysis_endpoint_is_limited_to_budget_roles() -> None:
approval_stage="预算管理者审批",
risk_flags_json=[],
)
db.add(claim)
leader_claim = ExpenseClaim(
claim_no="RE-BUDGET-ANALYSIS-LEADER",
employee_id=p6_budget_monitor.id,
employee_name="低级预算",
department_id="dept-market",
department_name="市场部",
project_code=None,
expense_type="travel",
reason="客户现场交付报销",
location="上海",
amount=Decimal("6000.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
finance_claim = ExpenseClaim(
claim_no="RE-BUDGET-ANALYSIS-FINANCE",
employee_id=p6_budget_monitor.id,
employee_name="低级预算",
department_id="dept-market",
department_name="市场部",
project_code=None,
expense_type="travel",
reason="客户现场交付报销",
location="上海",
amount=Decimal("8000.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="财务审批",
risk_flags_json=[],
)
db.add_all([claim, leader_claim, finance_claim])
db.commit()
claim_id = claim.id
leader_claim_id = leader_claim.id
finance_claim_id = finance_claim.id
ordinary_response = client.get(
f"/api/v1/reimbursements/claims/{claim_id}/budget-analysis",
@@ -367,8 +426,26 @@ def test_budget_analysis_endpoint_is_limited_to_budget_roles() -> None:
"x-auth-role-codes": "budget_monitor",
},
)
leader_response = client.get(
f"/api/v1/reimbursements/claims/{leader_claim_id}/budget-analysis",
headers={
"x-auth-username": "budget-manager-review@example.com",
"x-auth-role-codes": "manager",
},
)
finance_response = client.get(
f"/api/v1/reimbursements/claims/{finance_claim_id}/budget-analysis",
headers={
"x-auth-username": "budget-finance-review@example.com",
"x-auth-role-codes": "finance",
},
)
assert ordinary_response.status_code == 403
assert p6_monitor_response.status_code == 403
assert monitor_response.status_code == 200
assert leader_response.status_code == 200
assert finance_response.status_code == 200
assert Decimal(monitor_response.json()["metrics"]["claim_amount_ratio"]) == Decimal("24.00")
assert Decimal(leader_response.json()["metrics"]["claim_amount_ratio"]) == Decimal("12.00")
assert Decimal(finance_response.json()["metrics"]["claim_amount_ratio"]) == Decimal("16.00")

View File

@@ -4,6 +4,7 @@ import uuid
from datetime import UTC, datetime
from decimal import Decimal
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
@@ -236,7 +237,7 @@ def test_budget_warning_application_still_skips_budget_manager_when_not_over_bud
def test_application_routes_to_budget_manager_when_usage_reaches_90_percent() -> None:
with build_session() as db:
department, manager, _budget_manager, employee = _seed_people(db, suffix="OVER-90-APP")
department, manager, budget_manager, employee = _seed_people(db, suffix="OVER-90-APP")
_seed_budget_allocation(
db,
department_id=department.id,
@@ -288,6 +289,18 @@ def test_application_routes_to_budget_manager_when_usage_reaches_90_percent() ->
for flag in routed.risk_flags_json
)
with pytest.raises(ValueError, match="预算已超过警戒值"):
ExpenseClaimService(db).approve_claim(
claim.id,
CurrentUserContext(
username=budget_manager.email,
name=budget_manager.name,
role_codes=["budget_monitor"],
is_admin=False,
),
opinion=" ",
)
def test_application_stage_risk_under_90_percent_does_not_route_to_budget_manager() -> None:
with build_session() as db:
@@ -496,3 +509,57 @@ def test_risky_reimbursement_routes_to_budget_then_finance() -> None:
and flag.get("next_approval_stage") == FINANCE_APPROVAL_STAGE
for flag in budget_approved.risk_flags_json
)
def test_budget_manager_blank_opinion_defaults_to_agree_when_budget_under_warning() -> None:
with build_session() as db:
department, _manager, budget_manager, employee = _seed_people(db, suffix="BUDGET-NORMAL")
_seed_budget_allocation(
db,
department_id=department.id,
department_name=department.name,
amount=Decimal("10000.00"),
)
claim = ExpenseClaim(
claim_no="RE-20260530-BUDGET-NORMAL",
employee_id=employee.id,
employee_name=employee.name,
department_id=department.id,
department_name=department.name,
project_code=None,
expense_type="travel",
reason="客户现场沟通",
location="上海",
amount=Decimal("500.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage=BUDGET_MANAGER_APPROVAL_STAGE,
risk_flags_json=[],
)
db.add(claim)
db.commit()
approved = ExpenseClaimService(db).approve_claim(
claim.id,
CurrentUserContext(
username=budget_manager.email,
name=budget_manager.name,
role_codes=["budget_monitor"],
is_admin=False,
),
opinion=" ",
)
assert approved is not None
assert approved.status == "submitted"
assert approved.approval_stage == FINANCE_APPROVAL_STAGE
assert any(
isinstance(flag, dict)
and flag.get("source") == "budget_approval"
and flag.get("event_type") == "expense_claim_budget_approval"
and flag.get("opinion") == "同意"
for flag in approved.risk_flags_json
)

View File

@@ -0,0 +1,47 @@
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
def test_dedupe_claim_risk_flags_keeps_highest_severity_for_same_route_issue() -> None:
flags = [
{
"severity": "high",
"label": "多城市行程待说明",
"message": "检测到本次差旅涉及深圳多个目的地,但当前报销事由未说明中转原因。",
"item_ids": ["route-1", "route-2"],
"business_stage": "reimbursement",
},
{
"severity": "medium",
"label": "多城市行程缺少说明中风险",
"message": "本次报销识别到多城市行程,但事由中未说明中转、多地拜访或改签原因。",
"item_ids": ["route-2"],
"business_stage": "reimbursement",
},
]
deduped = dedupe_claim_risk_flags(flags)
assert [flag["label"] for flag in deduped] == ["多城市行程待说明"]
def test_dedupe_claim_risk_flags_keeps_distinct_item_risks() -> None:
flags = [
{
"severity": "high",
"label": "住宿金额超出报销标准",
"message": "第一张住宿票超出住宿标准。",
"item_id": "hotel-1",
"business_stage": "reimbursement",
},
{
"severity": "medium",
"label": "住宿金额超出报销标准",
"message": "第二张住宿票超出住宿标准。",
"item_id": "hotel-2",
"business_stage": "reimbursement",
},
]
deduped = dedupe_claim_risk_flags(flags)
assert [flag["item_id"] for flag in deduped] == ["hotel-1", "hotel-2"]

View File

@@ -37,6 +37,7 @@ from app.services.expense_claim_workflow_constants import (
APPLICATION_LINK_STATUS_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
)
from app.services.ontology import SemanticOntologyService
from app.services.ocr import OcrService
@@ -267,6 +268,81 @@ def test_validate_claim_for_submission_still_requires_hotel_receipt() -> None:
assert any("缺少票据标识" in item for item in issues)
def test_validate_claim_for_submission_does_not_block_uploaded_receipt_ocr_gaps() -> None:
service = ExpenseClaimService.__new__(ExpenseClaimService)
claim = build_claim(expense_type="hotel", location="北京")
claim.invoice_count = 1
claim.amount = Decimal("1086.00")
claim.items[0].item_type = "hotel_ticket"
claim.items[0].item_date = None
claim.items[0].item_reason = ""
claim.items[0].item_amount = Decimal("0.00")
claim.items[0].invoice_id = "claim-1/item-1/hotel-invoice.png"
issues = service._validate_claim_for_submission(claim)
assert not any("缺少日期" in item for item in issues)
assert not any("缺少说明" in item for item in issues)
assert not any("缺少金额" in item for item in issues)
assert not any("缺少票据标识" in item for item in issues)
def test_validate_claim_for_submission_ignores_trailing_placeholder_item() -> None:
service = ExpenseClaimService.__new__(ExpenseClaimService)
claim = build_claim(expense_type="travel", location="上海")
claim.amount = Decimal("1086.00")
claim.invoice_count = 1
claim.items[0].item_type = "hotel_ticket"
claim.items[0].item_reason = "上海喜来登酒店"
claim.items[0].item_location = "上海"
claim.items[0].item_amount = Decimal("1086.00")
claim.items[0].invoice_id = "claim-1/item-1/hotel-invoice.png"
claim.items.append(
ExpenseClaimItem(
id="item-2",
claim_id="claim-1",
item_date=date(2026, 2, 23),
item_type="hotel_ticket",
item_reason="",
item_location="",
item_amount=Decimal("0.00"),
invoice_id="",
)
)
issues = service._validate_claim_for_submission(claim)
assert not any(item.startswith("费用明细第 2 条") for item in issues)
def test_validate_claim_for_submission_skips_generated_allowance_item() -> None:
service = ExpenseClaimService.__new__(ExpenseClaimService)
claim = build_claim(expense_type="travel", location="上海")
claim.amount = Decimal("1486.00")
claim.invoice_count = 1
claim.items[0].item_type = "hotel_ticket"
claim.items[0].item_reason = "上海喜来登酒店"
claim.items[0].item_location = "上海"
claim.items[0].item_amount = Decimal("1086.00")
claim.items[0].invoice_id = "claim-1/item-1/hotel-invoice.png"
claim.items.append(
ExpenseClaimItem(
id="allowance-1",
claim_id="claim-1",
item_date=date(2026, 2, 23),
item_type="travel_allowance",
item_reason="",
item_location="",
item_amount=Decimal("0.00"),
invoice_id="",
)
)
issues = service._validate_claim_for_submission(claim)
assert not any(item.startswith("费用明细第 2 条") for item in issues)
def test_save_or_submit_preview_does_not_create_claim_without_explicit_action() -> None:
user_id = "preview-only@example.com"
message = "业务发生时间:2026-03-04打车去客户现场交通费32元请帮我看看怎么报"
@@ -2979,10 +3055,10 @@ def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_pat
def test_delete_claim_removes_all_claim_attachment_files(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-1",
username="admin",
name="张三",
role_codes=[],
is_admin=False,
role_codes=["admin"],
is_admin=True,
)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
@@ -3018,6 +3094,27 @@ def test_delete_claim_removes_all_claim_attachment_files(monkeypatch, tmp_path)
assert AgentConversationService(db).get_conversation(conversation.conversation_id) is None
def test_non_admin_cannot_delete_own_draft_claim(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
role_codes=[],
is_admin=False,
)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
with build_session() as db:
claim = build_claim(expense_type="office", location="深圳南山")
db.add(claim)
db.commit()
claim_id = claim.id
with pytest.raises(ValueError, match="只有 admin 管理员可以删除单据"):
ExpenseClaimService(db).delete_claim(claim_id, current_user)
assert db.get(ExpenseClaim, claim_id) is not None
def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-1",
@@ -3624,6 +3721,29 @@ def test_submit_claim_routes_travel_route_mismatch_to_approval_with_review_flag(
current_user=current_user,
)
def fake_platform_route_review(self, claim, *, rule_codes=None, business_stage=None):
return {
"flags": [
{
"source": "submission_review",
"hit_source": "rule_center",
"rule_code": "risk.travel.medium.multi_city_no_reason",
"severity": "medium",
"label": "多城市行程缺少说明中风险",
"message": "本次报销识别到多城市行程(上海、武汉、成都),但事由中未说明中转、多地拜访或改签原因。",
"item_ids": ["travel-item-2"],
"business_stage": "reimbursement",
}
],
"blocking_reasons": [],
}
monkeypatch.setattr(
ExpenseClaimService,
"evaluate_platform_risk_rules",
fake_platform_route_review,
)
submitted = service.submit_claim(claim.id, current_user)
assert submitted is not None
@@ -3648,6 +3768,11 @@ def test_submit_claim_routes_travel_route_mismatch_to_approval_with_review_flag(
assert route_flags
assert all(flag.get("item_ids") for flag in route_flags)
assert any("travel-item-2" in flag.get("item_ids", []) for flag in route_flags)
assert not any(
isinstance(flag, dict)
and str(flag.get("label") or "").strip() == "多城市行程缺少说明中风险"
for flag in list(submitted.risk_flags_json or [])
)
def test_submit_claim_allows_round_trip_ticket_origin_inferred_from_route(
@@ -5049,6 +5174,175 @@ def test_manager_cannot_operate_own_claim_submitted_to_direct_manager() -> None:
assert claim.risk_flags_json == []
def test_direct_manager_budget_monitor_routes_reimbursement_directly_to_finance() -> None:
current_user = CurrentUserContext(
username="manager-budget-monitor-reimbursement@example.com",
name="李预算经理",
role_codes=["manager", "budget_monitor", "executive"],
is_admin=False,
)
with build_session() as db:
budget_role = _seed_budget_monitor_role(db)
department = OrganizationUnit(
unit_code="DELIVERY-REIMBURSEMENT-MERGED",
name="交付部",
unit_type="department",
)
manager = Employee(
employee_no="E-RB-MERGED-MGR",
name="李预算经理",
email="manager-budget-monitor-reimbursement@example.com",
grade="P8",
organization_unit=department,
roles=[budget_role],
)
employee = Employee(
employee_no="E-RB-MERGED-EMP",
name="张三",
email="zhangsan-budget-monitor-reimbursement@example.com",
manager=manager,
organization_unit=department,
)
db.add_all([department, manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="RE-20260525-MERGED",
employee_id=employee.id,
employee_name="张三",
department_id=department.id,
department_name="交付部",
project_code="PRJ-A",
expense_type="travel",
reason="上海出差报销",
location="上海",
amount=Decimal("3020.00"),
currency="CNY",
invoice_count=3,
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[
{
"source": "submission_review",
"severity": "high",
"label": "报销风险复核",
"message": "多城市行程和住宿超标需要预算管理者二次确认。",
}
],
)
db.add(claim)
db.commit()
approved = ExpenseClaimService(db).approve_claim(
claim.id,
current_user,
opinion="业务必要且预算可承接,同意报销。",
)
assert approved is not None
assert approved.status == "submitted"
assert approved.approval_stage == "财务审批"
assert not any(
isinstance(flag, dict)
and flag.get("next_approval_stage") == "预算管理者审批"
for flag in approved.risk_flags_json
)
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_approval"
and flag.get("event_type") == "expense_claim_approval"
and flag.get("label") == "领导及预算审核通过"
and flag.get("opinion") == "业务必要且预算可承接,同意报销。"
and flag.get("previous_approval_stage") == "直属领导审批"
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"
for flag in approved.risk_flags_json
)
def test_duplicate_budget_stage_from_legacy_reimbursement_is_repaired_on_read() -> None:
admin_user = CurrentUserContext(
username="admin",
name="admin",
role_codes=["admin"],
is_admin=True,
)
with build_session() as db:
budget_role = _seed_budget_monitor_role(db)
department = OrganizationUnit(
unit_code="DELIVERY-LEGACY-REPAIR",
name="交付部",
unit_type="department",
)
manager = Employee(
employee_no="E-LEGACY-MGR",
name="李预算经理",
email="manager-legacy-repair@example.com",
grade="P8",
organization_unit=department,
roles=[budget_role],
)
employee = Employee(
employee_no="E-LEGACY-EMP",
name="张三",
email="zhangsan-legacy-repair@example.com",
manager=manager,
organization_unit=department,
)
db.add_all([department, manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="RE-20260525-LEGACY",
employee_id=employee.id,
employee_name=employee.name,
department_id=department.id,
department_name=department.name,
project_code="PRJ-A",
expense_type="travel",
reason="上海出差报销",
location="上海",
amount=Decimal("3020.00"),
currency="CNY",
invoice_count=3,
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage=BUDGET_MANAGER_APPROVAL_STAGE,
risk_flags_json=[
{
"source": "manual_approval",
"event_type": "expense_claim_approval",
"approval_event_id": "legacy-approval-event",
"operator": manager.name,
"operator_username": manager.email,
"previous_approval_stage": DIRECT_MANAGER_APPROVAL_STAGE,
"next_approval_stage": BUDGET_MANAGER_APPROVAL_STAGE,
"next_approver_name": manager.name,
"next_approver_employee_id": manager.id,
}
],
)
db.add(claim)
db.commit()
repaired = ExpenseClaimService(db).get_claim(claim.id, admin_user)
assert repaired is not None
assert repaired.approval_stage == FINANCE_APPROVAL_STAGE
assert 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
)
def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch: pytest.MonkeyPatch) -> None:
current_user = CurrentUserContext(
username="application-owner@example.com",