feat(server): 重构报销单服务,优化费用报销流程和数据校验逻辑,包含schema定义和服务实现

This commit is contained in:
caoxiaozhu
2026-05-14 15:42:45 +00:00
parent ad16358e71
commit e21f0d82e9
4 changed files with 1187 additions and 78 deletions

View File

@@ -11,9 +11,11 @@ from app.api.deps import CurrentUserContext
from app.db.base import Base
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.schemas.ontology import OntologyParseRequest
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate
from app.services.expense_claims import ExpenseClaimService
from app.services.ontology import SemanticOntologyService
from app.services.ocr import OcrService
@@ -97,6 +99,347 @@ def test_resolve_expense_type_maps_office_supplies_review_value_to_office() -> N
assert expense_type == "office"
def test_upsert_draft_from_ontology_defers_multi_document_association_choice() -> None:
user_id = "zhangsan@example.com"
with build_session() as db:
employee = Employee(
employee_no="E5001",
name="张三",
email=user_id,
)
db.add(employee)
db.flush()
existing_claim = ExpenseClaim(
claim_no="EXP-202605-010",
employee_id=employee.id,
employee_name="张三",
department_name="市场部",
project_code=None,
expense_type="transport",
reason="原有交通报销",
location="深圳",
amount=Decimal("20.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("20.00"),
invoice_id="old-trip.png",
)
]
db.add(existing_claim)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我上传了两张交通票据,帮我生成报销草稿",
user_id=user_id,
)
)
service = ExpenseClaimService(db)
result = service.upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message="我上传了两张交通票据,帮我生成报销草稿",
ontology=ontology,
context_json={
"name": "张三",
"attachment_names": ["didi-trip.png", "parking-ticket.jpg"],
"attachment_count": 2,
"draft_claim_id": existing_claim.id,
"ocr_documents": [
{
"filename": "didi-trip.png",
"summary": "滴滴出行 支付金额 32 元",
"text": "滴滴出行 支付金额 32 元",
},
{
"filename": "parking-ticket.jpg",
"summary": "停车费 合计 18 元",
"text": "停车费 合计 18 元",
},
],
},
)
db.refresh(existing_claim)
assert result["pending_association_decision"] is True
assert result["association_candidate_claim_id"] == existing_claim.id
assert existing_claim.invoice_count == 1
assert len(existing_claim.items) == 1
assert existing_claim.items[0].invoice_id == "old-trip.png"
def test_upsert_draft_from_ontology_keeps_reason_missing_for_attachment_only_upload() -> None:
user_id = "wangwu@example.com"
with build_session() as db:
employee = Employee(
employee_no="E5003",
name="王五",
email=user_id,
)
db.add(employee)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我上传了 1 份票据,请结合附件名称给出报销建议并尽量生成草稿。",
user_id=user_id,
)
)
service = ExpenseClaimService(db)
result = service.upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message="我上传了 1 份票据,请结合附件名称给出报销建议并尽量生成草稿。\n附件名称didi-trip.png",
ontology=ontology,
context_json={
"name": "王五",
"user_input_text": "",
"attachment_names": ["didi-trip.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "didi-trip.png",
"summary": "滴滴出行 支付金额 32 元",
"text": "滴滴出行 支付金额 32 元",
"document_type": "taxi_receipt",
"scene_code": "transport",
}
],
},
)
claim = db.get(ExpenseClaim, result["claim_id"])
assert claim is not None
assert claim.reason == "待补充"
def test_upsert_draft_from_ontology_supports_link_or_create_for_multi_documents() -> None:
user_id = "lisi@example.com"
with build_session() as db:
employee = Employee(
employee_no="E5002",
name="李四",
email=user_id,
)
db.add(employee)
db.flush()
existing_claim = ExpenseClaim(
claim_no="EXP-202605-011",
employee_id=employee.id,
employee_name="李四",
department_name="销售部",
project_code=None,
expense_type="transport",
reason="原有交通报销",
location="上海",
amount=Decimal("20.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("20.00"),
invoice_id="existing.png",
)
]
db.add(existing_claim)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我上传了两张交通票据,帮我生成报销草稿",
user_id=user_id,
)
)
service = ExpenseClaimService(db)
context_json = {
"name": "李四",
"attachment_names": ["didi-trip.png", "parking-ticket.jpg"],
"attachment_count": 2,
"draft_claim_id": existing_claim.id,
"ocr_documents": [
{
"filename": "didi-trip.png",
"summary": "滴滴出行",
"text": "滴滴出行 支付金额 32.50 元",
"document_type": "taxi_receipt",
"scene_code": "transport",
"document_fields": [{"key": "amount", "label": "支付金额", "value": "32.50"}],
},
{
"filename": "parking-ticket.jpg",
"summary": "停车票",
"text": "停车费 合计 18 元",
"document_type": "parking_toll_receipt",
"scene_code": "transport",
"document_fields": [{"key": "total_amount", "label": "合计金额", "value": "18"}],
},
],
}
link_result = service.upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message="把这两张票据关联到已有草稿",
ontology=ontology,
context_json={
**context_json,
"review_action": "link_to_existing_draft",
},
)
db.refresh(existing_claim)
assert link_result["claim_id"] == existing_claim.id
assert existing_claim.invoice_count == 3
assert len(existing_claim.items) == 3
assert float(existing_claim.amount) == 70.5
create_result = service.upsert_draft_from_ontology(
run_id=f"{ontology.run_id}-new",
user_id=user_id,
message="单独新建一张报销单",
ontology=ontology,
context_json={
**context_json,
"review_action": "create_new_claim_from_documents",
},
)
assert create_result["claim_id"] != existing_claim.id
new_claim = db.get(ExpenseClaim, create_result["claim_id"])
assert new_claim is not None
assert new_claim.invoice_count == 2
assert len(new_claim.items) == 2
assert float(new_claim.amount) == 50.5
def test_generate_claim_no_uses_max_suffix_instead_of_count() -> None:
with build_session() as db:
db.add_all(
[
ExpenseClaim(
claim_no="EXP-202605-001",
employee_name="张三",
department_name="市场部",
project_code=None,
expense_type="transport",
reason="交通报销",
location="深圳",
amount=Decimal("10.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 10, tzinfo=UTC),
status="draft",
approval_stage="待提交",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-202605-003",
employee_name="李四",
department_name="销售部",
project_code=None,
expense_type="transport",
reason="交通报销",
location="上海",
amount=Decimal("20.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, tzinfo=UTC),
status="submitted",
approval_stage="审批中",
risk_flags_json=[],
),
]
)
db.commit()
service = ExpenseClaimService(db)
assert service._generate_claim_no(datetime(2026, 5, 14, tzinfo=UTC)) == "EXP-202605-004"
def test_upsert_draft_from_ontology_retries_claim_no_conflict() -> None:
user_id = "zhaoliu-claimno@example.com"
with build_session() as db:
employee = Employee(
employee_no="E5006",
name="赵六",
email=user_id,
)
db.add(employee)
db.flush()
db.add(
ExpenseClaim(
claim_no="EXP-202605-004",
employee_name="历史单据",
department_name="财务部",
project_code=None,
expense_type="other",
reason="历史草稿",
location="北京",
amount=Decimal("0.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 12, tzinfo=UTC),
status="submitted",
approval_stage="审批中",
risk_flags_json=[],
)
)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="帮我生成报销草稿,我昨天交通费 13.4 元",
user_id=user_id,
)
)
service = ExpenseClaimService(db)
generated_claim_nos = iter(["EXP-202605-004", "EXP-202605-005"])
service._generate_claim_no = lambda occurred_at: next(generated_claim_nos)
result = service.upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message="帮我生成报销草稿,我昨天交通费 13.4 元",
ontology=ontology,
context_json={
"name": "赵六",
"user_input_text": "帮我生成报销草稿,我昨天交通费 13.4 元",
},
)
created_claim = db.get(ExpenseClaim, result["claim_id"])
assert created_claim is not None
assert created_claim.claim_no == "EXP-202605-005"
assert result["claim_no"] == "EXP-202605-005"
def test_create_claim_item_adds_blank_draft_row_without_forcing_attachment() -> None:
current_user = CurrentUserContext(
username="emp-1",
@@ -186,6 +529,10 @@ def test_update_claim_item_reanalyzes_existing_attachment(monkeypatch, tmp_path)
current_user=current_user,
)
assert uploaded_meta is not None
assert uploaded_meta["preview_kind"] == "image"
assert uploaded_meta["preview_url"].endswith(
f"/reimbursements/claims/{claim.id}/items/{claim.items[0].id}/attachment/preview"
)
assert uploaded_meta["analysis"]["severity"] == "pass"
assert uploaded_meta["document_info"]["document_type"] == "office_invoice"
assert uploaded_meta["requirement_check"]["matches"] is True