- 修复员工创建时组织架构关联与邮箱校验逻辑 - 修复报销单API端点参数及预审流程调用 - 优化审批中心、差旅详情等前端页面交互 - 更新侧边栏导航与请求视图模型 - 补充员工服务与报销单相关测试用例
1409 lines
50 KiB
Python
1409 lines
50 KiB
Python
from __future__ import annotations
|
||
|
||
from datetime import UTC, date, datetime
|
||
from decimal import Decimal
|
||
|
||
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.employee import Employee
|
||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||
from app.models.organization import OrganizationUnit
|
||
from app.schemas.ontology import OntologyParseRequest
|
||
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
|
||
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate
|
||
from app.services.expense_claims import ExpenseClaimService
|
||
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 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_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_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_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_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_generate_claim_no_uses_max_suffix_instead_of_count() -> 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 service._generate_claim_no(datetime(2026, 5, 14, tzinfo=UTC)) == "EXP-202605-004"
|
||
|
||
|
||
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="EXP-202605-004",
|
||
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(["EXP-202605-004", "EXP-202605-005"])
|
||
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 == "EXP-202605-005"
|
||
assert result["claim_no"] == "EXP-202605-005"
|
||
|
||
|
||
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_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(ExpenseClaimService, "_get_attachment_storage_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_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(ExpenseClaimService, "_get_attachment_storage_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_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_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(ExpenseClaimService, "_get_attachment_storage_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(ExpenseClaimService, "_get_attachment_storage_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(ExpenseClaimService, "_get_attachment_storage_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_allows_finance_to_view_all_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-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="submitted",
|
||
approval_stage="finance_review",
|
||
risk_flags_json=[],
|
||
),
|
||
ExpenseClaim(
|
||
claim_no="EXP-FIN-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="approved",
|
||
approval_stage="completed",
|
||
risk_flags_json=[],
|
||
),
|
||
]
|
||
)
|
||
db.commit()
|
||
|
||
claims = ExpenseClaimService(db).list_claims(current_user)
|
||
|
||
assert len(claims) == 2
|
||
assert {claim.claim_no for claim in claims} == {"EXP-FIN-101", "EXP-FIN-102"}
|
||
|
||
|
||
def test_list_claims_allows_executive_to_view_all_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-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="submitted",
|
||
approval_stage="直属领导审批",
|
||
risk_flags_json=[],
|
||
),
|
||
ExpenseClaim(
|
||
claim_no="EXP-EXE-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="approved",
|
||
approval_stage="completed",
|
||
risk_flags_json=[],
|
||
),
|
||
]
|
||
)
|
||
db.commit()
|
||
|
||
claims = ExpenseClaimService(db).list_claims(current_user)
|
||
|
||
assert len(claims) == 2
|
||
assert {claim.claim_no for claim in claims} == {"EXP-EXE-101", "EXP-EXE-102"}
|
||
|
||
|
||
def test_privileged_user_can_return_and_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
|
||
)
|
||
|
||
deleted = service.delete_claim(claim_id, current_user)
|
||
|
||
assert deleted is not None
|
||
assert deleted.claim_no == "EXP-RET-101"
|
||
assert db.get(ExpenseClaim, claim_id) is None
|
||
|
||
|
||
def test_list_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_claims(current_user)
|
||
|
||
assert len(claims) == 1
|
||
assert claims[0].claim_no == "EXP-MGR-201"
|