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

@@ -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",