feat: 完善差旅票据行程提取与费用明细回填逻辑

增强文档智能识别的票据场景关键词和字段提取能力,优化
会话关联草稿报销单的解析路径,修复费用明细合并和票据
去重边界问题,前端改进报销创建和审批详情交互,补充单
元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-21 14:24:51 +08:00
parent b183b0bd5e
commit f28d7e6d16
24 changed files with 1565 additions and 433 deletions

View File

@@ -207,6 +207,89 @@ def test_upsert_draft_from_ontology_defers_multi_document_association_choice() -
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"
@@ -471,7 +554,7 @@ def test_upsert_travel_draft_uses_ticket_item_types_and_auto_allowance() -> None
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 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
@@ -864,8 +947,8 @@ def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_p
OcrRecognizeDocumentRead(
filename="train-ticket.png",
media_type="image/png",
text="中国铁路电子客票 广州南-北京南 二等座 票价:¥354.00",
summary="铁路电子客票,票价 354 元。",
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,
@@ -874,6 +957,8 @@ def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_p
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"},
],
)
@@ -908,7 +993,8 @@ def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_p
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_reason == "从广州南到北京南"
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,
@@ -917,10 +1003,97 @@ def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_p
)
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_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(service, "_resolve_attachment_path", lambda storage_key: file_path)
monkeypatch.setattr(
service,
"_read_attachment_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:
@@ -987,7 +1160,7 @@ def test_upload_ride_receipt_backfills_item_reason_from_addresses(monkeypatch, t
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_reason == "深圳北站-腾讯滨海大厦"
assert claim.items[0].item_amount == Decimal("42.00")
assert claim.amount == Decimal("42.00")