fix(reimbursement): harden assistant draft and claim cleanup

This commit is contained in:
caoxiaozhu
2026-05-21 23:52:34 +08:00
parent e701fa01da
commit 2908dda024
9 changed files with 1060 additions and 398 deletions

View File

@@ -136,7 +136,7 @@ def test_save_or_submit_preview_does_not_create_claim_without_explicit_action()
assert result["preview_only"] is True
assert result["status"] == "preview"
assert "尚未保存为草稿" in result["message"]
assert "差旅费按“交通票据金额 + 住宿标准 × 出差天数 + 出差补贴 × 出差天数”估算" in result["message"]
assert _count_claims(db) == before_count
@@ -684,6 +684,62 @@ def test_upsert_travel_draft_uses_ticket_item_types_and_auto_allowance() -> None
)
def test_upsert_travel_draft_uses_explicit_text_days_for_allowance() -> None:
user_id = "travel-explicit-days@example.com"
message = "业务发生时间:2026-05-20 至 2026-05-23去上海支撑上海电力服务器部署出差3天申请差旅费报销"
with build_session() as db:
employee = Employee(
employee_no="E5012",
name="文本差旅员工",
email=user_id,
grade="P4",
)
db.add(employee)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id=user_id,
context_json={"name": "文本差旅员工", "grade": "P4"},
)
)
result = ExpenseClaimService(db).upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message=message,
ontology=ontology,
context_json={
"name": "文本差旅员工",
"grade": "P4",
"user_input_text": message,
"review_form_values": {
"expense_type": "差旅费",
"business_location": "上海",
"reason": "去上海支撑上海电力服务器部署出差3天",
"time_range": "2026-05-20 至 2026-05-23",
"business_time": "2026-05-20 至 2026-05-23",
},
"business_time_context": {
"mode": "range",
"start_date": "2026-05-20",
"end_date": "2026-05-23",
"display_value": "2026-05-20 至 2026-05-23",
},
},
)
claim = db.get(ExpenseClaim, result["claim_id"])
assert claim is not None
assert claim.expense_type == "travel"
allowance_item = next(item for item in claim.items if item.item_type == "travel_allowance")
assert allowance_item.item_amount == Decimal("300.00")
assert "3天" in allowance_item.item_reason
assert allowance_item.item_date == date(2026, 5, 22)
assert claim.amount == Decimal("300.00")
def test_sync_travel_claim_adds_allowance_from_manual_ticket_dates() -> None:
with build_session() as db:
employee = Employee(
@@ -1288,6 +1344,94 @@ def test_upload_hotel_attachment_audits_date_like_amount(monkeypatch, tmp_path)
assert not any("2026.00 元与报销金额" in point for point in uploaded_meta["analysis"]["points"])
def test_upload_hotel_attachment_flags_amount_over_travel_policy(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-hotel-risk@example.com",
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="hotel-risk.png",
media_type="image/png",
text="北京全季酒店 住宿 1晚 金额800元 2026-05-13",
summary="北京全季酒店住宿发票,住宿 1 晚,金额 800 元。",
avg_score=0.98,
line_count=1,
page_count=1,
document_type="hotel_invoice",
document_type_label="酒店住宿票据",
scene_code="hotel",
scene_label="住宿票据",
document_fields=[
{"key": "merchant_name", "label": "商户", "value": "北京全季酒店"},
{"key": "amount", "label": "金额", "value": "800元"},
{"key": "date", "label": "日期", "value": "2026-05-13"},
],
)
],
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimService, "_get_attachment_storage_root", lambda self: tmp_path)
with build_session() as db:
employee = Employee(
employee_no="E7401",
name="张三",
email="emp-hotel-risk@example.com",
grade="P4",
)
db.add(employee)
db.flush()
claim = build_claim(expense_type="travel", location="北京")
claim.employee = employee
claim.employee_id = employee.id
claim.reason = "北京客户现场出差"
claim.amount = Decimal("0.00")
claim.invoice_count = 0
claim.items[0].item_type = "hotel"
claim.items[0].item_reason = "北京住宿"
claim.items[0].item_location = "北京"
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="hotel-risk.png",
content=b"fake-image-bytes",
media_type="image/png",
current_user=current_user,
)
assert updated is not None
uploaded_meta = service.get_claim_item_attachment_meta(
claim_id=claim.id,
item_id=claim.items[0].id,
current_user=current_user,
)
assert uploaded_meta is not None
analysis = uploaded_meta["analysis"]
assert analysis["severity"] == "high"
assert analysis["headline"] == "AI提示住宿金额超出报销标准"
assert any("住宿标准" in point and "800.00 元" in point for point in analysis["points"])
assert any("住宿费按员工职级" in basis for basis in analysis["rule_basis"])
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="上海")
@@ -1505,6 +1649,47 @@ def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_pat
assert not attachment_root.exists()
def test_delete_claim_removes_all_claim_attachment_files(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
role_codes=[],
is_admin=False,
)
monkeypatch.setattr(ExpenseClaimService, "_get_attachment_storage_root", lambda self: tmp_path)
with build_session() as db:
claim = build_claim(expense_type="office", location="深圳南山")
attachment_dir = tmp_path / claim.id / claim.items[0].id
attachment_dir.mkdir(parents=True)
attachment_path = attachment_dir / "office-note.png"
attachment_path.write_bytes(b"fake-image-bytes")
(attachment_dir / "office-note.png.meta.json").write_text("{}", encoding="utf-8")
orphan_path = tmp_path / claim.id / "orphan-preview.png"
orphan_path.write_bytes(b"orphan-preview")
claim.items[0].invoice_id = f"{claim.id}/{claim.items[0].id}/office-note.png"
db.add(claim)
db.commit()
conversation = AgentConversationService(db).get_or_create_conversation(
conversation_id=None,
user_id=current_user.username,
source="user_message",
context_json={
"session_type": "expense",
"draft_claim_id": claim.id,
},
)
claim_id = claim.id
claim_root = tmp_path / claim.id
deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)
assert deleted is not None
assert db.get(ExpenseClaim, claim_id) is None
assert not claim_root.exists()
assert AgentConversationService(db).get_conversation(conversation.conversation_id) is None
def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-1",

View File

@@ -350,7 +350,7 @@ def test_orchestrator_expense_preview_does_not_persist_claim_before_user_action(
assert response.status == "succeeded"
assert response.result.get("review_payload") is not None
assert response.result.get("draft_payload") is None
assert "尚未保存为草稿" in response.result["answer"]
assert "交通费通常以实际票据金额为基础" in response.result["answer"]
assert user_claims == []

View File

@@ -158,6 +158,11 @@ def test_claim_item_attachment_upload_preview_and_delete(monkeypatch, tmp_path)
assert upload_payload["attachment"]["document_info"]["document_type"] == "office_invoice"
assert upload_payload["attachment"]["requirement_check"]["matches"] is True
assert upload_payload["invoice_id"]
assert upload_payload["item_type"] == "office"
assert upload_payload["item_reason"] == "识别到办公用品发票,金额 88 元。"
assert upload_payload["item_location"] == "深圳南山"
assert upload_payload["item_date"] == "2026-05-13"
assert upload_payload["item_amount"] == "88.00"
meta_response = client.get(
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment/meta",

View File

@@ -554,6 +554,7 @@ def test_user_agent_continues_identification_after_expense_type_selection() -> N
query=f"{message}\n用户选择报销场景:差旅费",
user_id="pytest-selected-type@example.com",
context_json={
"grade": "P4",
"expense_scene_selection": {
"expense_type": "travel",
"expense_type_label": "差旅费",
@@ -573,6 +574,7 @@ def test_user_agent_continues_identification_after_expense_type_selection() -> N
message=f"{message}\n用户选择报销场景:差旅费",
ontology=ontology,
context_json={
"grade": "P4",
"expense_scene_selection": {
"expense_type": "travel",
"expense_type_label": "差旅费",
@@ -593,6 +595,11 @@ def test_user_agent_continues_identification_after_expense_type_selection() -> N
assert slot_map["expense_type"].normalized_value == "travel"
assert slot_map["time_range"].value == "2026-02-20 至 2026-02-23"
assert slot_map["location"].value == "上海"
assert "报销测算参考:" in response.answer
assert "| 项目 | 测算口径 | 金额 |" in response.answer
assert "| 住宿标准 |" in response.answer
assert "| 出差补贴 |" in response.answer
assert "| 参考合计 |" in response.answer
def test_user_agent_guides_implicit_expense_draft_request() -> None:
@@ -618,12 +625,10 @@ def test_user_agent_guides_implicit_expense_draft_request() -> None:
assert response.review_payload is not None
assert response.answer == response.review_payload.body_message
assert response.review_payload.intent_summary.startswith("识别到您希望报销一笔“业务招待费”费用。")
assert response.review_payload.missing_slots == ["客户名称", "参与人员", "票据附件"]
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"cancel_review",
"edit_review",
"save_draft",
]
assert response.review_payload.missing_slots == ["客户名称", "参与人员", "票据附件"]
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"save_draft",
]
slot_map = {item.key: item for item in response.review_payload.slot_cards}
assert slot_map["expense_type"].value == "业务招待费"
@@ -1016,12 +1021,10 @@ def test_user_agent_draft_returns_structured_payload() -> None:
assert response.draft_payload.confirmation_required is True
assert response.review_payload is not None
assert response.review_payload.can_proceed is False
assert response.review_payload.missing_slots == ["金额", "事由说明", "票据附件"]
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"cancel_review",
"edit_review",
"save_draft",
]
assert response.review_payload.missing_slots == ["金额", "事由说明", "票据附件"]
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"save_draft",
]
assert response.answer == response.review_payload.body_message
@@ -1156,12 +1159,10 @@ def test_user_agent_builds_review_payload_for_multi_document_expense_flow() -> N
assert response.review_payload is not None
assert len(response.review_payload.document_cards) == 2
assert len(response.review_payload.claim_groups) == 2
assert response.review_payload.missing_slots == ["参与人员"]
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"cancel_review",
"edit_review",
"save_draft",
]
assert response.review_payload.missing_slots == ["参与人员"]
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"save_draft",
]
assert any(item.scene_label == "业务招待费" for item in response.review_payload.document_cards)
assert f"时间为 {yesterday}" in response.review_payload.intent_summary
slot_map = {item.key: item for item in response.review_payload.slot_cards}
@@ -1577,9 +1578,11 @@ def test_user_agent_review_payload_does_not_fill_hotel_name_from_train_ticket()
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
assert "已识别信息:" in response.answer
assert "酒店住宿发票/住宿清单" 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
@@ -1658,7 +1661,7 @@ def test_user_agent_review_payload_allows_next_step_when_only_optional_ride_rece
assert "save_draft" in action_types
assert "next_step" in action_types
assert "市内交通/乘车票据(非必须" in response.answer
assert "也可以继续下一步" in response.answer
assert "继续下一步" in response.answer
def test_user_agent_review_payload_allows_next_step_after_required_travel_receipts_are_complete() -> None:
@@ -2065,11 +2068,9 @@ def test_user_agent_prompts_existing_draft_association_choice_for_multi_document
)
assert response.review_payload is not None
assert response.review_payload.can_proceed is False
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"cancel_review",
"edit_review",
"link_to_existing_draft",
"create_new_claim_from_documents",
]
assert response.review_payload.can_proceed is False
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"link_to_existing_draft",
"create_new_claim_from_documents",
]
assert "EXP-202605-008" in response.answer