feat: 增强差旅报销审核流程与票据智能推理

优化本体解析和编排器的差旅场景处理能力,完善报销单草稿
保存和费用明细同步逻辑,前端报销创建页面增加行程推理和
票据审核交互,新增助手会话快照工具函数,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-21 16:09:47 +08:00
parent f28d7e6d16
commit e701fa01da
33 changed files with 3033 additions and 337 deletions

View File

@@ -51,6 +51,18 @@ def test_document_intelligence_extracts_larger_decimal_amount_from_multiple_cand
assert any(field.label == "金额" and field.value == "13.4元" for field in insight.fields)
def test_document_intelligence_extracts_hotel_total_fee_instead_of_date_year() -> None:
insight = build_document_insight(
filename="hotel-invoice.png",
summary="酒店住宿票据",
text="北京中心酒店 金额 2026-02-20 入住 总费用是828元 离店日期 2026-02-21",
)
assert insight.document_type == "hotel_invoice"
assert any(field.label == "金额" and field.value == "828元" for field in insight.fields)
assert not any(field.label == "金额" and field.value == "2026元" for field in insight.fields)
def test_document_intelligence_prefers_train_ticket_for_railway_e_ticket_invoice_text() -> None:
insight = build_document_insight(
filename="铁路电子客票.pdf",

View File

@@ -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:

View File

@@ -414,11 +414,11 @@ def test_semantic_ontology_service_uses_client_local_date_for_relative_time() ->
assert result.time_range.end_date == "2026-05-12"
def test_semantic_ontology_service_extracts_day_before_yesterday_from_client_local_date() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
def test_semantic_ontology_service_extracts_day_before_yesterday_from_client_local_date() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我前天请客户吃饭花了200元",
user_id="pytest",
context_json={
@@ -427,12 +427,77 @@ def test_semantic_ontology_service_extracts_day_before_yesterday_from_client_loc
},
)
)
assert result.time_range.raw == "前天"
assert result.time_range.start_date == "2026-05-11"
assert result.time_range.end_date == "2026-05-11"
assert result.time_range.raw == "前天"
assert result.time_range.start_date == "2026-05-11"
assert result.time_range.end_date == "2026-05-11"
def test_semantic_ontology_service_treats_status_document_text_as_query() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="查询草稿的单据",
user_id="pytest",
)
)
assert result.scenario == "expense"
assert result.intent == "query"
assert result.permission.level == "read"
assert any(
item.field == "status" and item.value == "draft"
for item in result.constraints
)
def test_semantic_ontology_service_extracts_history_query_time_and_location() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我去年去北京报销的单据",
user_id="pytest",
context_json={
"client_now_iso": "2026-05-21T04:00:00.000Z",
"client_timezone_offset_minutes": -480,
},
)
)
assert result.scenario == "expense"
assert result.intent == "query"
assert result.time_range.raw == "去年"
assert result.time_range.start_date == "2025-01-01"
assert result.time_range.end_date == "2025-12-31"
assert any(
item.type == "location" and item.normalized_value == "北京"
for item in result.entities
)
def test_semantic_ontology_service_understands_last_week_claim_progress_query() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我上周提交的单据报销了么?",
user_id="pytest",
context_json={
"client_now_iso": "2026-05-21T04:00:00.000Z",
"client_timezone_offset_minutes": -480,
},
)
)
assert result.scenario == "expense"
assert result.intent == "query"
assert result.time_range.raw == "上周"
assert result.time_range.start_date == "2026-05-11"
assert result.time_range.end_date == "2026-05-17"
def test_semantic_ontology_service_maps_office_supplies_to_office_expense_type() -> None:
session_factory = build_session_factory()
with session_factory() as db:

View File

@@ -202,7 +202,7 @@ def test_conversation_hydration_does_not_reuse_review_type_for_fresh_expense_pro
fresh_context = service.hydrate_context_json(
conversation=conversation,
context_json={},
context_json={"draft_claim_id": "claim-old"},
message="业务发生时间:2026-02-20 至 2026-02-23去上海支持上海电力部署项目申请报销",
)
continued_context = service.hydrate_context_json(
@@ -217,3 +217,183 @@ def test_conversation_hydration_does_not_reuse_review_type_for_fresh_expense_pro
assert fresh_context["conversation_state"]["review_form_values"]["expense_type"] == "差旅费"
assert continued_context["draft_claim_id"] == "claim-old"
assert continued_context["review_form_values"]["expense_type"] == "差旅费"
def test_orchestrator_history_query_filters_location_time_and_returns_real_amount(
monkeypatch,
) -> None:
monkeypatch.setattr(
"app.services.runtime_chat.RuntimeChatService.complete",
lambda *_args, **_kwargs: None,
)
session_factory = build_session_factory()
with session_factory() as db:
employee = Employee(
id="emp-history-query",
employee_no="E9020",
name="张三",
email="history-query@example.com",
)
beijing_claim = ExpenseClaim(
id="claim-history-beijing",
claim_no="EXP-202506-001",
employee=employee,
employee_id=employee.id,
employee_name="张三",
department_name="交付部",
expense_type="travel",
reason="去北京支持客户项目",
location="北京",
amount=Decimal("321.45"),
currency="CNY",
invoice_count=2,
occurred_at=datetime(2025, 6, 18, 9, 0, tzinfo=UTC),
submitted_at=datetime(2025, 6, 19, 10, 0, tzinfo=UTC),
status="paid",
approval_stage="已入账",
)
shanghai_claim = ExpenseClaim(
id="claim-history-shanghai",
claim_no="EXP-202507-001",
employee=employee,
employee_id=employee.id,
employee_name="张三",
department_name="交付部",
expense_type="travel",
reason="去上海支持项目",
location="上海",
amount=Decimal("888.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2025, 7, 8, 9, 0, tzinfo=UTC),
submitted_at=datetime(2025, 7, 9, 10, 0, tzinfo=UTC),
status="paid",
approval_stage="已入账",
)
current_year_claim = ExpenseClaim(
id="claim-history-beijing-current",
claim_no="EXP-202601-001",
employee=employee,
employee_id=employee.id,
employee_name="张三",
department_name="交付部",
expense_type="travel",
reason="去北京支持年度项目",
location="北京",
amount=Decimal("666.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 1, 8, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 1, 9, 10, 0, tzinfo=UTC),
status="paid",
approval_stage="已入账",
)
db.add_all([employee, beijing_claim, shanghai_claim, current_year_claim])
db.commit()
response = OrchestratorService(db).run(
OrchestratorRequest(
source="user_message",
user_id="history-query@example.com",
message="我去年去北京报销的单据",
context_json={
"client_now_iso": "2026-05-21T04:00:00.000Z",
"client_timezone_offset_minutes": -480,
},
)
)
query_payload = response.result["query_payload"]
assert response.status == "succeeded"
assert response.trace_summary.scenario == "expense"
assert response.trace_summary.intent == "query"
assert query_payload["record_count"] == 1
assert query_payload["total_amount"] == 321.45
assert [item["claim_no"] for item in query_payload["records"]] == ["EXP-202506-001"]
assert "321.45" in response.result["answer"]
def test_orchestrator_expense_preview_does_not_persist_claim_before_user_action(
monkeypatch,
) -> None:
monkeypatch.setattr(
"app.services.runtime_chat.RuntimeChatService.complete",
lambda *_args, **_kwargs: None,
)
session_factory = build_session_factory()
with session_factory() as db:
employee = Employee(
employee_no="E9030",
name="预览员工",
email="preview-orchestrator@example.com",
)
db.add(employee)
db.commit()
response = OrchestratorService(db).run(
OrchestratorRequest(
source="user_message",
user_id="preview-orchestrator@example.com",
message="业务发生时间:2026-03-04打车去客户现场交通费32元请帮我看看怎么报",
context_json={
"name": "预览员工",
"user_input_text": "业务发生时间:2026-03-04打车去客户现场交通费32元请帮我看看怎么报",
},
)
)
user_claims = [
claim
for claim in db.query(ExpenseClaim).all()
if claim.employee_name == "预览员工"
]
assert response.status == "succeeded"
assert response.result.get("review_payload") is not None
assert response.result.get("draft_payload") is None
assert "尚未保存为草稿" in response.result["answer"]
assert user_claims == []
def test_orchestrator_prompts_scene_choices_before_review_for_fresh_ambiguous_expense(
monkeypatch,
) -> None:
monkeypatch.setattr(
"app.services.runtime_chat.RuntimeChatService.complete",
lambda *_args, **_kwargs: None,
)
session_factory = build_session_factory()
with session_factory() as db:
service = AgentConversationService(db)
conversation = service.get_or_create_conversation(
conversation_id="conv-scene-choice",
user_id="emp-scene-choice@example.com",
source="user_message",
context_json={
"session_type": "expense",
"draft_claim_id": "claim-old",
"review_form_values": {
"expense_type": "差旅费",
"business_location": "北京",
},
},
)
response = OrchestratorService(db).run(
OrchestratorRequest(
source="user_message",
user_id="emp-scene-choice@example.com",
conversation_id=conversation.conversation_id,
message="业务发生时间:2026-02-20 至 2026-02-23去上海支持上海电力部署项目申请报销",
context_json={
"session_type": "expense",
"draft_claim_id": "claim-old",
},
)
)
result = response.result
assert response.status == "succeeded"
assert result.get("review_payload") is None
assert result.get("draft_payload") is None
assert "请先在下面选择报销场景" in result["answer"]
assert [item["label"] for item in result["suggested_actions"][:3]] == ["差旅费", "交通费", "住宿费"]

View File

@@ -496,25 +496,18 @@ def test_user_agent_guides_generic_expense_request() -> None:
)
)
assert response.review_payload is not None
assert response.answer == response.review_payload.body_message
assert response.review_payload.can_proceed is False
assert response.review_payload.missing_slots == [
"报销类型",
"发生时间",
"金额",
"事由说明",
"票据附件",
]
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"cancel_review",
"edit_review",
assert response.review_payload is None
assert response.draft_payload is None
assert "请先在下面选择报销场景" in response.answer
assert [item.action_type for item in response.suggested_actions] == [
"select_expense_type",
"select_expense_type",
"select_expense_type",
"select_expense_type",
"select_expense_type",
"select_expense_type",
]
edit_action = next(
item for item in response.review_payload.confirmation_actions if item.action_type == "edit_review"
)
assert edit_action.label == "选择报销类型"
assert edit_action.emphasis == "primary"
assert [item.label for item in response.suggested_actions[:3]] == ["差旅费", "交通费", "住宿费"]
def test_user_agent_asks_for_type_when_trip_context_is_ambiguous() -> None:
@@ -537,25 +530,69 @@ def test_user_agent_asks_for_type_when_trip_context_is_ambiguous() -> None:
)
)
assert response.review_payload is None
assert response.draft_payload is None
assert "请先在下面选择报销场景" in response.answer
assert "避免系统先入为主" in response.answer
assert [item.label for item in response.suggested_actions] == [
"差旅费",
"交通费",
"住宿费",
"业务招待费",
"办公费",
"其他费用",
]
assert response.suggested_actions[0].payload["original_message"] == message
def test_user_agent_continues_identification_after_expense_type_selection() -> None:
session_factory = build_session_factory()
with session_factory() as db:
message = "业务发生时间:2026-02-20 至 2026-02-23去上海支持上海电力部署项目申请报销"
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=f"{message}\n用户选择报销场景:差旅费",
user_id="pytest-selected-type@example.com",
context_json={
"expense_scene_selection": {
"expense_type": "travel",
"expense_type_label": "差旅费",
"original_message": message,
},
"review_form_values": {
"expense_type": "差旅费",
},
"user_input_text": message,
},
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-selected-type@example.com",
message=f"{message}\n用户选择报销场景:差旅费",
ontology=ontology,
context_json={
"expense_scene_selection": {
"expense_type": "travel",
"expense_type_label": "差旅费",
"original_message": message,
},
"review_form_values": {
"expense_type": "差旅费",
},
"user_input_text": message,
},
tool_payload={"draft_only": True},
)
)
assert response.review_payload is not None
slot_map = {item.key: item for item in response.review_payload.slot_cards}
assert slot_map["expense_type"].value == ""
assert slot_map["expense_type"].status == "missing"
assert slot_map["expense_type"].value == "差旅费"
assert slot_map["expense_type"].normalized_value == "travel"
assert slot_map["time_range"].value == "2026-02-20 至 2026-02-23"
assert slot_map["location"].value == "上海"
assert response.review_payload.can_proceed is False
assert "报销类型" in response.review_payload.missing_slots
assert "选择报销类型" in response.review_payload.body_message
assert "不会重新改判报销类型" in response.review_payload.body_message
edit_action = next(
item for item in response.review_payload.confirmation_actions if item.action_type == "edit_review"
)
assert edit_action.label == "选择报销类型"
assert edit_action.emphasis == "primary"
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"cancel_review",
"edit_review",
]
def test_user_agent_guides_implicit_expense_draft_request() -> None: