fix(reimbursement): harden assistant draft and claim cleanup
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user