feat: 增强差旅报销审核流程与票据智能推理
优化本体解析和编排器的差旅场景处理能力,完善报销单草稿 保存和费用明细同步逻辑,前端报销创建页面增加行程推理和 票据审核交互,新增助手会话快照工具函数,补充单元测试。
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
@@ -69,6 +69,10 @@ def build_session() -> Session:
|
||||
return session_factory()
|
||||
|
||||
|
||||
def _count_claims(db: Session) -> int:
|
||||
return int(db.query(ExpenseClaim).count())
|
||||
|
||||
|
||||
def test_validate_claim_for_submission_allows_office_claim_without_location() -> None:
|
||||
service = ExpenseClaimService.__new__(ExpenseClaimService)
|
||||
claim = build_claim(expense_type="office", location="待补充")
|
||||
@@ -99,6 +103,112 @@ def test_validate_claim_for_submission_still_requires_location_for_travel_claim(
|
||||
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 _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_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(
|
||||
[],
|
||||
@@ -574,6 +684,83 @@ def test_upsert_travel_draft_uses_ticket_item_types_and_auto_allowance() -> None
|
||||
)
|
||||
|
||||
|
||||
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 = {
|
||||
@@ -989,6 +1176,9 @@ def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_p
|
||||
|
||||
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")
|
||||
@@ -1018,6 +1208,86 @@ def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_p
|
||||
assert not any("用途字段" in point for point in uploaded_meta["analysis"]["points"])
|
||||
|
||||
|
||||
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(ExpenseClaimService, "_get_attachment_storage_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_attachment_analysis_does_not_compare_business_purpose_with_ticket_scene() -> None:
|
||||
with build_session() as db:
|
||||
claim = build_claim(expense_type="travel", location="上海")
|
||||
@@ -1053,7 +1323,7 @@ def test_attachment_analysis_does_not_compare_business_purpose_with_ticket_scene
|
||||
|
||||
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"])
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user