test: 同步报销审批流与预算分析测试
- 新增预算审批合并、风险标记去重与占位条目校验用例 - 补充预算分析对当前审核人与财务的可见性断言 - 调整单据删除权限测试以匹配 admin 限制
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user