feat(server): 优化费用报销服务,改进报销单创建和数据校验逻辑,增强单元测试覆盖
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -631,6 +631,409 @@ def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_pat
|
||||
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_blocks_high_risk_attachment_at_ai_review(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 == "supplement"
|
||||
assert submitted.approval_stage == "待补充"
|
||||
assert submitted.submitted_at is 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_blocks_travel_route_mismatch_without_explanation(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 == "supplement"
|
||||
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_blocks_hotel_amount_over_travel_policy_without_explanation(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 == "supplement"
|
||||
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",
|
||||
@@ -753,3 +1156,84 @@ def test_list_claims_allows_finance_to_view_all_records() -> None:
|
||||
|
||||
assert len(claims) == 2
|
||||
assert {claim.claim_no for claim in claims} == {"EXP-FIN-101", "EXP-FIN-102"}
|
||||
|
||||
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user