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",
|
||||
|
||||
Reference in New Issue
Block a user