feat: 细化差旅票据费用明细分类并自动计算出差补贴
将差旅费用明细拆分为火车票、机票、住宿票、乘车等细分类 型,根据票据字段自动生成行程/事由描述,结合规则引擎自 动计算出差补贴金额,前端适配费用明细编辑和差旅票据审 核交互,补充单元测试覆盖。
This commit is contained in:
@@ -15,7 +15,7 @@ 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.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate, ExpenseClaimUpdate
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.ontology import SemanticOntologyService
|
||||
@@ -405,6 +405,92 @@ def test_upsert_draft_from_ontology_supports_link_or_create_for_multi_documents(
|
||||
assert float(new_claim.amount) == 50.5
|
||||
|
||||
|
||||
def test_upsert_travel_draft_uses_ticket_item_types_and_auto_allowance() -> None:
|
||||
user_id = "travel-allowance@example.com"
|
||||
|
||||
with build_session() as db:
|
||||
employee = Employee(
|
||||
employee_no="E5010",
|
||||
name="差旅员工",
|
||||
email=user_id,
|
||||
grade="P4",
|
||||
)
|
||||
db.add(employee)
|
||||
db.commit()
|
||||
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="我去北京出差 3 天,上传了火车票,帮我生成差旅费报销草稿",
|
||||
user_id=user_id,
|
||||
)
|
||||
)
|
||||
result = ExpenseClaimService(db).upsert_draft_from_ontology(
|
||||
run_id=ontology.run_id,
|
||||
user_id=user_id,
|
||||
message="我去北京出差 3 天,上传了火车票,帮我生成差旅费报销草稿",
|
||||
ontology=ontology,
|
||||
context_json={
|
||||
"name": "差旅员工",
|
||||
"grade": "P4",
|
||||
"attachment_names": ["train-ticket.png"],
|
||||
"attachment_count": 1,
|
||||
"review_form_values": {
|
||||
"expense_type": "差旅费",
|
||||
"location": "北京",
|
||||
"time_range": "2026-05-13 至 2026-05-15",
|
||||
},
|
||||
"business_time_context": {
|
||||
"mode": "range",
|
||||
"start_date": "2026-05-13",
|
||||
"end_date": "2026-05-15",
|
||||
"display_value": "2026-05-13 至 2026-05-15",
|
||||
},
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "train-ticket.png",
|
||||
"document_type": "train_ticket",
|
||||
"document_type_label": "火车/高铁票",
|
||||
"scene_code": "travel",
|
||||
"scene_label": "差旅费",
|
||||
"summary": "中国铁路电子客票 广州南-北京南 二等座 票价 354 元",
|
||||
"text": "中国铁路电子客票 广州南-北京南 二等座 票价:¥354.00",
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "票价", "value": "¥354.00"},
|
||||
{"key": "route", "label": "行程", "value": "广州南-北京南"},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
claim = db.get(ExpenseClaim, result["claim_id"])
|
||||
assert claim is not None
|
||||
assert claim.expense_type == "travel"
|
||||
assert claim.invoice_count == 1
|
||||
assert len(claim.items) == 2
|
||||
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 allowance_item.item_amount == Decimal("300.00")
|
||||
assert allowance_item.invoice_id is None
|
||||
assert allowance_item.is_system_generated is True
|
||||
assert claim.amount == Decimal("654.00")
|
||||
|
||||
with pytest.raises(ValueError, match="系统自动计算"):
|
||||
ExpenseClaimService(db).update_claim_item(
|
||||
claim_id=claim.id,
|
||||
item_id=allowance_item.id,
|
||||
payload=ExpenseClaimItemUpdate(item_amount=Decimal("1.00")),
|
||||
current_user=CurrentUserContext(
|
||||
username=user_id,
|
||||
name="差旅员工",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_upsert_draft_from_ontology_updates_returned_claim_and_preserves_return_events() -> None:
|
||||
user_id = "returned-owner@example.com"
|
||||
return_flag = {
|
||||
@@ -635,6 +721,42 @@ def test_create_claim_item_adds_blank_draft_row_without_forcing_attachment() ->
|
||||
assert new_item.invoice_id is None
|
||||
|
||||
|
||||
def test_update_claim_reason_only_allows_draft_pending_submission() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-1",
|
||||
name="张三",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
claim = build_claim(expense_type="travel", location="北京")
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
updated = service.update_claim(
|
||||
claim_id=claim.id,
|
||||
payload=ExpenseClaimUpdate(reason="去北京客户现场出差,处理项目验收事项"),
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
assert updated is not None
|
||||
assert updated.reason == "去北京客户现场出差,处理项目验收事项"
|
||||
|
||||
claim.status = "submitted"
|
||||
claim.submitted_at = datetime(2026, 5, 14, tzinfo=UTC)
|
||||
claim.approval_stage = "直属领导审批"
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(ValueError, match="草稿待提交"):
|
||||
service.update_claim(
|
||||
claim_id=claim.id,
|
||||
payload=ExpenseClaimUpdate(reason="提交后不能改"),
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
|
||||
def test_update_claim_item_reanalyzes_existing_attachment(monkeypatch, tmp_path) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-1",
|
||||
@@ -785,6 +907,8 @@ def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_p
|
||||
assert updated["claim_amount"] == Decimal("354.00")
|
||||
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.amount == Decimal("354.00")
|
||||
uploaded_meta = service.get_claim_item_attachment_meta(
|
||||
claim_id=claim.id,
|
||||
@@ -799,6 +923,75 @@ def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_p
|
||||
)
|
||||
|
||||
|
||||
def test_upload_ride_receipt_backfills_item_reason_from_addresses(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="ride-receipt.png",
|
||||
media_type="image/png",
|
||||
text="滴滴出行订单 起点:深圳北站 终点:腾讯滨海大厦 实付金额:42.00元",
|
||||
summary="滴滴出行乘车票据,深圳北站到腾讯滨海大厦,金额 42 元。",
|
||||
avg_score=0.98,
|
||||
line_count=1,
|
||||
page_count=1,
|
||||
document_type="taxi_receipt",
|
||||
document_type_label="出租车/网约车票据",
|
||||
scene_code="transport",
|
||||
scene_label="交通票据",
|
||||
document_fields=[
|
||||
{"key": "start_location", "label": "起点", "value": "深圳北站"},
|
||||
{"key": "end_location", "label": "终点", "value": "腾讯滨海大厦"},
|
||||
{"key": "amount", "label": "实付金额", "value": "42.00元"},
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
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="transport", location="深圳")
|
||||
claim.amount = Decimal("0.00")
|
||||
claim.invoice_count = 0
|
||||
claim.items[0].item_type = "transport"
|
||||
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="ride-receipt.png",
|
||||
content=b"fake-image-bytes",
|
||||
media_type="image/png",
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
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_amount == Decimal("42.00")
|
||||
assert claim.amount == Decimal("42.00")
|
||||
|
||||
|
||||
def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_path) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-1",
|
||||
|
||||
@@ -1315,6 +1315,230 @@ def test_user_agent_review_payload_prefers_hotel_invoice_for_hotel_name() -> Non
|
||||
assert slot_map["merchant_name"].value == "北京中心酒店"
|
||||
|
||||
|
||||
def test_user_agent_review_payload_does_not_fill_hotel_name_from_train_ticket() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
query = "我去北京出差,先上传一张火车票,酒店发票还没上传,帮我生成差旅费报销草稿"
|
||||
context = {
|
||||
"name": "张三",
|
||||
"grade": "P4",
|
||||
"attachment_names": ["北京南站火车票.png"],
|
||||
"attachment_count": 1,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "北京南站火车票.png",
|
||||
"document_type": "train_ticket",
|
||||
"scene_code": "travel",
|
||||
"scene_label": "差旅票据",
|
||||
"summary": "电子发票(铁路电子客票) 2026-03-04 广州南至北京南 G123 二等座 金额 560 元 中国铁路",
|
||||
"text": "电子发票(铁路电子客票) 2026-03-04 广州南至北京南 G123 二等座 票价 ¥560.00 中国铁路祝您旅途愉快",
|
||||
"avg_score": 0.95,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "金额", "value": "560"},
|
||||
{"key": "route", "label": "行程", "value": "广州南-北京南"},
|
||||
{"key": "date", "label": "业务发生时间", "value": "2026-03-04"},
|
||||
{"key": "merchant_name", "label": "商户", "value": "中国铁路"},
|
||||
],
|
||||
"warnings": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest-train-only-hotel-name@example.com",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest-train-only-hotel-name@example.com",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
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["merchant_name"].value == ""
|
||||
assert "酒店/商户" not in response.review_payload.missing_slots
|
||||
assert "酒店的报销票据待上传(必须)" in response.review_payload.missing_slots
|
||||
assert response.review_payload.can_proceed is False
|
||||
assert [item.action_type for item in response.review_payload.confirmation_actions if item.emphasis == "primary"] == [
|
||||
"save_draft"
|
||||
]
|
||||
assert "继续下一步" not in [item.label for item in response.review_payload.confirmation_actions]
|
||||
assert "酒店住宿发票/住宿清单(必须,当前待上传)" in response.answer
|
||||
assert "市内交通/乘车票据(非必须" in response.answer
|
||||
assert "只能保存为草稿" in response.answer or "保存为草稿" in response.answer
|
||||
assert "您的职级为:P4" in response.answer
|
||||
assert "去北京" in response.answer
|
||||
assert "已提交火车 560.00 元" in response.answer
|
||||
field_labels = [
|
||||
field.label
|
||||
for card in response.review_payload.document_cards
|
||||
for field in card.fields
|
||||
]
|
||||
assert "商户/酒店" not in field_labels
|
||||
|
||||
|
||||
def test_user_agent_review_payload_allows_next_step_when_only_optional_ride_receipt_is_missing() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
query = "我去北京出差,上传了火车票和酒店票,帮我生成差旅费报销草稿"
|
||||
context = {
|
||||
"name": "张三",
|
||||
"grade": "P4",
|
||||
"review_form_values": {"occurred_date": "2026-03-04"},
|
||||
"attachment_names": ["北京南站火车票.png", "北京酒店发票.png"],
|
||||
"attachment_count": 2,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "北京南站火车票.png",
|
||||
"document_type": "train_ticket",
|
||||
"scene_code": "travel",
|
||||
"scene_label": "差旅票据",
|
||||
"summary": "电子发票(铁路电子客票) 2026-03-04 广州南至北京南 G123 二等座 金额 560 元",
|
||||
"text": "电子发票(铁路电子客票) 2026-03-04 广州南至北京南 G123 二等座 票价 ¥560.00",
|
||||
"avg_score": 0.95,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "金额", "value": "560"},
|
||||
{"key": "route", "label": "行程", "value": "广州南-北京南"},
|
||||
],
|
||||
"warnings": [],
|
||||
},
|
||||
{
|
||||
"filename": "北京酒店发票.png",
|
||||
"document_type": "hotel_invoice",
|
||||
"summary": "北京中心酒店 住宿 1 晚 金额 450 元",
|
||||
"text": "北京中心酒店 住宿 1 晚 金额 450 元",
|
||||
"avg_score": 0.96,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "金额", "value": "450"},
|
||||
{"key": "merchant", "label": "酒店名称", "value": "北京中心酒店"},
|
||||
],
|
||||
"warnings": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest-travel-optional-ride@example.com",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest-travel-optional-ride@example.com",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
tool_payload={"draft_only": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
assert response.review_payload.can_proceed is True
|
||||
assert response.review_payload.missing_slots == []
|
||||
receipt_brief = next(item for item in response.review_payload.risk_briefs if item.title == "差旅票据待补充")
|
||||
assert receipt_brief.level == "info"
|
||||
assert "市内交通/乘车票据可继续上传(非必须)" in receipt_brief.content
|
||||
assert "酒店的报销票据待上传(必须)" not in receipt_brief.content
|
||||
action_types = [item.action_type for item in response.review_payload.confirmation_actions]
|
||||
assert "save_draft" in action_types
|
||||
assert "next_step" in action_types
|
||||
assert "市内交通/乘车票据(非必须" in response.answer
|
||||
assert "也可以继续下一步" in response.answer
|
||||
|
||||
|
||||
def test_user_agent_review_payload_allows_next_step_after_required_travel_receipts_are_complete() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
query = "我去北京出差,上传了火车票、酒店票和打车票,帮我生成差旅费报销草稿"
|
||||
context = {
|
||||
"name": "张三",
|
||||
"grade": "P4",
|
||||
"review_form_values": {"occurred_date": "2026-03-04"},
|
||||
"attachment_names": ["北京南站火车票.png", "北京酒店发票.png", "北京打车票.png"],
|
||||
"attachment_count": 3,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "北京南站火车票.png",
|
||||
"document_type": "train_ticket",
|
||||
"scene_code": "travel",
|
||||
"scene_label": "差旅票据",
|
||||
"summary": "电子发票(铁路电子客票) 2026-03-04 广州南至北京南 G123 二等座 金额 560 元",
|
||||
"text": "电子发票(铁路电子客票) 2026-03-04 广州南至北京南 G123 二等座 票价 ¥560.00",
|
||||
"avg_score": 0.95,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "金额", "value": "560"},
|
||||
{"key": "route", "label": "行程", "value": "广州南-北京南"},
|
||||
{"key": "date", "label": "日期", "value": "2026-03-04"},
|
||||
],
|
||||
"warnings": [],
|
||||
},
|
||||
{
|
||||
"filename": "北京酒店发票.png",
|
||||
"document_type": "hotel_invoice",
|
||||
"summary": "北京中心酒店 住宿 1 晚 金额 450 元",
|
||||
"text": "北京中心酒店 住宿 1 晚 金额 450 元",
|
||||
"avg_score": 0.96,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "金额", "value": "450"},
|
||||
{"key": "merchant", "label": "酒店名称", "value": "北京中心酒店"},
|
||||
],
|
||||
"warnings": [],
|
||||
},
|
||||
{
|
||||
"filename": "北京打车票.png",
|
||||
"document_type": "taxi_receipt",
|
||||
"summary": "北京网约车 打车票 支付金额 32 元",
|
||||
"text": "北京网约车 打车票 支付金额 32 元",
|
||||
"avg_score": 0.94,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "支付金额", "value": "32"},
|
||||
],
|
||||
"warnings": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest-travel-complete@example.com",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest-travel-complete@example.com",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
tool_payload={"draft_only": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
assert response.review_payload.can_proceed is True
|
||||
assert response.review_payload.missing_slots == []
|
||||
action_types = [item.action_type for item in response.review_payload.confirmation_actions]
|
||||
assert "save_draft" in action_types
|
||||
assert "next_step" in action_types
|
||||
assert not any(item.title == "差旅票据待补充" for item in response.review_payload.risk_briefs)
|
||||
assert "无需继续上传票据" in response.answer
|
||||
assert "当前信息已较完整" in response.answer
|
||||
|
||||
|
||||
def test_user_agent_review_payload_prechecks_taxi_amount_against_rule_standard() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
|
||||
Reference in New Issue
Block a user