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