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