feat: 细化差旅票据费用明细分类并自动计算出差补贴

将差旅费用明细拆分为火车票、机票、住宿票、乘车等细分类
型,根据票据字段自动生成行程/事由描述,结合规则引擎自
动计算出差补贴金额,前端适配费用明细编辑和差旅票据审
核交互,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-21 10:57:06 +08:00
parent 8f65661809
commit b183b0bd5e
26 changed files with 2588 additions and 362 deletions

View File

@@ -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",