Files
X-Financial/server/tests/test_expense_claim_service.py
caoxiaozhu 92444e7eae feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造
- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制
- 引入费用审批动态路由、平台风险分级、预审与风险阶段管理
- 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板
- 新增 Hermes 风险线索收集器、Agent 链路追踪中心
- 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估
- 完善报销申请快速预览、权限控制与前端测试覆盖
2026-06-01 17:07:14 +08:00

5200 lines
188 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import re
import uuid
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext
from app.db.base import Base
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.organization import OrganizationUnit
from app.models.role import Role
from app.schemas.ontology import OntologyParseRequest
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate, ExpenseClaimUpdate
from app.services.agent_conversations import AgentConversationService
from app.services.budget import BudgetService
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claims import ExpenseClaimService
from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
)
from app.services.ontology import SemanticOntologyService
from app.services.ocr import OcrService
def build_claim(*, expense_type: str, location: str) -> ExpenseClaim:
claim = ExpenseClaim(
id="claim-1",
claim_no="EXP-202605-001",
employee_id="emp-1",
employee_name="张三",
department_id="dept-1",
department_name="市场部",
project_code=None,
expense_type=expense_type,
reason="费用报销",
location=location,
amount=Decimal("88.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
claim.items = [
ExpenseClaimItem(
id="item-1",
claim_id="claim-1",
item_date=date(2026, 5, 13),
item_type=expense_type,
item_reason="费用报销",
item_location=location,
item_amount=Decimal("88.00"),
invoice_id="invoice-1",
)
]
return claim
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory()
def _count_claims(db: Session) -> int:
return int(db.query(ExpenseClaim).count())
def _seed_budget_allocation(
db: Session,
*,
department_id: str | None,
department_name: str,
subject_code: str = "travel",
amount: Decimal = Decimal("50000.00"),
period_key: str = "2026Q2",
) -> BudgetAllocation:
allocation = BudgetAllocation(
budget_no=f"BUD-TEST-{uuid.uuid4().hex[:8]}",
fiscal_year=2026,
period_type="quarter",
period_key=period_key,
department_id=department_id,
department_name=department_name,
cost_center=None,
project_code=None,
subject_code=subject_code,
subject_name=subject_code,
original_amount=amount,
adjusted_amount=Decimal("0.00"),
status="active",
warning_threshold=Decimal("80.00"),
control_action="block",
)
db.add(allocation)
db.commit()
return allocation
def _seed_budget_monitor_role(db: Session) -> Role:
role = db.query(Role).filter(Role.role_code == "budget_monitor").one_or_none()
if role is not None:
return role
role = Role(role_code="budget_monitor", name="预算监控员")
db.add(role)
db.flush()
return role
def _seed_executive_role(db: Session) -> Role:
role = db.query(Role).filter(Role.role_code == "executive").one_or_none()
if role is not None:
return role
role = Role(role_code="executive", name="Senior finance")
db.add(role)
db.flush()
return role
def test_validate_claim_for_submission_allows_office_claim_without_location() -> None:
service = ExpenseClaimService.__new__(ExpenseClaimService)
claim = build_claim(expense_type="office", location="待补充")
issues = service._validate_claim_for_submission(claim)
assert "业务地点未完善" not in issues
assert not any("缺少地点" in item for item in issues)
def test_validate_claim_for_submission_allows_transport_claim_without_location() -> None:
service = ExpenseClaimService.__new__(ExpenseClaimService)
claim = build_claim(expense_type="transport", location="待补充")
issues = service._validate_claim_for_submission(claim)
assert "业务地点未完善" not in issues
assert not any("缺少地点" in item for item in issues)
def test_validate_claim_for_submission_still_requires_location_for_travel_claim() -> None:
service = ExpenseClaimService.__new__(ExpenseClaimService)
claim = build_claim(expense_type="travel", location="待补充")
issues = service._validate_claim_for_submission(claim)
assert "业务地点未完善" in issues
assert any("缺少地点" in item 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元请帮我看看怎么报"
with build_session() as db:
employee = Employee(
employee_no="E5100",
name="预览员工",
email=user_id,
)
db.add(employee)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id=user_id,
)
)
before_count = _count_claims(db)
result = ExpenseClaimService(db).save_or_submit_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message=message,
ontology=ontology,
context_json={
"name": "预览员工",
"user_input_text": message,
},
)
assert result["preview_only"] is True
assert result["status"] == "preview"
assert "报销测算参考:" in result["message"]
assert "| 项目 | 当前信息 | 复核口径 |" in result["message"]
assert "交通票据金额 + 住宿标准" not in result["message"]
assert _count_claims(db) == before_count
def test_save_or_submit_persists_claim_only_after_save_draft_action() -> None:
user_id = "save-draft-explicit@example.com"
message = "业务发生时间:2026-03-04打车去客户现场交通费32元请帮我看看怎么报"
with build_session() as db:
employee = Employee(
employee_no="E5101",
name="保存员工",
email=user_id,
)
db.add(employee)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id=user_id,
)
)
before_count = _count_claims(db)
result = ExpenseClaimService(db).save_or_submit_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message=message,
ontology=ontology,
context_json={
"name": "保存员工",
"user_input_text": message,
"review_action": "save_draft",
},
)
assert result["draft_only"] is True
assert result["claim_id"]
assert result["status"] == "draft"
assert _count_claims(db) == before_count + 1
def test_save_draft_persists_user_changed_expense_category() -> None:
user_id = "save-draft-category@example.com"
message = "业务发生时间:2026-03-04打车去客户现场交通费32元请帮我看看怎么报"
with build_session() as db:
employee = Employee(
employee_no="E5102",
name="分类员工",
email=user_id,
)
db.add(employee)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id=user_id,
)
)
result = ExpenseClaimService(db).save_or_submit_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message=message,
ontology=ontology,
context_json={
"name": "分类员工",
"user_input_text": message,
"review_action": "save_draft",
"review_form_values": {
"expense_type": "办公用品费",
"amount": "32元",
"occurred_date": "2026-03-04",
"reason": "右侧核对后改为办公用品费",
},
},
)
claim = db.get(ExpenseClaim, result["claim_id"])
assert claim is not None
assert claim.expense_type == "office"
assert claim.items[0].item_type == "office"
def test_upsert_draft_from_ontology_persists_linked_application_context() -> None:
user_id = "linked-application-context@example.com"
message = "业务发生时间:2026-05-20去北京支撑国网部署火车票354元申请差旅费报销"
with build_session() as db:
employee = Employee(
employee_no="E5103",
name="关联员工",
email=user_id,
)
db.add(employee)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id=user_id,
)
)
result = ExpenseClaimService(db).upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message=message,
ontology=ontology,
context_json={
"name": "关联员工",
"user_input_text": message,
"review_action": "save_draft",
"review_form_values": {
"expense_type": "差旅费",
"amount": "354元",
"application_claim_id": "application-linked-1",
"application_claim_no": "AP-202605-001",
"application_reason": "支撑国网仿生产环境部署",
"application_location": "北京",
"application_amount": "3000",
},
"expense_scene_selection": {
"expense_type": "travel",
"application_claim_id": "application-linked-1",
"application_claim_no": "AP-202605-001",
},
},
)
claim = db.get(ExpenseClaim, result["claim_id"])
assert claim is not None
link_flag = next(
flag
for flag in claim.risk_flags_json
if isinstance(flag, dict) and flag.get("source") == "application_link"
)
assert link_flag["application_claim_no"] == "AP-202605-001"
assert link_flag["application_claim_id"] == "application-linked-1"
assert link_flag["application_detail"]["application_reason"] == "支撑国网仿生产环境部署"
def test_unsaved_conversation_expires_after_retention_but_saved_conversation_stays() -> None:
with build_session() as db:
service = AgentConversationService(db)
unsaved = service.get_or_create_conversation(
conversation_id="conv-unsaved-expire",
user_id="expire@example.com",
source="user_message",
context_json={"session_type": "expense"},
)
saved = service.get_or_create_conversation(
conversation_id="conv-saved-keep",
user_id="expire@example.com",
source="user_message",
context_json={
"session_type": "expense",
"draft_claim_id": "claim-saved",
},
)
old_time = datetime.now(UTC) - timedelta(days=4)
unsaved.updated_at = old_time
saved.updated_at = old_time
db.add_all([unsaved, saved])
db.commit()
deleted_count = service.prune_expired_conversations(retention_days=3)
assert deleted_count == 1
assert service.get_conversation("conv-unsaved-expire") is None
assert service.get_conversation("conv-saved-keep") is not None
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": "办公用品"
}
},
)
assert expense_type == "office"
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": "乘车费用"
}
},
)
assert expense_type == "transport"
def test_upsert_draft_from_ontology_defers_multi_document_association_choice() -> None:
user_id = "zhangsan@example.com"
with build_session() as db:
employee = Employee(
employee_no="E5001",
name="张三",
email=user_id,
)
db.add(employee)
db.flush()
existing_claim = ExpenseClaim(
claim_no="EXP-202605-010",
employee_id=employee.id,
employee_name="张三",
department_name="市场部",
project_code=None,
expense_type="transport",
reason="原有交通报销",
location="深圳",
amount=Decimal("20.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
existing_claim.items = [
ExpenseClaimItem(
claim_id=existing_claim.id,
item_date=date(2026, 5, 13),
item_type="transport",
item_reason="原有交通报销",
item_location="深圳",
item_amount=Decimal("20.00"),
invoice_id="old-trip.png",
)
]
db.add(existing_claim)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我上传了两张交通票据,帮我生成报销草稿",
user_id=user_id,
)
)
service = ExpenseClaimService(db)
result = service.upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message="我上传了两张交通票据,帮我生成报销草稿",
ontology=ontology,
context_json={
"name": "张三",
"attachment_names": ["didi-trip.png", "parking-ticket.jpg"],
"attachment_count": 2,
"draft_claim_id": existing_claim.id,
"ocr_documents": [
{
"filename": "didi-trip.png",
"summary": "滴滴出行 支付金额 32 元",
"text": "滴滴出行 支付金额 32 元",
},
{
"filename": "parking-ticket.jpg",
"summary": "停车费 合计 18 元",
"text": "停车费 合计 18 元",
},
],
},
)
db.refresh(existing_claim)
assert result["pending_association_decision"] is True
assert result["association_candidate_claim_id"] == existing_claim.id
assert existing_claim.invoice_count == 1
assert len(existing_claim.items) == 1
assert existing_claim.items[0].invoice_id == "old-trip.png"
def test_linked_document_supplement_keeps_existing_claim_expense_type() -> None:
user_id = "type-lock@example.com"
with build_session() as db:
employee = Employee(
employee_no="E5010",
name="类型锁定员工",
email=user_id,
)
db.add(employee)
db.flush()
existing_claim = ExpenseClaim(
claim_no="EXP-202605-020",
employee_id=employee.id,
employee_name="类型锁定员工",
department_name="市场部",
project_code=None,
expense_type="transport",
reason="原有交通报销",
location="深圳",
amount=Decimal("32.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
existing_claim.items = [
ExpenseClaimItem(
claim_id=existing_claim.id,
item_date=date(2026, 5, 13),
item_type="transport",
item_reason="原有交通报销",
item_location="深圳",
item_amount=Decimal("32.00"),
invoice_id="old-trip.png",
)
]
db.add(existing_claim)
db.commit()
context_json = {
"name": "类型锁定员工",
"review_action": "link_to_existing_draft",
"draft_claim_id": existing_claim.id,
"attachment_names": ["hotel-invoice.pdf"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "hotel-invoice.pdf",
"document_type": "hotel_invoice",
"scene_code": "hotel",
"scene_label": "住宿票据",
"summary": "酒店住宿 发票金额 300 元",
"text": "酒店住宿 发票金额 ¥300.00",
"document_fields": [
{"key": "amount", "label": "金额", "value": "300"},
{"key": "merchant", "label": "酒店名称", "value": "上海酒店"},
],
}
],
}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="把酒店发票补充到现有草稿",
user_id=user_id,
context_json=context_json,
)
)
ExpenseClaimService(db).upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message="把酒店发票补充到现有草稿",
ontology=ontology,
context_json=context_json,
)
db.refresh(existing_claim)
assert existing_claim.expense_type == "transport"
assert any(item.item_type == "hotel_ticket" for item in existing_claim.items)
def test_upsert_draft_from_ontology_keeps_reason_missing_for_attachment_only_upload() -> None:
user_id = "wangwu@example.com"
with build_session() as db:
employee = Employee(
employee_no="E5003",
name="王五",
email=user_id,
)
db.add(employee)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我上传了 1 份票据,请结合附件名称给出报销建议并尽量生成草稿。",
user_id=user_id,
)
)
service = ExpenseClaimService(db)
result = service.upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message="我上传了 1 份票据,请结合附件名称给出报销建议并尽量生成草稿。\n附件名称didi-trip.png",
ontology=ontology,
context_json={
"name": "王五",
"user_input_text": "",
"attachment_names": ["didi-trip.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "didi-trip.png",
"summary": "滴滴出行 支付金额 32 元",
"text": "滴滴出行 支付金额 32 元",
"document_type": "taxi_receipt",
"scene_code": "transport",
}
],
},
)
claim = db.get(ExpenseClaim, result["claim_id"])
assert claim is not None
assert claim.reason == "待补充"
def test_upsert_draft_from_ontology_strips_recognized_business_time_from_reason() -> None:
user_id = "transport-time@example.com"
message = "业务发生时间:2026-03-04送客户去林萃小区办事请报销乘车费用"
with build_session() as db:
employee = Employee(
employee_no="E5004",
name="赵六",
email=user_id,
)
db.add(employee)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id=user_id,
)
)
result = ExpenseClaimService(db).upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message=message,
ontology=ontology,
context_json={
"name": "赵六",
"user_input_text": message,
},
)
claim = db.get(ExpenseClaim, result["claim_id"])
assert claim is not None
assert claim.occurred_at.date() == date(2026, 3, 4)
assert claim.reason == "送客户去林萃小区办事,请报销乘车费用"
assert len(claim.items) == 1
assert claim.items[0].item_date == date(2026, 3, 4)
assert claim.items[0].item_reason == "送客户去林萃小区办事,请报销乘车费用"
assert "客户单位" not in result["message"]
assert "票据附件" not in result["message"]
assert "费用明细" not in result["message"]
def test_upsert_draft_from_ontology_supports_link_or_create_for_multi_documents() -> None:
user_id = "lisi@example.com"
with build_session() as db:
employee = Employee(
employee_no="E5002",
name="李四",
email=user_id,
)
db.add(employee)
db.flush()
existing_claim = ExpenseClaim(
claim_no="EXP-202605-011",
employee_id=employee.id,
employee_name="李四",
department_name="销售部",
project_code=None,
expense_type="transport",
reason="原有交通报销",
location="上海",
amount=Decimal("20.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
existing_claim.items = [
ExpenseClaimItem(
claim_id=existing_claim.id,
item_date=date(2026, 5, 13),
item_type="transport",
item_reason="原有交通报销",
item_location="上海",
item_amount=Decimal("20.00"),
invoice_id="existing.png",
)
]
db.add(existing_claim)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我上传了两张交通票据,帮我生成报销草稿",
user_id=user_id,
)
)
service = ExpenseClaimService(db)
context_json = {
"name": "李四",
"attachment_names": ["didi-trip.png", "parking-ticket.jpg"],
"attachment_count": 2,
"draft_claim_id": existing_claim.id,
"ocr_documents": [
{
"filename": "didi-trip.png",
"summary": "滴滴出行",
"text": "滴滴出行 支付金额 32.50 元",
"document_type": "taxi_receipt",
"scene_code": "transport",
"document_fields": [{"key": "amount", "label": "支付金额", "value": "32.50"}],
},
{
"filename": "parking-ticket.jpg",
"summary": "停车票",
"text": "停车费 合计 18 元",
"document_type": "parking_toll_receipt",
"scene_code": "transport",
"document_fields": [{"key": "total_amount", "label": "合计金额", "value": "18"}],
},
],
}
link_result = service.upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message="把这两张票据关联到已有草稿",
ontology=ontology,
context_json={
**context_json,
"review_action": "link_to_existing_draft",
},
)
db.refresh(existing_claim)
assert link_result["claim_id"] == existing_claim.id
assert existing_claim.invoice_count == 3
assert len(existing_claim.items) == 3
assert float(existing_claim.amount) == 70.5
create_result = service.upsert_draft_from_ontology(
run_id=f"{ontology.run_id}-new",
user_id=user_id,
message="单独新建一张报销单",
ontology=ontology,
context_json={
**context_json,
"review_action": "create_new_claim_from_documents",
},
)
assert create_result["claim_id"] != existing_claim.id
new_claim = db.get(ExpenseClaim, create_result["claim_id"])
assert new_claim is not None
assert new_claim.invoice_count == 2
assert len(new_claim.items) == 2
assert float(new_claim.amount) == 50.5
def test_link_existing_draft_blocks_duplicate_uploaded_invoice() -> None:
user_id = "duplicate@example.com"
with build_session() as db:
employee = Employee(
employee_no="E5010",
name="重复票据员工",
email=user_id,
)
db.add(employee)
db.flush()
existing_claim = ExpenseClaim(
claim_no="EXP-202605-021",
employee_id=employee.id,
employee_name="重复票据员工",
department_name="销售部",
project_code=None,
expense_type="transport",
reason="原有交通报销",
location="上海",
amount=Decimal("32.50"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
existing_claim.items = [
ExpenseClaimItem(
claim_id=existing_claim.id,
item_date=date(2026, 5, 13),
item_type="transport",
item_reason="原有交通报销",
item_location="上海",
item_amount=Decimal("32.50"),
invoice_id="didi-trip.png",
)
]
db.add(existing_claim)
db.commit()
context_json = {
"name": "重复票据员工",
"review_action": "link_to_existing_draft",
"draft_claim_id": existing_claim.id,
"attachment_names": ["didi-trip.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "didi-trip.png",
"summary": "滴滴出行 支付金额 32.50 元",
"text": "滴滴出行 支付金额 32.50 元",
"document_type": "taxi_receipt",
"scene_code": "transport",
"document_fields": [{"key": "amount", "label": "支付金额", "value": "32.50"}],
}
],
}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="把这张票据关联到已有草稿",
user_id=user_id,
context_json=context_json,
)
)
result = ExpenseClaimService(db).upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message="把这张票据关联到已有草稿",
ontology=ontology,
context_json=context_json,
)
db.refresh(existing_claim)
assert result["duplicate_attachment_blocked"] is True
assert result["submission_blocked"] is True
assert "重复" in result["message"]
assert "重新上传不同的票据" in result["message"]
assert len(existing_claim.items) == 1
assert existing_claim.invoice_count == 1
assert float(existing_claim.amount) == 32.5
def test_upsert_travel_draft_uses_ticket_item_types_and_auto_allowance() -> None:
user_id = "travel-allowance@example.com"
with build_session() as db:
employee = Employee(
employee_no="E5010",
name="差旅员工",
email=user_id,
grade="P4",
)
db.add(employee)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我去北京出差 3 天,上传了火车票,帮我生成差旅费报销草稿",
user_id=user_id,
)
)
result = ExpenseClaimService(db).upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message="我去北京出差 3 天,上传了火车票,帮我生成差旅费报销草稿",
ontology=ontology,
context_json={
"name": "差旅员工",
"grade": "P4",
"attachment_names": ["train-ticket.png"],
"attachment_count": 1,
"review_form_values": {
"expense_type": "差旅费",
"location": "北京",
"time_range": "2026-05-13 至 2026-05-15",
},
"business_time_context": {
"mode": "range",
"start_date": "2026-05-13",
"end_date": "2026-05-15",
"display_value": "2026-05-13 至 2026-05-15",
},
"ocr_documents": [
{
"filename": "train-ticket.png",
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅费",
"summary": "中国铁路电子客票 广州南-北京南 二等座 票价 354 元",
"text": "中国铁路电子客票 广州南-北京南 二等座 票价:¥354.00",
"document_fields": [
{"key": "amount", "label": "票价", "value": "¥354.00"},
{"key": "route", "label": "行程", "value": "广州南-北京南"},
],
}
],
},
)
claim = db.get(ExpenseClaim, result["claim_id"])
assert claim is not None
assert claim.expense_type == "travel"
assert claim.invoice_count == 1
assert len(claim.items) == 2
train_item = next(item for item in claim.items if item.item_type == "train_ticket")
allowance_item = next(item for item in claim.items if item.item_type == "travel_allowance")
assert train_item.item_amount == Decimal("354.00")
assert train_item.item_reason == "广州南-北京南"
assert allowance_item.item_amount == Decimal("300.00")
assert allowance_item.invoice_id is None
assert allowance_item.is_system_generated is True
assert claim.amount == Decimal("654.00")
with pytest.raises(ValueError, match="系统自动计算"):
ExpenseClaimService(db).update_claim_item(
claim_id=claim.id,
item_id=allowance_item.id,
payload=ExpenseClaimItemUpdate(item_amount=Decimal("1.00")),
current_user=CurrentUserContext(
username=user_id,
name="差旅员工",
role_codes=[],
is_admin=False,
),
)
def test_upsert_travel_draft_uses_explicit_text_days_for_allowance() -> None:
user_id = "travel-explicit-days@example.com"
message = "业务发生时间:2026-05-20 至 2026-05-23去上海支撑上海电力服务器部署出差3天申请差旅费报销"
with build_session() as db:
employee = Employee(
employee_no="E5012",
name="文本差旅员工",
email=user_id,
grade="P4",
)
db.add(employee)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id=user_id,
context_json={"name": "文本差旅员工", "grade": "P4"},
)
)
result = ExpenseClaimService(db).upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message=message,
ontology=ontology,
context_json={
"name": "文本差旅员工",
"grade": "P4",
"user_input_text": message,
"review_form_values": {
"expense_type": "差旅费",
"business_location": "上海",
"reason": "去上海支撑上海电力服务器部署出差3天",
"time_range": "2026-05-20 至 2026-05-23",
"business_time": "2026-05-20 至 2026-05-23",
},
"business_time_context": {
"mode": "range",
"start_date": "2026-05-20",
"end_date": "2026-05-23",
"display_value": "2026-05-20 至 2026-05-23",
},
},
)
claim = db.get(ExpenseClaim, result["claim_id"])
assert claim is not None
assert claim.expense_type == "travel"
allowance_item = next(item for item in claim.items if item.item_type == "travel_allowance")
assert allowance_item.item_amount == Decimal("300.00")
assert "3天" in allowance_item.item_reason
assert allowance_item.item_date == date(2026, 5, 22)
assert claim.amount == Decimal("300.00")
def test_sync_travel_claim_adds_allowance_from_manual_ticket_dates() -> None:
with build_session() as db:
employee = Employee(
employee_no="E5011",
name="手工差旅员工",
email="manual-travel-allowance@example.com",
grade="P4",
)
db.add(employee)
db.flush()
claim = build_claim(expense_type="travel", location="北京")
claim.employee_id = employee.id
claim.employee_name = employee.name
claim.items[0].item_date = date(2026, 5, 13)
claim.items[0].item_type = "train_ticket"
claim.items[0].item_reason = "广州南-北京南"
claim.items[0].item_location = "北京"
claim.items[0].item_amount = Decimal("354.00")
claim.items.append(
ExpenseClaimItem(
claim_id=claim.id,
item_date=date(2026, 5, 15),
item_type="train_ticket",
item_reason="北京南-广州南",
item_location="北京",
item_amount=Decimal("354.00"),
invoice_id="return-train.png",
)
)
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
service._sync_claim_from_items(claim)
db.commit()
db.refresh(claim)
allowance_item = next(item for item in claim.items if item.item_type == "travel_allowance")
assert allowance_item.item_amount == Decimal("300.00")
assert "3天" in allowance_item.item_reason
assert allowance_item.invoice_id is None
assert claim.amount == Decimal("1008.00")
def test_update_claim_item_allows_placeholder_date_reason_and_amount() -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
role_codes=[],
is_admin=False,
)
with build_session() as db:
claim = build_claim(expense_type="office", location="深圳")
db.add(claim)
db.commit()
updated = ExpenseClaimService(db).update_claim_item(
claim_id=claim.id,
item_id=claim.items[0].id,
payload=ExpenseClaimItemUpdate(
item_reason="",
item_location="",
item_amount=Decimal("0.00"),
),
current_user=current_user,
)
assert updated is not None
db.refresh(claim)
assert claim.items[0].item_date == date(2026, 5, 13)
assert claim.items[0].item_reason == ""
assert claim.items[0].item_location == ""
assert claim.items[0].item_amount == Decimal("0.00")
def test_upsert_draft_from_ontology_updates_returned_claim_and_preserves_return_events() -> None:
user_id = "returned-owner@example.com"
return_flag = {
"source": "manual_return",
"return_event_id": "return-event-1",
"message": "第一次退回:附件缺失。",
"reason": "附件缺失。",
"return_count": 1,
"return_stage": "直属领导审批",
"return_stage_key": "direct_manager",
"risk_points": ["附件缺失或不清晰"],
}
with build_session() as db:
employee = Employee(
employee_no="E5004",
name="赵六",
email=user_id,
)
db.add(employee)
db.flush()
existing_claim = ExpenseClaim(
claim_no="EXP-202605-012",
employee_id=employee.id,
employee_name="赵六",
department_name="市场部",
project_code=None,
expense_type="transport",
reason="原有交通报销",
location="上海",
amount=Decimal("20.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
status="returned",
approval_stage="待提交",
risk_flags_json=[return_flag],
)
existing_claim.items = [
ExpenseClaimItem(
claim_id=existing_claim.id,
item_date=date(2026, 5, 13),
item_type="transport",
item_reason="原有交通报销",
item_location="上海",
item_amount=Decimal("20.00"),
invoice_id="old-trip.png",
)
]
db.add(existing_claim)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我补充了交通票据,更新这张退回单据",
user_id=user_id,
)
)
ontology.risk_flags = ["系统识别:票据金额待人工核对。"]
result = ExpenseClaimService(db).upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message="我补充了交通票据,更新这张退回单据",
ontology=ontology,
context_json={
"name": "赵六",
"draft_claim_id": existing_claim.id,
"attachment_names": ["new-trip.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "new-trip.png",
"summary": "滴滴出行 支付金额 32 元",
"text": "滴滴出行 支付金额 32 元",
"document_type": "taxi_receipt",
"scene_code": "transport",
}
],
},
)
db.refresh(existing_claim)
assert result["claim_id"] == existing_claim.id
assert existing_claim.status == "draft"
assert "系统识别:票据金额待人工核对。" in existing_claim.risk_flags_json
manual_returns = [
flag
for flag in list(existing_claim.risk_flags_json or [])
if isinstance(flag, dict) and flag.get("source") == "manual_return"
]
assert manual_returns == [return_flag]
def test_generate_claim_no_uses_re_prefix_timestamp_and_random_suffix() -> None:
with build_session() as db:
db.add_all(
[
ExpenseClaim(
claim_no="EXP-202605-001",
employee_name="张三",
department_name="市场部",
project_code=None,
expense_type="transport",
reason="交通报销",
location="深圳",
amount=Decimal("10.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 10, tzinfo=UTC),
status="draft",
approval_stage="待提交",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-202605-003",
employee_name="李四",
department_name="销售部",
project_code=None,
expense_type="transport",
reason="交通报销",
location="上海",
amount=Decimal("20.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, tzinfo=UTC),
status="submitted",
approval_stage="审批中",
risk_flags_json=[],
),
]
)
db.commit()
service = ExpenseClaimService(db)
assert re.fullmatch(
r"RE-\d{14}-[A-HJ-NP-Z2-9]{8}",
service._generate_claim_no(datetime(2026, 5, 14, tzinfo=UTC)),
)
def test_upsert_draft_from_ontology_retries_claim_no_conflict() -> None:
user_id = "zhaoliu-claimno@example.com"
with build_session() as db:
employee = Employee(
employee_no="E5006",
name="赵六",
email=user_id,
)
db.add(employee)
db.flush()
db.add(
ExpenseClaim(
claim_no="RE-20260525101010-ABCDEFGH",
employee_name="历史单据",
department_name="财务部",
project_code=None,
expense_type="other",
reason="历史草稿",
location="北京",
amount=Decimal("0.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 12, tzinfo=UTC),
status="submitted",
approval_stage="审批中",
risk_flags_json=[],
)
)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="帮我生成报销草稿,我昨天交通费 13.4 元",
user_id=user_id,
)
)
service = ExpenseClaimService(db)
generated_claim_nos = iter(
["RE-20260525101010-ABCDEFGH", "RE-20260525101010-HGFEDCBA"]
)
service._generate_claim_no = lambda occurred_at: next(generated_claim_nos)
result = service.upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message="帮我生成报销草稿,我昨天交通费 13.4 元",
ontology=ontology,
context_json={
"name": "赵六",
"user_input_text": "帮我生成报销草稿,我昨天交通费 13.4 元",
},
)
created_claim = db.get(ExpenseClaim, result["claim_id"])
assert created_claim is not None
assert created_claim.claim_no == "RE-20260525101010-HGFEDCBA"
assert result["claim_no"] == "RE-20260525101010-HGFEDCBA"
def test_create_claim_item_adds_blank_draft_row_without_forcing_attachment() -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
role_codes=[],
is_admin=False,
)
with build_session() as db:
claim = build_claim(expense_type="office", location="深圳南山")
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
updated = service.create_claim_item(
claim_id=claim.id,
payload=ExpenseClaimItemCreate(),
current_user=current_user,
)
assert updated is not None
assert len(updated.items) == 2
assert updated.amount == Decimal("88.00")
assert updated.invoice_count == 1
new_item = next(item for item in updated.items if item.id != "item-1")
assert new_item.item_type == "office"
assert new_item.item_reason == ""
assert new_item.item_location == ""
assert new_item.item_amount == Decimal("0.00")
assert new_item.invoice_id is None
def test_update_claim_reason_only_allows_draft_pending_submission() -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
role_codes=[],
is_admin=False,
)
with build_session() as db:
claim = build_claim(expense_type="travel", location="北京")
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
updated = service.update_claim(
claim_id=claim.id,
payload=ExpenseClaimUpdate(reason="去北京客户现场出差,处理项目验收事项"),
current_user=current_user,
)
assert updated is not None
assert updated.reason == "去北京客户现场出差,处理项目验收事项"
claim.status = "submitted"
claim.submitted_at = datetime(2026, 5, 14, tzinfo=UTC)
claim.approval_stage = "直属领导审批"
db.commit()
with pytest.raises(ValueError, match="草稿待提交"):
service.update_claim(
claim_id=claim.id,
payload=ExpenseClaimUpdate(reason="提交后不能改"),
current_user=current_user,
)
def test_update_claim_item_reanalyzes_existing_attachment(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
role_codes=[],
is_admin=False,
)
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="office-note.png",
media_type="image/png",
text="办公用品发票 金额88元 2026-05-13",
summary="识别到办公用品发票,金额 88 元。",
avg_score=0.98,
line_count=1,
page_count=1,
warnings=[],
)
],
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
with build_session() as db:
claim = build_claim(expense_type="office", location="深圳南山")
claim.invoice_count = 0
claim.items[0].invoice_id = None
claim.items[0].item_reason = "办公用品采购"
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
service.upload_claim_item_attachment(
claim_id=claim.id,
item_id=claim.items[0].id,
filename="office-note.png",
content=b"fake-image-bytes",
media_type="image/png",
current_user=current_user,
)
uploaded_meta = service.get_claim_item_attachment_meta(
claim_id=claim.id,
item_id=claim.items[0].id,
current_user=current_user,
)
assert uploaded_meta is not None
assert uploaded_meta["preview_kind"] == "image"
assert uploaded_meta["preview_url"].endswith(
f"/reimbursements/claims/{claim.id}/items/{claim.items[0].id}/attachment/preview"
)
assert uploaded_meta["analysis"]["severity"] == "pass"
assert uploaded_meta["document_info"]["document_type"] == "office_invoice"
assert uploaded_meta["requirement_check"]["matches"] is True
updated = service.update_claim_item(
claim_id=claim.id,
item_id=claim.items[0].id,
payload=ExpenseClaimItemUpdate(
item_type="transport",
item_reason="打车报销",
),
current_user=current_user,
)
assert updated is not None
assert any(flag.get("source") == "attachment_analysis" for flag in updated.risk_flags_json)
refreshed_meta = service.get_claim_item_attachment_meta(
claim_id=claim.id,
item_id=claim.items[0].id,
current_user=current_user,
)
assert refreshed_meta is not None
assert refreshed_meta["analysis"]["severity"] == "high"
assert refreshed_meta["requirement_check"]["matches"] is False
assert any("附件类型要求" in point for point in refreshed_meta["analysis"]["points"])
def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
role_codes=[],
is_admin=False,
)
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="train-ticket.png",
media_type="image/png",
text="中国铁路电子客票 广州南-北京南 二等座 2026-02-20 08:30开 票价:¥354.00",
summary="铁路电子客票2026-02-20 08:30 广州南至北京南,票价 354 元。",
avg_score=0.98,
line_count=1,
page_count=1,
document_type="train_ticket",
document_type_label="火车/高铁票",
scene_code="travel",
scene_label="差旅费",
document_fields=[
{"key": "invoice_date", "label": "开票日期", "value": "2026-02-18"},
{"key": "trip_date", "label": "行程日期", "value": "2026-02-20 08:30"},
{"key": "fare", "label": "票价", "value": "¥354.00"},
],
)
],
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
with build_session() as db:
claim = build_claim(expense_type="travel", location="北京")
claim.amount = Decimal("0.00")
claim.invoice_count = 0
claim.items[0].item_amount = Decimal("0.00")
claim.items[0].invoice_id = None
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
updated = service.upload_claim_item_attachment(
claim_id=claim.id,
item_id=claim.items[0].id,
filename="train-ticket.png",
content=b"fake-image-bytes",
media_type="image/png",
current_user=current_user,
)
assert updated is not None
assert updated["item_amount"] == Decimal("354.00")
assert updated["item_date"] == "2026-02-20"
assert updated["item_type"] == "train_ticket"
assert updated["item_reason"] == "广州南-北京南"
assert updated["claim_amount"] == Decimal("354.00")
db.refresh(claim)
assert claim.items[0].item_amount == Decimal("354.00")
assert claim.items[0].item_type == "train_ticket"
assert claim.items[0].item_date == date(2026, 2, 20)
assert claim.items[0].item_reason == "广州南-北京南"
assert claim.amount == Decimal("354.00")
uploaded_meta = service.get_claim_item_attachment_meta(
claim_id=claim.id,
item_id=claim.items[0].id,
current_user=current_user,
)
assert uploaded_meta is not None
assert uploaded_meta["document_info"]["document_type"] == "train_ticket"
assert any(
field["label"] == "列车出发时间" and field["value"] == "2026-02-20 08:30"
for field in uploaded_meta["document_info"]["fields"]
)
assert any(
field["label"] == "开票日期" and field["value"] == "2026-02-18"
for field in uploaded_meta["document_info"]["fields"]
)
assert any(
field["label"] == "票价" and field["value"] == "¥354.00"
for field in uploaded_meta["document_info"]["fields"]
)
assert not any("用途字段" in point for point in uploaded_meta["analysis"]["points"])
def test_upload_attachment_response_includes_refreshed_rule_center_risk_flags(
monkeypatch,
tmp_path,
) -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
role_codes=[],
is_admin=False,
)
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="train-ticket.png",
media_type="image/png",
text="中国铁路电子客票 武汉-上海 2026-02-20 票价354元",
summary="铁路电子客票,武汉至上海,票价 354 元。",
avg_score=0.98,
line_count=1,
page_count=1,
document_type="train_ticket",
document_type_label="火车/高铁票",
scene_code="travel",
scene_label="差旅费",
document_fields=[
{"key": "route", "label": "行程", "value": "武汉-上海"},
{"key": "trip_date", "label": "行程日期", "value": "2026-02-20"},
{"key": "fare", "label": "票价", "value": "354元"},
],
)
],
)
def fake_evaluate_platform_risk_rules(self, claim, **kwargs):
assert kwargs.get("business_stage") == "reimbursement"
return {
"flags": [
{
"source": "submission_review",
"hit_source": "rule_center",
"rule_type": "risk",
"rule_code": "risk.test.upload_preview",
"severity": "high",
"message": "测试规则命中",
"business_stage": "reimbursement",
"risk_domain": "invoice",
"visibility_scope": "submitter",
"actionability": "fixable_by_submitter",
}
],
"blocking_reasons": ["测试规则命中"],
}
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
monkeypatch.setattr(
ExpenseClaimService,
"evaluate_platform_risk_rules",
fake_evaluate_platform_risk_rules,
)
with build_session() as db:
claim = build_claim(expense_type="travel", location="北京")
claim.items[0].invoice_id = None
db.add(claim)
db.commit()
payload = ExpenseClaimService(db).upload_claim_item_attachment(
claim_id=claim.id,
item_id=claim.items[0].id,
filename="train-ticket.png",
content=b"fake-image-bytes",
media_type="image/png",
current_user=current_user,
)
assert payload is not None
assert any(
flag.get("rule_code") == "risk.test.upload_preview"
for flag in payload["claim_risk_flags"]
)
db.refresh(claim)
assert payload["claim_risk_flags"] == claim.risk_flags_json
def test_upload_hotel_attachment_audits_date_like_amount(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
role_codes=[],
is_admin=False,
)
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="hotel-invoice.png",
media_type="image/png",
text="北京中心酒店 总费用是828元 入住日期 2026-02-20 离店日期 2026-02-21",
summary="酒店住宿票据,住宿总费用 828 元。",
avg_score=0.96,
line_count=1,
page_count=1,
document_type="hotel_invoice",
document_type_label="酒店住宿票据",
scene_code="hotel",
scene_label="住宿票据",
document_fields=[
{"key": "amount", "label": "金额", "value": "2026元"},
{"key": "hotel_name", "label": "酒店", "value": "北京中心酒店"},
{"key": "check_in", "label": "入住日期", "value": "2026-02-20"},
{"key": "check_out", "label": "离店日期", "value": "2026-02-21"},
],
)
],
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
with build_session() as db:
claim = build_claim(expense_type="hotel", location="北京")
claim.amount = Decimal("0.00")
claim.invoice_count = 0
claim.items[0].item_type = "hotel"
claim.items[0].item_reason = ""
claim.items[0].item_amount = Decimal("0.00")
claim.items[0].invoice_id = None
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
updated = service.upload_claim_item_attachment(
claim_id=claim.id,
item_id=claim.items[0].id,
filename="hotel-invoice.png",
content=b"fake-image-bytes",
media_type="image/png",
current_user=current_user,
)
assert updated is not None
assert updated["item_type"] == "hotel_ticket"
assert updated["item_amount"] == Decimal("828.00")
assert updated["claim_amount"] == Decimal("828.00")
db.refresh(claim)
assert claim.items[0].item_amount == Decimal("828.00")
assert claim.amount == Decimal("828.00")
uploaded_meta = service.get_claim_item_attachment_meta(
claim_id=claim.id,
item_id=claim.items[0].id,
current_user=current_user,
)
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"])
def test_upload_hotel_attachment_flags_amount_over_travel_policy(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-hotel-risk@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="hotel-risk.png",
media_type="image/png",
text="北京全季酒店 住宿 1晚 金额800元 2026-05-13",
summary="北京全季酒店住宿发票,住宿 1 晚,金额 800 元。",
avg_score=0.98,
line_count=1,
page_count=1,
document_type="hotel_invoice",
document_type_label="酒店住宿票据",
scene_code="hotel",
scene_label="住宿票据",
document_fields=[
{"key": "merchant_name", "label": "商户", "value": "北京全季酒店"},
{"key": "amount", "label": "金额", "value": "800元"},
{"key": "date", "label": "日期", "value": "2026-05-13"},
],
)
],
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
with build_session() as db:
employee = Employee(
employee_no="E7401",
name="张三",
email="emp-hotel-risk@example.com",
grade="P4",
)
db.add(employee)
db.flush()
claim = build_claim(expense_type="travel", location="北京")
claim.employee = employee
claim.employee_id = employee.id
claim.reason = "北京客户现场出差"
claim.amount = Decimal("0.00")
claim.invoice_count = 0
claim.items[0].item_type = "hotel"
claim.items[0].item_reason = "北京住宿"
claim.items[0].item_location = "北京"
claim.items[0].item_amount = Decimal("0.00")
claim.items[0].invoice_id = None
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
updated = service.upload_claim_item_attachment(
claim_id=claim.id,
item_id=claim.items[0].id,
filename="hotel-risk.png",
content=b"fake-image-bytes",
media_type="image/png",
current_user=current_user,
)
assert updated is not None
uploaded_meta = service.get_claim_item_attachment_meta(
claim_id=claim.id,
item_id=claim.items[0].id,
current_user=current_user,
)
assert uploaded_meta is not None
analysis = uploaded_meta["analysis"]
assert analysis["severity"] == "high"
assert analysis["headline"] == "AI提示住宿金额超出报销标准"
assert "保留在单据中" in analysis["summary"]
assert "特殊情况" in analysis["summary"]
assert any("住宿标准" in point and "800.00 元" in point for point in analysis["points"])
assert any("住宿费按员工职级" in basis for basis in analysis["rule_basis"])
db.refresh(claim)
hotel_item = next(item for item in claim.items if str(item.invoice_id or "").strip())
assert hotel_item.item_amount == Decimal("800.00")
assert claim.invoice_count == 1
assert any(
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "attachment_analysis"
and flag.get("item_id") == hotel_item.id
and str(flag.get("severity") or "").strip() == "high"
for flag in list(claim.risk_flags_json or [])
)
def test_attachment_analysis_does_not_compare_business_purpose_with_ticket_scene() -> None:
with build_session() as db:
claim = build_claim(expense_type="travel", location="上海")
claim.items[0].item_type = "train_ticket"
claim.items[0].item_reason = "2026-02-20 至 2026-02-23支撑上海电力项目部署"
claim.items[0].item_amount = Decimal("354.00")
db.add(claim)
db.commit()
document = OcrRecognizeDocumentRead(
filename="train-ticket.png",
media_type="image/png",
text="中国铁路电子客票 上海虹桥-武汉 二等座 2026-02-20 票价:¥354.00",
summary="铁路电子客票,上海虹桥至武汉,票价 354 元。",
avg_score=0.98,
line_count=1,
page_count=1,
document_type="train_ticket",
document_type_label="火车/高铁票",
scene_code="travel",
scene_label="差旅票据",
document_fields=[
{"key": "amount", "label": "票价", "value": "¥354.00"},
{"key": "date", "label": "日期", "value": "2026-02-20"},
{"key": "route", "label": "行程", "value": "上海虹桥-武汉"},
],
)
analysis = ExpenseClaimService(db)._build_attachment_analysis(
document=document,
item=claim.items[0],
)
assert analysis["severity"] == "medium"
assert not any("用途字段" in point for point in analysis["points"])
assert any("行程说明" in point and "起始地-目的地" in point for point in analysis["points"])
def test_attachment_risk_flag_message_uses_specific_points(monkeypatch, tmp_path) -> None:
with build_session() as db:
claim = build_claim(expense_type="travel", location="上海")
claim.items[0].invoice_id = "invoice.png"
db.add(claim)
db.commit()
generic_summary = "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。"
file_path = tmp_path / "invoice.png"
file_path.write_bytes(b"fake")
service = ExpenseClaimService(db)
monkeypatch.setattr(
ExpenseClaimAttachmentStorage,
"resolve_path",
lambda self, storage_key: file_path,
)
monkeypatch.setattr(
service._attachment_storage,
"read_meta",
lambda path: {
"analysis": {
"severity": "medium",
"label": "中风险",
"summary": generic_summary,
"points": [
"日期字段:未识别到开票日期或业务发生日期。",
"金额字段:附件识别金额 300.00 元与报销金额 88.00 元不一致。",
],
}
},
)
flags = service._build_claim_attachment_risk_flags([claim.items[0]])
assert len(flags) == 1
assert "日期字段:未识别到开票日期或业务发生日期。" in flags[0]["message"]
assert "当前附件可见部分内容" not in flags[0]["message"]
assert flags[0]["summary"] == generic_summary
assert flags[0]["points"] == [
"日期字段:未识别到开票日期或业务发生日期。",
"金额字段:附件识别金额 300.00 元与报销金额 88.00 元不一致。",
]
def test_upload_ride_receipt_backfills_item_reason_from_addresses(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
role_codes=[],
is_admin=False,
)
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="ride-receipt.png",
media_type="image/png",
text="滴滴出行订单 起点:深圳北站 终点:腾讯滨海大厦 实付金额42.00元",
summary="滴滴出行乘车票据,深圳北站到腾讯滨海大厦,金额 42 元。",
avg_score=0.98,
line_count=1,
page_count=1,
document_type="taxi_receipt",
document_type_label="出租车/网约车票据",
scene_code="transport",
scene_label="交通票据",
document_fields=[
{"key": "start_location", "label": "起点", "value": "深圳北站"},
{"key": "end_location", "label": "终点", "value": "腾讯滨海大厦"},
{"key": "amount", "label": "实付金额", "value": "42.00元"},
],
)
],
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
with build_session() as db:
claim = build_claim(expense_type="transport", location="深圳")
claim.amount = Decimal("0.00")
claim.invoice_count = 0
claim.items[0].item_type = "transport"
claim.items[0].item_reason = "打车报销"
claim.items[0].item_amount = Decimal("0.00")
claim.items[0].invoice_id = None
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
updated = service.upload_claim_item_attachment(
claim_id=claim.id,
item_id=claim.items[0].id,
filename="ride-receipt.png",
content=b"fake-image-bytes",
media_type="image/png",
current_user=current_user,
)
assert updated is not None
db.refresh(claim)
assert claim.items[0].item_type == "ride_ticket"
assert claim.items[0].item_reason == "深圳北站-腾讯滨海大厦"
assert claim.items[0].item_amount == Decimal("42.00")
assert claim.amount == Decimal("42.00")
def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
role_codes=[],
is_admin=False,
)
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="office-note.png",
media_type="image/png",
text="办公用品发票 金额88元 2026-05-13",
summary="识别到办公用品发票,金额 88 元。",
avg_score=0.98,
line_count=1,
page_count=1,
warnings=[],
)
],
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
with build_session() as db:
claim = build_claim(expense_type="office", location="深圳南山")
claim.invoice_count = 0
claim.items[0].invoice_id = None
claim.items[0].item_reason = "办公用品采购"
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
upload_payload = service.upload_claim_item_attachment(
claim_id=claim.id,
item_id=claim.items[0].id,
filename="office-note.png",
content=b"fake-image-bytes",
media_type="image/png",
current_user=current_user,
)
assert upload_payload is not None
attachment_root = tmp_path / claim.id / claim.items[0].id
assert attachment_root.exists()
delete_payload = service.delete_claim_item(
claim_id=claim.id,
item_id=claim.items[0].id,
current_user=current_user,
)
assert delete_payload is not None
assert delete_payload["claim_id"] == claim.id
refreshed_claim = service.get_claim(claim.id, current_user)
assert refreshed_claim is not None
assert refreshed_claim.items == []
assert refreshed_claim.amount == Decimal("0.00")
assert refreshed_claim.invoice_count == 0
assert not attachment_root.exists()
def test_delete_claim_removes_all_claim_attachment_files(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="深圳南山")
attachment_dir = tmp_path / claim.id / claim.items[0].id
attachment_dir.mkdir(parents=True)
attachment_path = attachment_dir / "office-note.png"
attachment_path.write_bytes(b"fake-image-bytes")
(attachment_dir / "office-note.png.meta.json").write_text("{}", encoding="utf-8")
orphan_path = tmp_path / claim.id / "orphan-preview.png"
orphan_path.write_bytes(b"orphan-preview")
claim.items[0].invoice_id = f"{claim.id}/{claim.items[0].id}/office-note.png"
db.add(claim)
db.commit()
conversation = AgentConversationService(db).get_or_create_conversation(
conversation_id=None,
user_id=current_user.username,
source="user_message",
context_json={
"session_type": "expense",
"draft_claim_id": claim.id,
},
)
claim_id = claim.id
claim_root = tmp_path / claim.id
deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)
assert deleted is not None
assert db.get(ExpenseClaim, claim_id) is None
assert not claim_root.exists()
assert AgentConversationService(db).get_conversation(conversation.conversation_id) is None
def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(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="transport", location="上海")
claim.items[0].invoice_id = "legacy-ticket.pdf"
db.add(claim)
db.commit()
attachment_dir = tmp_path / claim.id / claim.items[0].id
attachment_dir.mkdir(parents=True)
file_path = attachment_dir / "legacy-ticket.pdf"
file_path.write_bytes(b"legacy-pdf-bytes")
(attachment_dir / "legacy-ticket.pdf.meta.json").write_text(
'{"file_name":"legacy-ticket.pdf","media_type":"application/pdf","previewable":true}',
encoding="utf-8",
)
payload = ExpenseClaimService(db).get_claim_item_attachment_preview_content(
claim_id=claim.id,
item_id=claim.items[0].id,
current_user=current_user,
)
assert payload is not None
resolved_path, media_type, filename = payload
assert resolved_path == file_path
assert media_type == "application/pdf"
assert filename == "legacy-ticket.pdf"
def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
current_user = CurrentUserContext(
username="emp-submit@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E7000",
name="李经理",
email="manager@example.com",
)
employee = Employee(
employee_no="E7001",
name="张三",
email="emp-submit@example.com",
manager=manager,
)
claim = build_claim(expense_type="transport", location="上海")
claim.employee = employee
claim.employee_id = employee.id
claim.items[0].invoice_id = "taxi-ticket.png"
db.add_all([manager, employee, claim])
db.commit()
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.approval_stage == "直属领导审批"
assert submitted.submitted_at is not None
def test_pre_review_claim_records_ai_result_without_submitting() -> None:
current_user = CurrentUserContext(
username="emp-pre-review@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E7050",
name="李经理",
email="manager-pre-review@example.com",
)
employee = Employee(
employee_no="E7051",
name="张三",
email="emp-pre-review@example.com",
manager=manager,
)
claim = build_claim(expense_type="transport", location="上海")
claim.employee = employee
claim.employee_id = employee.id
claim.items[0].invoice_id = "taxi-ticket.png"
claim.risk_flags_json = [
{
"source": "manual_risk",
"severity": "high",
"label": "票据风险",
"message": "票据金额与行程不匹配。",
}
]
db.add_all([manager, employee, claim])
db.commit()
reviewed = ExpenseClaimService(db).pre_review_claim(claim.id, current_user)
assert reviewed is not None
assert reviewed.status == "draft"
assert reviewed.approval_stage == "AI预审"
assert reviewed.submitted_at is None
pre_review_flag = next(
flag
for flag in reviewed.risk_flags_json
if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
)
assert pre_review_flag["status"] == "failed"
assert pre_review_flag["next_action"] == "risk_explanation_required"
def test_submit_claim_allows_returned_claim_to_be_resubmitted() -> None:
current_user = CurrentUserContext(
username="emp-submit@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E7100",
name="李经理",
email="manager-returned@example.com",
)
employee = Employee(
employee_no="E7101",
name="张三",
email="emp-submit@example.com",
manager=manager,
)
claim = build_claim(expense_type="transport", location="上海")
claim.employee = employee
claim.employee_id = employee.id
claim.status = "returned"
claim.approval_stage = "待补充"
claim.items[0].invoice_id = "taxi-ticket.png"
db.add_all([manager, employee, claim])
db.commit()
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.approval_stage == "直属领导审批"
assert submitted.submitted_at is not None
def test_submit_claim_backfills_department_from_current_employee() -> None:
current_user = CurrentUserContext(
username="emp-dept@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
with build_session() as db:
department = OrganizationUnit(
unit_code="D7200",
name="销售部",
)
manager = Employee(
employee_no="E7200",
name="李经理",
email="manager-dept@example.com",
)
employee = Employee(
employee_no="E7201",
name="张三",
email="emp-dept@example.com",
organization_unit=department,
manager=manager,
)
claim = build_claim(expense_type="transport", location="待补充")
claim.employee = None
claim.employee_id = None
claim.employee_name = "张三"
claim.department_id = None
claim.department_name = "待补充"
claim.items[0].item_location = "待补充"
db.add_all([department, manager, employee, claim])
db.commit()
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.department_id == department.id
assert submitted.department_name == "销售部"
assert submitted.approval_stage == "直属领导审批"
def test_submit_claim_routes_high_risk_attachment_to_approval_with_review_flag(
monkeypatch,
tmp_path,
) -> None:
current_user = CurrentUserContext(
username="emp-risk@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="taxi-note.png",
media_type="image/png",
text="滴滴出行电子发票 金额120元 2026-05-13",
summary="识别到交通出行发票,金额 120 元。",
avg_score=0.97,
line_count=1,
page_count=1,
warnings=[],
)
],
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
with build_session() as db:
manager = Employee(
employee_no="E7100",
name="李经理",
email="manager2@example.com",
)
employee = Employee(
employee_no="E7101",
name="张三",
email="emp-risk@example.com",
manager=manager,
)
claim = build_claim(expense_type="office", location="深圳南山")
claim.employee = employee
claim.employee_id = employee.id
claim.invoice_count = 0
claim.items[0].invoice_id = None
claim.items[0].item_reason = "办公用品采购"
db.add_all([manager, employee, claim])
db.commit()
service = ExpenseClaimService(db)
service.upload_claim_item_attachment(
claim_id=claim.id,
item_id=claim.items[0].id,
filename="taxi-note.png",
content=b"fake-image-bytes",
media_type="image/png",
current_user=current_user,
)
submitted = service.submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.approval_stage == "直属领导审批"
assert submitted.submitted_at is not None
assert any(
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "submission_review"
for flag in list(submitted.risk_flags_json or [])
)
def test_submit_claim_routes_travel_route_mismatch_to_approval_with_review_flag(
monkeypatch,
tmp_path,
) -> None:
current_user = CurrentUserContext(
username="emp-travel@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
documents: list[OcrRecognizeDocumentRead] = []
for filename, _, media_type in files:
if filename == "outbound.png":
documents.append(
OcrRecognizeDocumentRead(
filename=filename,
media_type=media_type or "image/png",
text="电子行程单 2026-05-13 经济舱 武汉-上海 金额 480元 航班号 MU5101",
summary="武汉到上海机票",
avg_score=0.98,
line_count=1,
page_count=1,
document_type="flight_itinerary",
document_type_label="机票/航班行程单",
scene_code="travel",
scene_label="差旅票据",
document_fields=[
{"key": "route", "label": "行程", "value": "武汉-上海"},
{"key": "amount", "label": "金额", "value": "480元"},
{"key": "date", "label": "日期", "value": "2026-05-13"},
],
warnings=[],
)
)
elif filename == "onward.png":
documents.append(
OcrRecognizeDocumentRead(
filename=filename,
media_type=media_type or "image/png",
text="电子行程单 2026-05-14 经济舱 上海-成都 金额 360元 航班号 MU5402",
summary="上海到成都机票",
avg_score=0.98,
line_count=1,
page_count=1,
document_type="flight_itinerary",
document_type_label="机票/航班行程单",
scene_code="travel",
scene_label="差旅票据",
document_fields=[
{"key": "route", "label": "行程", "value": "上海-成都"},
{"key": "amount", "label": "金额", "value": "360元"},
{"key": "date", "label": "日期", "value": "2026-05-14"},
],
warnings=[],
)
)
return OcrRecognizeBatchRead(
total_file_count=len(files),
success_count=len(documents),
documents=documents,
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
with build_session() as db:
manager = Employee(
employee_no="E7200",
name="李经理",
email="manager-travel@example.com",
)
employee = Employee(
employee_no="E7201",
name="张三",
email="emp-travel@example.com",
grade="P4",
manager=manager,
)
db.add_all([manager, employee])
db.flush()
claim = build_claim(expense_type="travel", location="上海")
claim.reason = "上海客户现场出差"
claim.employee = employee
claim.employee_id = employee.id
claim.items = [
ExpenseClaimItem(
id="travel-item-1",
claim_id=claim.id,
item_date=date(2026, 5, 13),
item_type="travel",
item_reason="赴上海客户现场",
item_location="上海",
item_amount=Decimal("480.00"),
invoice_id=None,
),
ExpenseClaimItem(
id="travel-item-2",
claim_id=claim.id,
item_date=date(2026, 5, 14),
item_type="travel",
item_reason="赴上海客户现场",
item_location="上海",
item_amount=Decimal("360.00"),
invoice_id=None,
),
]
claim.amount = Decimal("840.00")
claim.invoice_count = 0
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
service.upload_claim_item_attachment(
claim_id=claim.id,
item_id="travel-item-1",
filename="outbound.png",
content=b"outbound-image",
media_type="image/png",
current_user=current_user,
)
service.upload_claim_item_attachment(
claim_id=claim.id,
item_id="travel-item-2",
filename="onward.png",
content=b"onward-image",
media_type="image/png",
current_user=current_user,
)
submitted = service.submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.approval_stage == "直属领导审批"
assert any(
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "submission_review"
and (
"多城市" in str(flag.get("message") or "")
or "终点" in str(flag.get("message") or "")
)
for flag in list(submitted.risk_flags_json or [])
)
def test_submit_claim_routes_hotel_amount_over_travel_policy_to_approval_with_review_flag(
monkeypatch,
tmp_path,
) -> None:
current_user = CurrentUserContext(
username="emp-hotel@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
documents: list[OcrRecognizeDocumentRead] = []
for filename, _, media_type in files:
if filename == "beijing-trip.png":
documents.append(
OcrRecognizeDocumentRead(
filename=filename,
media_type=media_type or "image/png",
text="电子行程单 2026-05-13 经济舱 武汉-北京 金额 520元 航班号 MU6101",
summary="武汉到北京机票",
avg_score=0.97,
line_count=1,
page_count=1,
document_type="flight_itinerary",
document_type_label="机票/航班行程单",
scene_code="travel",
scene_label="差旅票据",
document_fields=[
{"key": "route", "label": "行程", "value": "武汉-北京"},
{"key": "amount", "label": "金额", "value": "520元"},
{"key": "date", "label": "日期", "value": "2026-05-13"},
],
warnings=[],
)
)
elif filename == "beijing-hotel.png":
documents.append(
OcrRecognizeDocumentRead(
filename=filename,
media_type=media_type or "image/png",
text="北京全季酒店 1晚 金额 880元 2026-05-13",
summary="北京全季酒店住宿发票",
avg_score=0.98,
line_count=1,
page_count=1,
document_type="hotel_invoice",
document_type_label="酒店住宿票据",
scene_code="hotel",
scene_label="住宿票据",
document_fields=[
{"key": "merchant_name", "label": "商户", "value": "北京全季酒店"},
{"key": "amount", "label": "金额", "value": "880元"},
{"key": "date", "label": "日期", "value": "2026-05-13"},
],
warnings=[],
)
)
return OcrRecognizeBatchRead(
total_file_count=len(files),
success_count=len(documents),
documents=documents,
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
with build_session() as db:
manager = Employee(
employee_no="E7300",
name="李经理",
email="manager-hotel@example.com",
)
employee = Employee(
employee_no="E7301",
name="张三",
email="emp-hotel@example.com",
grade="P4",
manager=manager,
)
db.add_all([manager, employee])
db.flush()
claim = build_claim(expense_type="travel", location="北京")
claim.reason = "北京客户现场出差"
claim.employee = employee
claim.employee_id = employee.id
claim.items = [
ExpenseClaimItem(
id="hotel-trip-item",
claim_id=claim.id,
item_date=date(2026, 5, 13),
item_type="travel",
item_reason="赴北京客户现场",
item_location="北京",
item_amount=Decimal("520.00"),
invoice_id=None,
),
ExpenseClaimItem(
id="hotel-item",
claim_id=claim.id,
item_date=date(2026, 5, 13),
item_type="hotel",
item_reason="北京住宿",
item_location="北京",
item_amount=Decimal("880.00"),
invoice_id=None,
),
]
claim.amount = Decimal("1400.00")
claim.invoice_count = 0
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
service.upload_claim_item_attachment(
claim_id=claim.id,
item_id="hotel-trip-item",
filename="beijing-trip.png",
content=b"travel-image",
media_type="image/png",
current_user=current_user,
)
service.upload_claim_item_attachment(
claim_id=claim.id,
item_id="hotel-item",
filename="beijing-hotel.png",
content=b"hotel-image",
media_type="image/png",
current_user=current_user,
)
submitted = service.submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.approval_stage == "直属领导审批"
assert any(
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "submission_review"
and "住宿标准" in str(flag.get("message") or "")
for flag in list(submitted.risk_flags_json or [])
)
def test_list_claims_scopes_to_current_user_id_even_when_names_duplicate() -> None:
current_user = CurrentUserContext(
username="zhangsan1@example.com",
name="张三",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
employee_a = Employee(
employee_no="E2001",
name="张三",
email="zhangsan1@example.com",
)
employee_b = Employee(
employee_no="E2002",
name="张三",
email="zhangsan2@example.com",
)
db.add_all([employee_a, employee_b])
db.flush()
db.add_all(
[
ExpenseClaim(
claim_no="EXP-DUP-001",
employee_id=employee_a.id,
employee_name="张三",
department_name="市场部",
project_code="PRJ-A",
expense_type="travel",
reason="本人报销",
location="上海",
amount=Decimal("120.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="finance_review",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-DUP-002",
employee_id=employee_b.id,
employee_name="张三",
department_name="销售部",
project_code="PRJ-B",
expense_type="meal",
reason="他人报销",
location="杭州",
amount=Decimal("300.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 12, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 13, 0, tzinfo=UTC),
status="approved",
approval_stage="completed",
risk_flags_json=[],
),
]
)
db.commit()
claims = ExpenseClaimService(db).list_claims(current_user)
assert len(claims) == 1
assert claims[0].claim_no == "EXP-DUP-001"
def test_list_claims_limits_finance_to_personal_records() -> None:
current_user = CurrentUserContext(
username="finance@example.com",
name="财务",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
db.add_all(
[
ExpenseClaim(
claim_no="EXP-FIN-OWN",
employee_name="财务",
department_name="A部",
project_code="PRJ-A",
expense_type="travel",
reason="A 报销",
location="上海",
amount=Decimal("120.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="finance_review",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-FIN-OTHER-DRAFT",
employee_name="",
department_name="B部",
project_code="PRJ-B",
expense_type="meal",
reason="B 报销",
location="杭州",
amount=Decimal("300.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
),
]
)
db.commit()
claims = ExpenseClaimService(db).list_claims(current_user)
assert len(claims) == 1
assert claims[0].claim_no == "EXP-FIN-OWN"
def test_list_claims_limits_executive_to_personal_records() -> None:
current_user = CurrentUserContext(
username="executive@example.com",
name="高管",
role_codes=["executive"],
is_admin=False,
)
with build_session() as db:
db.add_all(
[
ExpenseClaim(
claim_no="EXP-EXE-OWN",
employee_name="高管",
department_name="A部",
project_code="PRJ-A",
expense_type="travel",
reason="A 报销",
location="上海",
amount=Decimal("120.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-EXE-OTHER-DRAFT",
employee_name="",
department_name="B部",
project_code="PRJ-B",
expense_type="meal",
reason="B 报销",
location="杭州",
amount=Decimal("300.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
),
]
)
db.commit()
claims = ExpenseClaimService(db).list_claims(current_user)
assert len(claims) == 1
assert claims[0].claim_no == "EXP-EXE-OWN"
def test_list_claims_keeps_own_archived_claim_for_finance_applicant() -> None:
current_user = CurrentUserContext(
username="finance@example.com",
name="财务",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
db.add(
ExpenseClaim(
claim_no="EXP-FIN-OWN-ARCH",
employee_name="财务",
department_name="财务部",
project_code="PRJ-FIN",
expense_type="meal",
reason="本人报销",
location="上海",
amount=Decimal("88.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="approved",
approval_stage="归档入账",
risk_flags_json=[],
)
)
db.commit()
claims = ExpenseClaimService(db).list_claims(current_user)
assert len(claims) == 1
assert claims[0].claim_no == "EXP-FIN-OWN-ARCH"
def test_list_archived_claims_returns_company_archived_records_for_finance() -> None:
current_user = CurrentUserContext(
username="finance@example.com",
name="财务",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
db.add_all(
[
ExpenseClaim(
claim_no="EXP-ARCH-101",
employee_name="",
department_name="A部",
project_code="PRJ-A",
expense_type="travel",
reason="A 报销",
location="上海",
amount=Decimal("120.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
status="approved",
approval_stage="归档入账",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-ARCH-102",
employee_name="",
department_name="B部",
project_code="PRJ-B",
expense_type="meal",
reason="B 报销",
location="杭州",
amount=Decimal("300.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 13, 0, tzinfo=UTC),
status="submitted",
approval_stage="财务审批",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-ARCH-PAID",
employee_name="",
department_name="C部",
project_code="PRJ-C",
expense_type="office",
reason="C 报销",
location="深圳",
amount=Decimal("180.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 14, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 15, 0, tzinfo=UTC),
status="paid",
approval_stage="已付款",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="AP-20260525120000-ABCDEFGH",
employee_name="",
department_name="C部",
project_code="PRJ-C",
expense_type="travel_application",
reason="C 申请",
location="成都",
amount=Decimal("800.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 11, 14, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 15, 0, tzinfo=UTC),
status="approved",
approval_stage="审批完成",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="AP-20260525123000-HGFEDCBA",
employee_name="",
department_name="D部",
project_code="PRJ-D",
expense_type="travel_application",
reason="D 申请",
location="北京",
amount=Decimal("500.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 11, 16, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 17, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
),
]
)
db.commit()
claims = ExpenseClaimService(db).list_archived_claims(current_user)
assert {claim.claim_no for claim in claims} == {
"EXP-ARCH-101",
"EXP-ARCH-PAID",
"AP-20260525120000-ABCDEFGH",
}
def test_list_archived_claims_returns_only_own_records_for_regular_employee() -> None:
current_user = CurrentUserContext(
username="zhangsan@example.com",
name="张三",
role_codes=["employee"],
is_admin=False,
)
with build_session() as db:
db.add_all(
[
ExpenseClaim(
claim_no="EXP-ARCH-EMP",
employee_name="张三",
department_name="研发部",
project_code="PRJ-EMP",
expense_type="travel",
reason="本人报销",
location="北京",
amount=Decimal("200.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 10, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 10, 10, 0, tzinfo=UTC),
status="approved",
approval_stage="归档入账",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="AP-20260525130000-ABCDEFGH",
employee_name="李四",
department_name="研发部",
project_code="PRJ-EMP",
expense_type="travel_application",
reason="他人申请",
location="上海",
amount=Decimal("500.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 10, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 10, 10, 0, tzinfo=UTC),
status="approved",
approval_stage="审批完成",
risk_flags_json=[],
),
]
)
db.commit()
claims = ExpenseClaimService(db).list_archived_claims(current_user)
assert [claim.claim_no for claim in claims] == ["EXP-ARCH-EMP"]
def test_finance_can_return_but_cannot_delete_submitted_claim() -> None:
current_user = CurrentUserContext(
username="finance@example.com",
name="财务",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
claim = ExpenseClaim(
claim_no="EXP-RET-101",
employee_name="张三",
department_name="市场部",
project_code="PRJ-A",
expense_type="travel",
reason="差旅报销",
location="上海",
amount=Decimal("120.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="财务审批",
risk_flags_json=[],
)
db.add(claim)
db.commit()
claim_id = claim.id
service = ExpenseClaimService(db)
returned = service.return_claim(claim_id, current_user, reason="资料不完整")
assert returned is not None
assert returned.status == "returned"
assert returned.approval_stage == "待提交"
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_return"
and flag.get("message") == "资料不完整"
for flag in returned.risk_flags_json
)
with pytest.raises(ValueError, match="只有高级财务人员可以删除"):
service.delete_claim(claim_id, current_user)
assert db.get(ExpenseClaim, claim_id) is not None
def test_executive_can_delete_submitted_claim() -> None:
current_user = CurrentUserContext(
username="executive-delete@example.com",
name="高管",
role_codes=["executive"],
is_admin=False,
)
with build_session() as db:
claim = ExpenseClaim(
claim_no="EXP-DEL-EXEC-101",
employee_name="张三",
department_name="市场部",
project_code="PRJ-A",
expense_type="travel",
reason="差旅报销",
location="上海",
amount=Decimal("120.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="财务审批",
risk_flags_json=[],
)
db.add(claim)
db.commit()
claim_id = claim.id
deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)
assert deleted is not None
assert deleted.claim_no == "EXP-DEL-EXEC-101"
assert db.get(ExpenseClaim, claim_id) is None
def test_executive_cannot_delete_archived_claim() -> None:
current_user = CurrentUserContext(
username="executive-archive-delete@example.com",
name="高管",
role_codes=["executive"],
is_admin=False,
)
with build_session() as db:
claim = ExpenseClaim(
claim_no="EXP-DEL-ARCHIVE-101",
employee_name="张三",
department_name="市场部",
project_code="PRJ-A",
expense_type="travel",
reason="差旅报销",
location="上海",
amount=Decimal("120.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
status="approved",
approval_stage="归档入账",
risk_flags_json=[],
)
db.add(claim)
db.commit()
claim_id = claim.id
with pytest.raises(ValueError, match="已归档单据不能删除"):
ExpenseClaimService(db).delete_claim(claim_id, current_user)
assert db.get(ExpenseClaim, claim_id) is not None
def test_admin_can_delete_archived_claim() -> None:
current_user = CurrentUserContext(
username="superadmin",
name="系统管理员",
role_codes=["manager"],
is_admin=True,
)
with build_session() as db:
claim = ExpenseClaim(
claim_no="EXP-DEL-ARCHIVE-102",
employee_name="张三",
department_name="市场部",
project_code="PRJ-A",
expense_type="travel",
reason="差旅报销",
location="上海",
amount=Decimal("120.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
status="approved",
approval_stage="归档入账",
risk_flags_json=[],
)
db.add(claim)
db.commit()
claim_id = claim.id
deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)
assert deleted is not None
assert deleted.claim_no == "EXP-DEL-ARCHIVE-102"
assert db.get(ExpenseClaim, claim_id) is None
def test_direct_manager_can_return_subordinate_claim_to_pending_submission() -> None:
current_user = CurrentUserContext(
username="manager-return@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E8100",
name="李经理",
email="manager-return@example.com",
)
employee = Employee(
employee_no="E8101",
name="张三",
email="zhangsan-return@example.com",
manager=manager,
)
db.add_all([manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="EXP-RET-201",
employee_id=employee.id,
employee_name="张三",
department_name="市场部",
project_code="PRJ-A",
expense_type="transport",
reason="交通报销",
location="上海",
amount=Decimal("66.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(claim)
db.commit()
claim_id = claim.id
returned = ExpenseClaimService(db).return_claim(claim_id, current_user, reason="请补充行程说明")
assert returned is not None
assert returned.status == "returned"
assert returned.approval_stage == "待提交"
assert returned.submitted_at is None
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_return"
and flag.get("message") == "请补充行程说明"
for flag in returned.risk_flags_json
)
def test_direct_manager_can_approve_subordinate_claim_to_finance_review() -> None:
current_user = CurrentUserContext(
username="manager-approve@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E8110",
name="李经理",
email="manager-approve@example.com",
)
employee = Employee(
employee_no="E8111",
name="张三",
email="zhangsan-approve@example.com",
manager=manager,
)
db.add_all([manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="EXP-APP-201",
employee_id=employee.id,
employee_name="张三",
department_name="市场部",
project_code="PRJ-A",
expense_type="transport",
reason="交通报销",
location="上海",
amount=Decimal("66.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(claim)
db.commit()
claim_id = claim.id
approved = ExpenseClaimService(db).approve_claim(
claim_id,
current_user,
opinion="情况属实,同意报销。",
)
assert approved is not None
assert approved.status == "submitted"
assert approved.approval_stage == "财务审批"
assert approved.submitted_at is not None
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_approval"
and flag.get("event_type") == "expense_claim_approval"
and flag.get("opinion") == "情况属实,同意报销。"
and flag.get("previous_approval_stage") == "直属领导审批"
and flag.get("next_approval_stage") == "财务审批"
for flag in approved.risk_flags_json
)
def test_manager_cannot_operate_own_claim_submitted_to_direct_manager() -> None:
current_user = CurrentUserContext(
username="manager-own-approval@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
superior = Employee(
employee_no="E8112",
name="王总",
email="superior-own-approval@example.com",
)
manager = Employee(
employee_no="E8113",
name="李经理",
email="manager-own-approval@example.com",
manager=superior,
)
db.add_all([superior, manager])
db.flush()
claim = ExpenseClaim(
claim_no="EXP-APP-SELF-201",
employee_id=manager.id,
employee_name="李经理",
department_name="市场部",
project_code="PRJ-A",
expense_type="transport",
reason="交通报销",
location="上海",
amount=Decimal("66.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(claim)
db.commit()
claim_id = claim.id
service = ExpenseClaimService(db)
with pytest.raises(ValueError, match="当前直属领导审批人"):
service.approve_claim(claim_id, current_user, opinion="同意")
with pytest.raises(ValueError, match="当前审批人"):
service.return_claim(claim_id, current_user, reason="退回")
db.refresh(claim)
assert claim.status == "submitted"
assert claim.approval_stage == "直属领导审批"
assert claim.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",
name="张三",
role_codes=["employee"],
is_admin=True,
)
with build_session() as db:
claim = ExpenseClaim(
claim_no="APP-20260525-SUBMIT",
employee_name="张三",
department_name="交付部",
project_code="PRJ-A",
expense_type="travel_application",
reason="支撑国网服务器上线部署",
location="上海",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[
{
"source": "platform_risk",
"severity": "medium",
"message": "旧 AI 预审提示不应保留到申请单提交结果。",
}
],
)
db.add(claim)
db.commit()
claim_id = claim.id
service = ExpenseClaimService(db)
def fail_ai_review(_claim: ExpenseClaim) -> dict[str, object]:
raise AssertionError("费用申请提交不应进入 AI 预审")
monkeypatch.setattr(service, "_run_ai_submission_review", fail_ai_review)
submitted = service.submit_claim(claim_id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.approval_stage == "直属领导审批"
assert submitted.invoice_count == 0
assert submitted.items == []
assert not any(
isinstance(flag, dict) and flag.get("source") == "submission_review"
for flag in submitted.risk_flags_json
)
assert any(
isinstance(flag, dict)
and flag.get("source") == "application_submission"
and flag.get("event_type") == "expense_application_submission"
and flag.get("next_approval_stage") == "直属领导审批"
for flag in submitted.risk_flags_json
)
def test_application_submit_reserves_budget_once() -> None:
current_user = CurrentUserContext(
username="application-budget-owner@example.com",
name="张三",
role_codes=["employee"],
is_admin=True,
)
with build_session() as db:
_seed_budget_allocation(
db,
department_id="dept-budget",
department_name="交付部",
amount=Decimal("50000.00"),
)
claim = ExpenseClaim(
claim_no="APP-20260525-BUDGET",
employee_id="emp-budget",
employee_name="张三",
department_id="dept-budget",
department_name="交付部",
project_code=None,
expense_type="travel_application",
reason="客户现场交付",
location="上海",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
db.add(claim)
db.commit()
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
assert submitted is not None
reservations = db.query(BudgetReservation).all()
assert len(reservations) == 1
assert reservations[0].source_type == "application"
assert reservations[0].source_id == claim.id
assert reservations[0].amount == Decimal("12000.00")
transactions = db.query(BudgetTransaction).all()
assert any(item.transaction_type == "reserve" for item in transactions)
assert any(
isinstance(flag, dict)
and flag.get("source") == "budget_control"
and flag.get("event_type") == "budget_reserved"
for flag in submitted.risk_flags_json
)
def test_application_submit_blocks_when_budget_insufficient_without_state_change() -> None:
current_user = CurrentUserContext(
username="application-budget-block@example.com",
name="张三",
role_codes=["employee"],
is_admin=True,
)
with build_session() as db:
_seed_budget_allocation(
db,
department_id="dept-budget-block",
department_name="交付部",
amount=Decimal("1000.00"),
)
claim = ExpenseClaim(
claim_no="APP-20260525-BLOCK",
employee_id="emp-budget-block",
employee_name="张三",
department_id="dept-budget-block",
department_name="交付部",
project_code=None,
expense_type="travel_application",
reason="客户现场交付",
location="上海",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
db.add(claim)
db.commit()
with pytest.raises(ValueError):
ExpenseClaimService(db).submit_claim(claim.id, current_user)
db.refresh(claim)
assert claim.status == "draft"
assert db.query(BudgetReservation).count() == 0
assert db.query(BudgetTransaction).count() == 0
def test_application_submit_skips_budget_for_non_demo_subject() -> None:
current_user = CurrentUserContext(
username="application-budget-skip@example.com",
name="张三",
role_codes=["employee"],
is_admin=True,
)
with build_session() as db:
_seed_budget_allocation(
db,
department_id="dept-budget-skip",
department_name="交付部",
amount=Decimal("1000.00"),
)
claim = ExpenseClaim(
claim_no="APP-20260525-SKIP",
employee_id="emp-budget-skip",
employee_name="张三",
department_id="dept-budget-skip",
department_name="交付部",
project_code=None,
expense_type="software_application",
reason="采购演示软件服务",
location="深圳",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
db.add(claim)
db.commit()
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.approval_stage == "直属领导审批"
assert db.query(BudgetReservation).count() == 0
assert db.query(BudgetTransaction).count() == 0
assert not any(
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "budget_control"
for flag in submitted.risk_flags_json
)
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="李经理",
role_codes=["manager"],
is_admin=False,
)
budget_user = CurrentUserContext(
username="budget-p8-application-approve@example.com",
name="赵预算",
role_codes=["budget_monitor"],
is_admin=False,
)
with build_session() as db:
budget_role = _seed_budget_monitor_role(db)
department = OrganizationUnit(
unit_code="DELIVERY-BUDGET-APPROVE",
name="交付部",
unit_type="department",
)
manager = Employee(
employee_no="E8112",
name="李经理",
email="manager-application-approve@example.com",
organization_unit=department,
)
budget_manager = Employee(
employee_no="E8112-BUDGET",
name="赵预算",
email="budget-p8-application-approve@example.com",
grade="P8",
organization_unit=department,
roles=[budget_role],
)
employee = Employee(
employee_no="E8113",
name="张三",
email="zhangsan-application-approve@example.com",
manager=manager,
organization_unit=department,
)
db.add_all([department, manager, budget_manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="APP-20260525-APPROVE",
employee_id=employee.id,
employee_name="张三",
department_id=department.id,
department_name="交付部",
project_code="PRJ-A",
expense_type="travel_application",
reason="支撑国网服务器上线部署",
location="上海",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
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": "application_detail",
"application_detail": {
"application_type": "差旅费用申请",
"time": "2026-05-25 至 2026-05-27",
"location": "上海",
"reason": "支撑国网服务器上线部署",
"days": "3 天",
"transport_mode": "高铁",
"amount": "12000.00",
},
},
{
"source": "submission_review",
"severity": "high",
"label": "申请风险复核",
"message": "申请金额和行程安排需要预算管理者二次确认。",
}
],
)
db.add(claim)
db.commit()
claim_id = claim.id
leader_approved = ExpenseClaimService(db).approve_claim(
claim_id,
manager_user,
opinion="业务必要,同意申请。",
)
assert leader_approved is not None
assert leader_approved.status == "submitted"
assert leader_approved.approval_stage == "预算管理者审批"
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_approval"
and flag.get("event_type") == "expense_application_approval"
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("next_approver_name") == "赵预算"
and flag.get("next_approver_grade") == "P8"
for flag in leader_approved.risk_flags_json
)
approved = ExpenseClaimService(db).approve_claim(
claim_id,
budget_user,
opinion="预算额度可承接,同意。",
)
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == "审批完成"
archived_claims = ExpenseClaimService(db).list_archived_claims(
CurrentUserContext(
username="finance-archive@example.com",
name="财务归档员",
role_codes=["finance"],
is_admin=False,
)
)
assert any(claim.claim_no == "APP-20260525-APPROVE" for claim in archived_claims)
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
assert generated_draft.status == "draft"
assert generated_draft.approval_stage == "待提交"
assert generated_draft.expense_type == "travel"
assert generated_draft.employee_id == employee.id
assert generated_draft.employee_name == "张三"
assert generated_draft.department_name == "交付部"
assert generated_draft.reason == "支撑国网服务器上线部署"
assert generated_draft.location == "上海"
assert generated_draft.amount == Decimal("12000.00")
assert generated_draft.invoice_count == 0
assert generated_draft.items == []
assert any(
isinstance(flag, dict)
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_days") == "3 天"
and flag.get("application_detail", {}).get("application_transport_mode") == "高铁"
and flag.get("leader_opinion") == "业务必要,同意申请。"
and flag.get("budget_opinion") == "预算额度可承接,同意。"
for flag in generated_draft.risk_flags_json
)
assert any(
isinstance(flag, dict)
and flag.get("source") == "budget_approval"
and flag.get("event_type") == "expense_application_budget_approval"
and flag.get("opinion") == "预算额度可承接,同意。"
and flag.get("previous_approval_stage") == "预算管理者审批"
and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == "审批完成"
and flag.get("generated_draft_claim_id") == generated_draft.id
and flag.get("generated_draft_claim_no") == generated_draft.claim_no
for flag in approved.risk_flags_json
)
def test_application_routes_to_department_p8_executive_with_approver_name() -> None:
manager_user = CurrentUserContext(
username="manager-executive-route@example.com",
name="Manager",
role_codes=["manager"],
is_admin=False,
)
budget_user = CurrentUserContext(
username="p8-executive-route@example.com",
name="P8 Executive",
role_codes=["executive"],
is_admin=False,
)
with build_session() as db:
executive_role = _seed_executive_role(db)
department = OrganizationUnit(
unit_code="DELIVERY-EXECUTIVE-ROUTE",
name="Engineering",
unit_type="department",
)
manager = Employee(
employee_no="E-EXEC-ROUTE-MGR",
name="Manager",
email="manager-executive-route@example.com",
organization_unit=department,
)
budget_manager = Employee(
employee_no="E-EXEC-ROUTE-P8",
name="P8 Executive",
email="p8-executive-route@example.com",
grade="P8",
organization_unit=department,
roles=[executive_role],
)
employee = Employee(
employee_no="E-EXEC-ROUTE-APP",
name="Applicant",
email="applicant-executive-route@example.com",
manager=manager,
organization_unit=department,
)
db.add_all([department, manager, budget_manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="APP-20260531-EXEC-ROUTE",
employee_id=employee.id,
employee_name=employee.name,
department_id=department.id,
department_name=department.name,
project_code="PRJ-A",
expense_type="travel_application",
reason="Production deployment support",
location="Beijing",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 31, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 31, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
risk_flags_json=[
{
"source": "submission_review",
"severity": "high",
"label": "Route risk",
"message": "Application requires budget confirmation.",
}
],
)
db.add(claim)
db.commit()
claim_id = claim.id
routed = ExpenseClaimService(db).approve_claim(
claim_id,
manager_user,
opinion="Approved by direct manager.",
)
assert routed is not None
assert routed.status == "submitted"
assert routed.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
assert getattr(routed, "budget_approver_name", "") == "P8 Executive"
assert getattr(routed, "budget_approver_grade", "") == "P8"
assert getattr(routed, "budget_approver_role_code", "") == "executive"
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_approval"
and flag.get("next_approval_stage") == BUDGET_MANAGER_APPROVAL_STAGE
and flag.get("next_approver_name") == "P8 Executive"
and flag.get("next_approver_grade") == "P8"
and flag.get("next_approver_role_code") == "executive"
for flag in routed.risk_flags_json
)
approved = ExpenseClaimService(db).approve_claim(
claim_id,
budget_user,
opinion="Budget confirmed.",
)
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == APPROVAL_DONE_STAGE
def test_direct_manager_cannot_route_application_to_missing_budget_approver() -> None:
manager_user = CurrentUserContext(
username="manager-missing-budget@example.com",
name="Manager",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
department = OrganizationUnit(
unit_code="DELIVERY-MISSING-BUDGET",
name="Engineering",
unit_type="department",
)
manager = Employee(
employee_no="E-MISSING-BUDGET-MGR",
name="Manager",
email="manager-missing-budget@example.com",
organization_unit=department,
)
employee = Employee(
employee_no="E-MISSING-BUDGET-APP",
name="Applicant",
email="applicant-missing-budget@example.com",
manager=manager,
organization_unit=department,
)
db.add_all([department, manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="APP-20260531-MISSING-BUDGET",
employee_id=employee.id,
employee_name=employee.name,
department_id=department.id,
department_name=department.name,
project_code="PRJ-A",
expense_type="travel_application",
reason="Production deployment support",
location="Beijing",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 31, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 31, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
risk_flags_json=[
{
"source": "submission_review",
"severity": "high",
"label": "Route risk",
"message": "Application requires budget confirmation.",
}
],
)
db.add(claim)
db.commit()
claim_id = claim.id
with pytest.raises(ValueError, match="未找到同部门 P8 预算审批人"):
ExpenseClaimService(db).approve_claim(
claim_id,
manager_user,
opinion="Approved by direct manager.",
)
db.refresh(claim)
assert claim.status == "submitted"
assert claim.approval_stage == DIRECT_MANAGER_APPROVAL_STAGE
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
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",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
executive_role = _seed_executive_role(db)
department = OrganizationUnit(
unit_code="DELIVERY-EXECUTIVE-MERGED",
name="Engineering",
unit_type="department",
)
manager = Employee(
employee_no="E-EXEC-MERGED-MGR",
name="P8 Manager",
email="manager-executive-merged@example.com",
grade="P8",
organization_unit=department,
roles=[executive_role],
)
employee = Employee(
employee_no="E-EXEC-MERGED-APP",
name="Applicant",
email="applicant-executive-merged@example.com",
manager=manager,
organization_unit=department,
)
db.add_all([department, manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="APP-20260531-EXEC-MERGED",
employee_id=employee.id,
employee_name=employee.name,
department_id=department.id,
department_name=department.name,
project_code="PRJ-A",
expense_type="travel_application",
reason="Production deployment support",
location="Beijing",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 31, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 31, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
risk_flags_json=[
{
"source": "submission_review",
"severity": "high",
"label": "Route risk",
"message": "Application requires budget confirmation.",
}
],
)
db.add(claim)
db.commit()
claim_id = claim.id
approved = ExpenseClaimService(db).approve_claim(
claim_id,
manager_user,
opinion="Approved by direct manager and budget owner.",
)
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == APPROVAL_DONE_STAGE
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1
assert not any(
isinstance(flag, dict)
and flag.get("next_approval_stage") == BUDGET_MANAGER_APPROVAL_STAGE
for flag in approved.risk_flags_json
)
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_approval"
and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == APPROVAL_DONE_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_direct_manager_budget_monitor_completes_application_claim_without_duplicate_budget_approval() -> None:
manager_user = CurrentUserContext(
username="manager-budget-monitor-application@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-BUDGET-MERGED",
name="交付部",
unit_type="department",
)
manager = Employee(
employee_no="E8112-MERGED",
name="李预算经理",
email="manager-budget-monitor-application@example.com",
grade="P8",
organization_unit=department,
roles=[budget_role],
)
employee = Employee(
employee_no="E8113-MERGED",
name="张三",
email="zhangsan-budget-monitor-application@example.com",
manager=manager,
organization_unit=department,
)
db.add_all([department, manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="APP-20260525-MERGED",
employee_id=employee.id,
employee_name="张三",
department_id=department.id,
department_name="交付部",
project_code="PRJ-A",
expense_type="travel_application",
reason="支撑国网服务器上线部署",
location="上海",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
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()
claim_id = claim.id
approved = ExpenseClaimService(db).approve_claim(
claim_id,
manager_user,
opinion="业务必要且预算可承接,同意申请。",
)
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == "审批完成"
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1
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_application_approval"
and flag.get("label") == "领导及预算审核通过"
and flag.get("opinion") == "业务必要且预算可承接,同意申请。"
and flag.get("previous_approval_stage") == "直属领导审批"
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"
for flag in approved.risk_flags_json
)
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
assert generated_draft.status == "draft"
assert generated_draft.expense_type == "travel"
reviewer_claims = ExpenseClaimService(db).list_claims(manager_user)
assert all(claim.claim_no != generated_draft.claim_no for claim in reviewer_claims)
applicant_claims = ExpenseClaimService(db).list_claims(
CurrentUserContext(
username="zhangsan-budget-monitor-application@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
)
assert any(claim.claim_no == generated_draft.claim_no for claim in applicant_claims)
assert any(
isinstance(flag, dict)
and flag.get("source") == "application_handoff"
and flag.get("event_type") == "expense_application_to_reimbursement_draft"
and flag.get("application_claim_no") == "APP-20260525-MERGED"
and flag.get("leader_opinion") == "业务必要且预算可承接,同意申请。"
and flag.get("budget_opinion") == "业务必要且预算可承接,同意申请。"
for flag in generated_draft.risk_flags_json
)
def test_direct_manager_return_application_claim_records_return_node_and_opinion() -> None:
manager_user = CurrentUserContext(
username="manager-application-return@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E8114",
name="李经理",
email="manager-application-return@example.com",
)
employee = Employee(
employee_no="E8115",
name="张三",
email="zhangsan-application-return@example.com",
manager=manager,
)
db.add_all([manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="APP-20260525-RETURN",
employee_id=employee.id,
employee_name="张三",
department_name="交付部",
project_code="PRJ-A",
expense_type="travel_application",
reason="支撑国网服务器上线部署",
location="上海",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
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=[],
)
db.add(claim)
db.commit()
with pytest.raises(ValueError, match="退单类型"):
ExpenseClaimService(db).return_claim(
claim.id,
manager_user,
reason="预算说明不够清楚,请补充项目必要性。",
)
db.refresh(claim)
assert claim.status == "submitted"
assert claim.risk_flags_json == []
returned = ExpenseClaimService(db).return_claim(
claim.id,
manager_user,
reason="预算说明不够清楚,请补充项目必要性。",
reason_codes=["application_budget_basis_missing"],
)
assert returned is not None
assert returned.status == "returned"
assert returned.approval_stage == "待提交"
return_event = next(
flag
for flag in returned.risk_flags_json
if isinstance(flag, dict) and flag.get("event_type") == "expense_application_return"
)
assert return_event["label"] == "领导退回"
assert return_event["node_key"] == "returned"
assert return_event["node_label"] == "退回"
assert return_event["approval_node"] == "退回"
assert return_event["operator"] == "李经理"
assert return_event["opinion"] == "预算说明不够清楚,请补充项目必要性。"
assert return_event["leader_opinion"] == "预算说明不够清楚,请补充项目必要性。"
assert return_event["return_stage"] == "直属领导审批"
assert return_event["return_stage_key"] == "direct_manager"
assert return_event["reason_codes"] == ["application_budget_basis_missing"]
assert return_event["risk_points"] == ["预算测算依据不足"]
assert return_event["next_status"] == "returned"
assert return_event["next_approval_stage"] == "待提交"
def test_application_approval_transfers_budget_reservation_to_reimbursement_draft() -> None:
owner = CurrentUserContext(
username="application-budget-owner-approve@example.com",
name="张三",
role_codes=["employee"],
is_admin=False,
)
manager_user = CurrentUserContext(
username="manager-application-budget@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
budget_user = CurrentUserContext(
username="budget-p8-transfer@example.com",
name="赵预算",
role_codes=["budget_monitor"],
is_admin=False,
)
with build_session() as db:
budget_role = _seed_budget_monitor_role(db)
department = OrganizationUnit(
id="dept-budget-transfer",
unit_code="DELIVERY-BUDGET-TRANSFER",
name="交付部",
unit_type="department",
)
manager = Employee(
employee_no="M-BUDGET-APP",
name="李经理",
email="manager-application-budget@example.com",
organization_unit=department,
)
budget_manager = Employee(
employee_no="P8-BUDGET-APP",
name="赵预算",
email="budget-p8-transfer@example.com",
grade="P8",
organization_unit=department,
roles=[budget_role],
)
employee = Employee(
employee_no="E-BUDGET-APP",
name="张三",
email="application-budget-owner-approve@example.com",
manager=manager,
organization_unit=department,
)
db.add_all([department, manager, budget_manager, employee])
db.flush()
_seed_budget_allocation(
db,
department_id="dept-budget-transfer",
department_name="交付部",
amount=Decimal("50000.00"),
)
claim = ExpenseClaim(
claim_no="APP-20260525-TRANSFER",
employee_id=employee.id,
employee_name="张三",
department_id="dept-budget-transfer",
department_name="交付部",
project_code=None,
expense_type="travel_application",
reason="客户现场交付",
location="上海",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[
{
"source": "platform_risk",
"severity": "high",
"label": "申请风险复核",
"message": "申请金额和行程安排需要预算管理者二次确认。",
}
],
)
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
service.submit_claim(claim.id, owner)
leader_approved = service.approve_claim(claim.id, manager_user, opinion="同意申请")
reservation = db.query(BudgetReservation).one()
assert leader_approved is not None
assert leader_approved.approval_stage == "预算管理者审批"
assert reservation.source_type == "application"
assert reservation.source_id == claim.id
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
approved = service.approve_claim(claim.id, budget_user, opinion="预算通过")
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
db.refresh(reservation)
assert approved is not None
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"
for flag in generated_draft.risk_flags_json
)
deleted = service.delete_claim(
generated_draft.id,
CurrentUserContext(
username="browser-session-user",
name="",
role_codes=["user"],
is_admin=False,
employee_no="E-BUDGET-APP",
),
)
db.refresh(reservation)
assert deleted is not None
assert db.get(ExpenseClaim, generated_draft.id) is None
assert reservation.source_status == "released"
assert reservation.released_amount == Decimal("12000.00")
def test_direct_manager_approval_defaults_blank_opinion_to_agree() -> None:
current_user = CurrentUserContext(
username="manager-application-required-opinion@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E8122",
name="李经理",
email="manager-application-required-opinion@example.com",
)
employee = Employee(
employee_no="E8123",
name="张三",
email="zhangsan-application-required-opinion@example.com",
manager=manager,
)
db.add_all([manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="APP-20260525-REQUIRE-OPINION",
employee_id=employee.id,
employee_name="张三",
department_name="交付部",
project_code="PRJ-A",
expense_type="travel_application",
reason="支撑国网服务器上线部署",
location="上海",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
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=[],
)
db.add(claim)
db.commit()
claim_id = claim.id
approved = ExpenseClaimService(db).approve_claim(
claim_id,
current_user,
opinion=" ",
)
assert approved is not None
assert approved.status == "submitted"
assert approved.approval_stage == "预算管理者审批"
assert any(
isinstance(flag, dict)
and flag.get("event_type") == "expense_application_approval"
and flag.get("opinion") == "同意"
and flag.get("next_approval_stage") == "预算管理者审批"
for flag in approved.risk_flags_json
)
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
def test_budget_analysis_uses_current_application_reservation_without_double_counting() -> None:
owner = CurrentUserContext(
username="application-budget-analysis-owner@example.com",
name="张三",
role_codes=["employee"],
is_admin=False,
)
with build_session() as db:
employee = Employee(
employee_no="E-BUDGET-ANALYSIS",
name="张三",
email="application-budget-analysis-owner@example.com",
)
db.add(employee)
db.flush()
_seed_budget_allocation(
db,
department_id="dept-budget-analysis",
department_name="交付部",
amount=Decimal("50000.00"),
)
claim = ExpenseClaim(
claim_no="APP-20260525-ANALYSIS",
employee_id=employee.id,
employee_name="张三",
department_id="dept-budget-analysis",
department_name="交付部",
project_code="PRJ-A",
expense_type="travel_application",
reason="客户现场交付",
location="上海",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
db.add(claim)
db.commit()
ExpenseClaimService(db).submit_claim(claim.id, owner)
analysis = BudgetService(db).analyze_claim_budget(claim)
assert analysis["metrics"]["claim_amount_ratio"] == "24.00"
assert analysis["metrics"]["after_usage_rate"] == "24.00"
assert analysis["budget_context"]["current_reserved_amount"] == "12000.00"
assert analysis["score"] >= 70
assert any("本次申请金额 12000.00 元,占预算 24.00%" in item for item in analysis["basis"])
def test_finance_approve_reimbursement_consumes_budget_reservation() -> None:
current_user = CurrentUserContext(
username="finance-budget-approve@example.com",
name="财务",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
allocation = _seed_budget_allocation(
db,
department_id="dept-finance-budget",
department_name="交付部",
amount=Decimal("50000.00"),
)
claim = ExpenseClaim(
claim_no="RE-20260525-BUDGET",
employee_id="emp-finance-budget",
employee_name="张三",
department_id="dept-finance-budget",
department_name="交付部",
project_code=None,
expense_type="travel",
reason="客户现场交付",
location="上海",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=1,
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=[],
)
db.add(claim)
db.flush()
reservation = BudgetReservation(
reservation_no=f"BRS-TEST-{uuid.uuid4().hex[:8]}",
allocation_id=allocation.id,
source_type="claim",
source_id=claim.id,
source_no=claim.claim_no,
source_status="active",
amount=Decimal("12000.00"),
context_json={},
)
db.add(reservation)
db.commit()
approved = ExpenseClaimService(db).approve_claim(claim.id, current_user, opinion="同意入账")
assert approved is not 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 any(
isinstance(flag, dict)
and flag.get("source") == "budget_control"
and flag.get("event_type") == "budget_consumed"
for flag in approved.risk_flags_json
)
def test_finance_cannot_operate_own_claim_in_finance_stage() -> None:
current_user = CurrentUserContext(
username="finance-own-approval@example.com",
name="财务",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
employee = Employee(
employee_no="E8124",
name="财务",
email="finance-own-approval@example.com",
)
db.add(employee)
db.flush()
claim = ExpenseClaim(
claim_no="RE-20260525-FINANCE-SELF",
employee_id=employee.id,
employee_name="财务",
department_name="财务部",
project_code=None,
expense_type="travel",
reason="差旅报销",
location="上海",
amount=Decimal("800.00"),
currency="CNY",
invoice_count=1,
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=[],
)
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
with pytest.raises(ValueError, match="财务终审"):
service.approve_claim(claim.id, current_user, opinion="同意入账")
with pytest.raises(ValueError, match="可以退回"):
service.return_claim(claim.id, current_user, reason="退回")
db.refresh(claim)
assert claim.status == "submitted"
assert claim.approval_stage == "财务审批"
assert claim.risk_flags_json == []
def test_finance_can_approve_claim_to_pending_payment_stage() -> None:
current_user = CurrentUserContext(
username="finance-approve@example.com",
name="财务复核",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
claim = ExpenseClaim(
claim_no="EXP-FIN-APP-201",
employee_name="张三",
department_name="市场部",
project_code="PRJ-A",
expense_type="transport",
reason="交通报销",
location="上海",
amount=Decimal("66.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(claim)
db.commit()
claim_id = claim.id
approved = ExpenseClaimService(db).approve_claim(
claim_id,
current_user,
opinion="票据与明细一致,同意入账。",
)
assert approved is not None
assert approved.status == "pending_payment"
assert approved.approval_stage == "待付款"
assert any(
isinstance(flag, dict)
and flag.get("source") == "finance_approval"
and flag.get("event_type") == "expense_claim_finance_approval"
and flag.get("opinion") == "票据与明细一致,同意入账。"
and flag.get("previous_approval_stage") == "财务审批"
and flag.get("next_status") == "pending_payment"
and flag.get("next_approval_stage") == "待付款"
for flag in approved.risk_flags_json
)
def test_finance_can_mark_pending_payment_claim_as_paid() -> None:
current_user = CurrentUserContext(
username="finance-pay@example.com",
name="财务付款",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
claim = ExpenseClaim(
claim_no="EXP-FIN-PAY-201",
employee_name="张三",
department_name="市场部",
project_code="PRJ-A",
expense_type="transport",
reason="交通报销",
location="上海",
amount=Decimal("66.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="pending_payment",
approval_stage="待付款",
risk_flags_json=[],
)
db.add(claim)
db.commit()
paid = ExpenseClaimService(db).mark_claim_paid(claim.id, current_user)
assert paid is not None
assert paid.status == "paid"
assert paid.approval_stage == "已付款"
assert any(
isinstance(flag, dict)
and flag.get("source") == "payment"
and flag.get("event_type") == "expense_claim_payment_completed"
and flag.get("previous_status") == "pending_payment"
and flag.get("next_status") == "paid"
and flag.get("next_approval_stage") == "已付款"
for flag in paid.risk_flags_json
)
def test_return_claim_rejects_already_returned_claim_without_adding_event() -> None:
current_user = CurrentUserContext(
username="finance-returned@example.com",
name="财务",
role_codes=["finance"],
is_admin=False,
)
return_flag = {
"source": "manual_return",
"return_event_id": "return-event-existing",
"message": "请补充附件。",
"return_count": 1,
"return_stage": "直属领导审批",
"return_stage_key": "direct_manager",
}
with build_session() as db:
claim = ExpenseClaim(
claim_no="EXP-RET-202",
employee_name="张三",
department_name="市场部",
project_code="PRJ-A",
expense_type="transport",
reason="交通报销",
location="上海",
amount=Decimal("66.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=None,
status="returned",
approval_stage="待提交",
risk_flags_json=[return_flag],
)
db.add(claim)
db.commit()
claim_id = claim.id
with pytest.raises(ValueError, match="无需重复退回"):
ExpenseClaimService(db).return_claim(claim_id, current_user, reason="重复退回")
db.refresh(claim)
manual_returns = [
flag
for flag in list(claim.risk_flags_json or [])
if isinstance(flag, dict) and flag.get("source") == "manual_return"
]
assert manual_returns == [return_flag]
def test_return_claim_records_each_return_event_with_stage_reason_and_counts() -> None:
manager_user = CurrentUserContext(
username="manager-return-count@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
finance_user = CurrentUserContext(
username="finance-return@example.com",
name="财务复核",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E8130",
name="李经理",
email="manager-return-count@example.com",
)
employee = Employee(
employee_no="E8131",
name="张三",
email="zhangsan-return-count@example.com",
manager=manager,
)
db.add_all([manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="EXP-RET-301",
employee_id=employee.id,
employee_name="张三",
department_name="市场部",
project_code="PRJ-A",
expense_type="transport",
reason="交通报销",
location="上海",
amount=Decimal("66.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(claim)
db.commit()
claim_id = claim.id
service = ExpenseClaimService(db)
first_returned = service.return_claim(
claim_id,
manager_user,
reason="发票金额与明细金额不一致,请重新核对。",
reason_codes=["invoice_mismatch", "business_explanation"],
)
assert first_returned is not None
first_returned.status = "submitted"
first_returned.approval_stage = "财务审批"
first_returned.submitted_at = datetime(2026, 5, 12, 11, 0, tzinfo=UTC)
db.commit()
second_returned = service.return_claim(
claim_id,
finance_user,
reason="超标说明仍不完整,请补充制度例外依据。",
reason_codes=["over_policy"],
)
assert second_returned is not None
return_events = [
flag
for flag in list(second_returned.risk_flags_json or [])
if isinstance(flag, dict) and flag.get("source") == "manual_return"
]
assert len(return_events) == 2
assert return_events[0]["return_count"] == 1
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]["reason"] == "发票金额与明细金额不一致,请重新核对。"
assert return_events[0]["operator_role_codes"] == ["manager"]
assert return_events[1]["return_count"] == 2
assert return_events[1]["stage_return_count"] == 1
assert return_events[1]["return_stage"] == "财务审批"
assert return_events[1]["risk_points"] == ["超出制度标准或缺少超标说明"]
def test_submit_returned_claim_preserves_manual_return_events() -> None:
current_user = CurrentUserContext(
username="emp-submit-returned@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
return_flag = {
"source": "manual_return",
"return_event_id": "return-event-submit",
"message": "第一次退回:业务说明不完整。",
"reason": "业务说明不完整。",
"return_count": 1,
"return_stage": "直属领导审批",
"return_stage_key": "direct_manager",
"risk_points": ["业务事由/地点/人员信息不完整"],
}
with build_session() as db:
manager = Employee(
employee_no="E8200",
name="李经理",
email="manager-submit-returned@example.com",
)
employee = Employee(
employee_no="E8201",
name="张三",
email="emp-submit-returned@example.com",
manager=manager,
)
claim = build_claim(expense_type="office", location="上海")
claim.employee = employee
claim.employee_id = employee.id
claim.employee_name = "张三"
claim.department_name = "市场部"
claim.status = "returned"
claim.approval_stage = "待提交"
claim.risk_flags_json = [return_flag]
db.add_all([manager, employee, claim])
db.commit()
conversation = AgentConversationService(db).get_or_create_conversation(
conversation_id=None,
user_id=current_user.username,
source="user_message",
context_json={
"session_type": "expense",
"draft_claim_id": claim.id,
},
)
conversation_id = conversation.conversation_id
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.approval_stage == "直属领导审批"
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_return"
and flag.get("return_event_id") == "return-event-submit"
for flag in list(submitted.risk_flags_json or [])
)
assert AgentConversationService(db).get_conversation(conversation_id) is None
def test_manager_personal_claims_exclude_subordinate_pending_approval_claims() -> None:
current_user = CurrentUserContext(
username="manager-personal@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E8300",
name="李经理",
email="manager-personal@example.com",
)
employee = Employee(
employee_no="E8301",
name="张三",
email="zhangsan-personal@example.com",
manager=manager,
)
db.add_all([manager, employee])
db.flush()
db.add_all(
[
ExpenseClaim(
claim_no="EXP-MGR-OWN",
employee_id=manager.id,
employee_name="李经理",
department_name="市场部",
project_code="PRJ-MGR",
expense_type="office",
reason="本人报销",
location="上海",
amount=Decimal("88.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-MGR-SUB",
employee_id=employee.id,
employee_name="张三",
department_name="市场部",
project_code="PRJ-MGR",
expense_type="transport",
reason="下属待审批报销",
location="上海",
amount=Decimal("66.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 11, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
),
]
)
db.commit()
service = ExpenseClaimService(db)
personal_claims = service.list_claims(current_user)
approval_claims = service.list_approval_claims(current_user)
assert [claim.claim_no for claim in personal_claims] == ["EXP-MGR-OWN"]
assert [claim.claim_no for claim in approval_claims] == ["EXP-MGR-SUB"]
def test_list_approval_claims_allows_direct_manager_to_view_pending_claims_for_approval() -> None:
current_user = CurrentUserContext(
username="manager@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E8000",
name="李经理",
email="manager@example.com",
)
employee = Employee(
employee_no="E8001",
name="张三",
email="zhangsan@example.com",
manager=manager,
)
outsider_manager = Employee(
employee_no="E8002",
name="王经理",
email="other-manager@example.com",
)
outsider = Employee(
employee_no="E8003",
name="李四",
email="lisi@example.com",
manager=outsider_manager,
)
db.add_all([manager, employee, outsider_manager, outsider])
db.flush()
db.add_all(
[
ExpenseClaim(
claim_no="EXP-MGR-201",
employee_id=employee.id,
employee_name="张三",
department_name="市场部",
project_code="PRJ-MGR",
expense_type="transport",
reason="滴滴报销",
location="上海",
amount=Decimal("66.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=[],
),
ExpenseClaim(
claim_no="EXP-MGR-202",
employee_id=outsider.id,
employee_name="李四",
department_name="销售部",
project_code="PRJ-OTHER",
expense_type="meal",
reason="客户用餐",
location="杭州",
amount=Decimal("188.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 12, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 13, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
),
]
)
db.commit()
claims = ExpenseClaimService(db).list_approval_claims(current_user)
assert len(claims) == 1
assert claims[0].claim_no == "EXP-MGR-201"
def test_list_approval_claims_limits_finance_to_finance_stage_claims() -> None:
current_user = CurrentUserContext(
username="finance-approval-list@example.com",
name="财务",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
db.add_all(
[
ExpenseClaim(
claim_no="EXP-FIN-LIST-201",
employee_name="张三",
department_name="市场部",
project_code="PRJ-FIN",
expense_type="transport",
reason="直属领导待审",
location="上海",
amount=Decimal("66.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=[],
),
ExpenseClaim(
claim_no="EXP-FIN-LIST-202",
employee_name="李四",
department_name="销售部",
project_code="PRJ-FIN",
expense_type="meal",
reason="财务待审",
location="杭州",
amount=Decimal("188.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 12, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 13, 0, tzinfo=UTC),
status="submitted",
approval_stage="财务审批",
risk_flags_json=[],
),
]
)
db.commit()
claims = ExpenseClaimService(db).list_approval_claims(current_user)
assert [claim.claim_no for claim in claims] == ["EXP-FIN-LIST-202"]
def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applications() -> None:
current_user = CurrentUserContext(
username="budget-p8-list@example.com",
name="赵预算",
role_codes=["budget_monitor"],
is_admin=False,
)
p8_without_budget_role = CurrentUserContext(
username="p8-without-budget-list@example.com",
name="budget manager",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
budget_role = _seed_budget_monitor_role(db)
delivery_department = OrganizationUnit(
unit_code="DELIVERY-BUDGET-LIST",
name="交付部",
unit_type="department",
)
market_department = OrganizationUnit(
unit_code="MARKET-BUDGET-LIST",
name="市场部",
unit_type="department",
)
budget_manager = Employee(
employee_no="E-P8-BUDGET-LIST",
name="赵预算",
email="budget-p8-list@example.com",
grade="P8",
organization_unit=delivery_department,
roles=[budget_role],
)
p8_without_budget_employee = Employee(
employee_no="E-P8-NO-BUDGET-LIST",
name="P8 No Budget Role",
email="p8-without-budget-list@example.com",
grade="P8",
organization_unit=delivery_department,
)
employee = Employee(
employee_no="E-BUDGET-LIST-OWNER",
name="张三",
email="budget-list-owner@example.com",
organization_unit=delivery_department,
)
market_employee = Employee(
employee_no="E-BUDGET-LIST-MARKET",
name="王五",
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.flush()
db.add_all(
[
ExpenseClaim(
claim_no="APP-BUDGET-LIST-201",
employee_id=employee.id,
employee_name="张三",
department_id=delivery_department.id,
department_name="交付部",
project_code="PRJ-BUDGET",
expense_type="travel_application",
reason="预算待审申请",
location="上海",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
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=[],
),
ExpenseClaim(
claim_no="APP-BUDGET-LIST-OTHER-DEPT",
employee_id=market_employee.id,
employee_name="王五",
department_id=market_department.id,
department_name="市场部",
project_code="PRJ-BUDGET",
expense_type="travel_application",
reason="其他部门预算待审申请",
location="上海",
amount=Decimal("13000.00"),
currency="CNY",
invoice_count=0,
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=[],
),
ExpenseClaim(
claim_no="EXP-BUDGET-LIST-202",
employee_id=employee.id,
employee_name="张三",
department_id=delivery_department.id,
department_name="交付部",
project_code="PRJ-BUDGET",
expense_type="transport",
reason="财务待审报销",
location="上海",
amount=Decimal("88.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.commit()
claims = ExpenseClaimService(db).list_approval_claims(current_user)
assert [claim.claim_no for claim in claims] == ["APP-BUDGET-LIST-201"]
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)
assert [claim.claim_no for claim in claims_without_budget_role] == []