feat: 完善报销单审批流程及退回原因追踪

新增直属领导审批通过接口和审批待办列表查询,报销单退回
支持原因码分类和审批环节标记,优化票据附件去重和路径
回退查找,前端新增退回原因对话框、审批收件箱和工作台
图标组件,补充工具函数和单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-20 21:00:47 +08:00
parent f8b25a7ccc
commit 002bf4f756
62 changed files with 5331 additions and 2101 deletions

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from datetime import UTC, date, datetime
from decimal import Decimal
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
@@ -110,6 +111,19 @@ def test_resolve_expense_type_maps_office_supplies_review_value_to_office() -> N
assert expense_type == "office"
def test_resolve_expense_type_maps_riding_fare_review_value_to_transport() -> None:
expense_type = ExpenseClaimService._resolve_expense_type(
[],
context_json={
"review_form_values": {
"expense_type": "乘车费用"
}
},
)
assert expense_type == "transport"
def test_upsert_draft_from_ontology_defers_multi_document_association_choice() -> None:
user_id = "zhangsan@example.com"
@@ -238,6 +252,48 @@ def test_upsert_draft_from_ontology_keeps_reason_missing_for_attachment_only_upl
assert claim.reason == "待补充"
def test_upsert_draft_from_ontology_strips_recognized_business_time_from_reason() -> None:
user_id = "transport-time@example.com"
message = "业务发生时间:2026-03-04送客户去林萃小区办事请报销乘车费用"
with build_session() as db:
employee = Employee(
employee_no="E5004",
name="赵六",
email=user_id,
)
db.add(employee)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id=user_id,
)
)
result = ExpenseClaimService(db).upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message=message,
ontology=ontology,
context_json={
"name": "赵六",
"user_input_text": message,
},
)
claim = db.get(ExpenseClaim, result["claim_id"])
assert claim is not None
assert claim.occurred_at.date() == date(2026, 3, 4)
assert claim.reason == "送客户去林萃小区办事,请报销乘车费用"
assert len(claim.items) == 1
assert claim.items[0].item_date == date(2026, 3, 4)
assert claim.items[0].item_reason == "送客户去林萃小区办事,请报销乘车费用"
assert "客户单位" not in result["message"]
assert "票据附件" not in result["message"]
assert "费用明细" not in result["message"]
def test_upsert_draft_from_ontology_supports_link_or_create_for_multi_documents() -> None:
user_id = "lisi@example.com"
@@ -348,6 +404,100 @@ def test_upsert_draft_from_ontology_supports_link_or_create_for_multi_documents(
assert float(new_claim.amount) == 50.5
def test_upsert_draft_from_ontology_updates_returned_claim_and_preserves_return_events() -> None:
user_id = "returned-owner@example.com"
return_flag = {
"source": "manual_return",
"return_event_id": "return-event-1",
"message": "第一次退回:附件缺失。",
"reason": "附件缺失。",
"return_count": 1,
"return_stage": "直属领导审批",
"return_stage_key": "direct_manager",
"risk_points": ["附件缺失或不清晰"],
}
with build_session() as db:
employee = Employee(
employee_no="E5004",
name="赵六",
email=user_id,
)
db.add(employee)
db.flush()
existing_claim = ExpenseClaim(
claim_no="EXP-202605-012",
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="returned",
approval_stage="待提交",
risk_flags_json=[return_flag],
)
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,
)
)
ontology.risk_flags = ["系统识别:票据金额待人工核对。"]
result = ExpenseClaimService(db).upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message="我补充了交通票据,更新这张退回单据",
ontology=ontology,
context_json={
"name": "赵六",
"draft_claim_id": existing_claim.id,
"attachment_names": ["new-trip.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "new-trip.png",
"summary": "滴滴出行 支付金额 32 元",
"text": "滴滴出行 支付金额 32 元",
"document_type": "taxi_receipt",
"scene_code": "transport",
}
],
},
)
db.refresh(existing_claim)
assert result["claim_id"] == existing_claim.id
assert existing_claim.status == "draft"
assert "系统识别:票据金额待人工核对。" in existing_claim.risk_flags_json
manual_returns = [
flag
for flag in list(existing_claim.risk_flags_json or [])
if isinstance(flag, dict) and flag.get("source") == "manual_return"
]
assert manual_returns == [return_flag]
def test_generate_claim_no_uses_max_suffix_instead_of_count() -> None:
with build_session() as db:
db.add_all(
@@ -642,6 +792,44 @@ def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_pat
assert not attachment_root.exists()
def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(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="transport", location="上海")
claim.items[0].invoice_id = "legacy-ticket.pdf"
db.add(claim)
db.commit()
attachment_dir = tmp_path / claim.id / claim.items[0].id
attachment_dir.mkdir(parents=True)
file_path = attachment_dir / "legacy-ticket.pdf"
file_path.write_bytes(b"legacy-pdf-bytes")
(attachment_dir / "legacy-ticket.pdf.meta.json").write_text(
'{"file_name":"legacy-ticket.pdf","media_type":"application/pdf","previewable":true}',
encoding="utf-8",
)
payload = ExpenseClaimService(db).get_claim_item_attachment_preview_content(
claim_id=claim.id,
item_id=claim.items[0].id,
current_user=current_user,
)
assert payload is not None
resolved_path, media_type, filename = payload
assert resolved_path == file_path
assert media_type == "application/pdf"
assert filename == "legacy-ticket.pdf"
def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
current_user = CurrentUserContext(
username="emp-submit@example.com",
@@ -677,6 +865,43 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
assert submitted.submitted_at is not None
def test_submit_claim_allows_returned_claim_to_be_resubmitted() -> None:
current_user = CurrentUserContext(
username="emp-submit@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E7100",
name="李经理",
email="manager-returned@example.com",
)
employee = Employee(
employee_no="E7101",
name="张三",
email="emp-submit@example.com",
manager=manager,
)
claim = build_claim(expense_type="transport", location="上海")
claim.employee = employee
claim.employee_id = employee.id
claim.status = "returned"
claim.approval_stage = "待补充"
claim.items[0].invoice_id = "taxi-ticket.png"
db.add_all([manager, employee, claim])
db.commit()
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.approval_stage == "直属领导审批"
assert submitted.submitted_at is not None
def test_submit_claim_backfills_department_from_current_employee() -> None:
current_user = CurrentUserContext(
username="emp-dept@example.com",
@@ -1327,7 +1552,377 @@ def test_privileged_user_can_return_and_delete_submitted_claim() -> None:
assert db.get(ExpenseClaim, claim_id) is None
def test_list_claims_allows_direct_manager_to_view_pending_claims_for_approval() -> None:
def test_direct_manager_can_return_subordinate_claim_to_pending_submission() -> None:
current_user = CurrentUserContext(
username="manager-return@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E8100",
name="李经理",
email="manager-return@example.com",
)
employee = Employee(
employee_no="E8101",
name="张三",
email="zhangsan-return@example.com",
manager=manager,
)
db.add_all([manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="EXP-RET-201",
employee_id=employee.id,
employee_name="张三",
department_name="市场部",
project_code="PRJ-A",
expense_type="transport",
reason="交通报销",
location="上海",
amount=Decimal("66.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
db.add(claim)
db.commit()
claim_id = claim.id
returned = ExpenseClaimService(db).return_claim(claim_id, current_user, reason="请补充行程说明")
assert returned is not None
assert returned.status == "returned"
assert returned.approval_stage == "待提交"
assert returned.submitted_at is None
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_return"
and flag.get("message") == "请补充行程说明"
for flag in returned.risk_flags_json
)
def test_direct_manager_can_approve_subordinate_claim_to_finance_review() -> None:
current_user = CurrentUserContext(
username="manager-approve@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E8110",
name="李经理",
email="manager-approve@example.com",
)
employee = Employee(
employee_no="E8111",
name="张三",
email="zhangsan-approve@example.com",
manager=manager,
)
db.add_all([manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="EXP-APP-201",
employee_id=employee.id,
employee_name="张三",
department_name="市场部",
project_code="PRJ-A",
expense_type="transport",
reason="交通报销",
location="上海",
amount=Decimal("66.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
db.add(claim)
db.commit()
claim_id = claim.id
approved = ExpenseClaimService(db).approve_claim(
claim_id,
current_user,
opinion="情况属实,同意报销。",
)
assert approved is not None
assert approved.status == "submitted"
assert approved.approval_stage == "财务审批"
assert approved.submitted_at is not None
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_approval"
and flag.get("event_type") == "expense_claim_approval"
and flag.get("opinion") == "情况属实,同意报销。"
and flag.get("previous_approval_stage") == "直属领导审批"
and flag.get("next_approval_stage") == "财务审批"
for flag in approved.risk_flags_json
)
def test_return_claim_rejects_already_returned_claim_without_adding_event() -> None:
current_user = CurrentUserContext(
username="finance-returned@example.com",
name="财务",
role_codes=["finance"],
is_admin=False,
)
return_flag = {
"source": "manual_return",
"return_event_id": "return-event-existing",
"message": "请补充附件。",
"return_count": 1,
"return_stage": "直属领导审批",
"return_stage_key": "direct_manager",
}
with build_session() as db:
claim = ExpenseClaim(
claim_no="EXP-RET-202",
employee_name="张三",
department_name="市场部",
project_code="PRJ-A",
expense_type="transport",
reason="交通报销",
location="上海",
amount=Decimal("66.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=None,
status="returned",
approval_stage="待提交",
risk_flags_json=[return_flag],
)
db.add(claim)
db.commit()
claim_id = claim.id
with pytest.raises(ValueError, match="无需重复退回"):
ExpenseClaimService(db).return_claim(claim_id, current_user, reason="重复退回")
db.refresh(claim)
manual_returns = [
flag
for flag in list(claim.risk_flags_json or [])
if isinstance(flag, dict) and flag.get("source") == "manual_return"
]
assert manual_returns == [return_flag]
def test_return_claim_records_each_return_event_with_stage_reason_and_counts() -> None:
current_user = CurrentUserContext(
username="finance-return@example.com",
name="财务复核",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
claim = ExpenseClaim(
claim_no="EXP-RET-301",
employee_name="张三",
department_name="市场部",
project_code="PRJ-A",
expense_type="transport",
reason="交通报销",
location="上海",
amount=Decimal("66.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
db.add(claim)
db.commit()
claim_id = claim.id
service = ExpenseClaimService(db)
first_returned = service.return_claim(
claim_id,
current_user,
reason="发票金额与明细金额不一致,请重新核对。",
reason_codes=["invoice_mismatch", "business_explanation"],
)
assert first_returned is not None
first_returned.status = "submitted"
first_returned.approval_stage = "财务审批"
first_returned.submitted_at = datetime(2026, 5, 12, 11, 0, tzinfo=UTC)
db.commit()
second_returned = service.return_claim(
claim_id,
current_user,
reason="超标说明仍不完整,请补充制度例外依据。",
reason_codes=["over_policy"],
)
assert second_returned is not None
return_events = [
flag
for flag in list(second_returned.risk_flags_json or [])
if isinstance(flag, dict) and flag.get("source") == "manual_return"
]
assert len(return_events) == 2
assert return_events[0]["return_count"] == 1
assert return_events[0]["stage_return_count"] == 1
assert return_events[0]["return_stage"] == "直属领导审批"
assert return_events[0]["reason_codes"] == ["invoice_mismatch", "business_explanation"]
assert return_events[0]["risk_points"] == ["票据类型/金额与明细不一致", "业务事由/地点/人员信息不完整"]
assert return_events[0]["reason"] == "发票金额与明细金额不一致,请重新核对。"
assert return_events[0]["operator_role_codes"] == ["finance"]
assert return_events[1]["return_count"] == 2
assert return_events[1]["stage_return_count"] == 1
assert return_events[1]["return_stage"] == "财务审批"
assert return_events[1]["risk_points"] == ["超出制度标准或缺少超标说明"]
def test_submit_returned_claim_preserves_manual_return_events() -> None:
current_user = CurrentUserContext(
username="emp-submit-returned@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
return_flag = {
"source": "manual_return",
"return_event_id": "return-event-submit",
"message": "第一次退回:业务说明不完整。",
"reason": "业务说明不完整。",
"return_count": 1,
"return_stage": "直属领导审批",
"return_stage_key": "direct_manager",
"risk_points": ["业务事由/地点/人员信息不完整"],
}
with build_session() as db:
manager = Employee(
employee_no="E8200",
name="李经理",
email="manager-submit-returned@example.com",
)
employee = Employee(
employee_no="E8201",
name="张三",
email="emp-submit-returned@example.com",
manager=manager,
)
claim = build_claim(expense_type="office", location="上海")
claim.employee = employee
claim.employee_id = employee.id
claim.employee_name = "张三"
claim.department_name = "市场部"
claim.status = "returned"
claim.approval_stage = "待提交"
claim.risk_flags_json = [return_flag]
db.add_all([manager, employee, claim])
db.commit()
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.approval_stage == "直属领导审批"
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_return"
and flag.get("return_event_id") == "return-event-submit"
for flag in list(submitted.risk_flags_json or [])
)
def test_manager_personal_claims_exclude_subordinate_pending_approval_claims() -> None:
current_user = CurrentUserContext(
username="manager-personal@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E8300",
name="李经理",
email="manager-personal@example.com",
)
employee = Employee(
employee_no="E8301",
name="张三",
email="zhangsan-personal@example.com",
manager=manager,
)
db.add_all([manager, employee])
db.flush()
db.add_all(
[
ExpenseClaim(
claim_no="EXP-MGR-OWN",
employee_id=manager.id,
employee_name="李经理",
department_name="市场部",
project_code="PRJ-MGR",
expense_type="office",
reason="本人报销",
location="上海",
amount=Decimal("88.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-MGR-SUB",
employee_id=employee.id,
employee_name="张三",
department_name="市场部",
project_code="PRJ-MGR",
expense_type="transport",
reason="下属待审批报销",
location="上海",
amount=Decimal("66.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 11, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
),
]
)
db.commit()
service = ExpenseClaimService(db)
personal_claims = service.list_claims(current_user)
approval_claims = service.list_approval_claims(current_user)
assert [claim.claim_no for claim in personal_claims] == ["EXP-MGR-OWN"]
assert [claim.claim_no for claim in approval_claims] == ["EXP-MGR-SUB"]
def test_list_approval_claims_allows_direct_manager_to_view_pending_claims_for_approval() -> None:
current_user = CurrentUserContext(
username="manager@example.com",
name="李经理",
@@ -1402,7 +1997,7 @@ def test_list_claims_allows_direct_manager_to_view_pending_claims_for_approval()
)
db.commit()
claims = ExpenseClaimService(db).list_claims(current_user)
claims = ExpenseClaimService(db).list_approval_claims(current_user)
assert len(claims) == 1
assert claims[0].claim_no == "EXP-MGR-201"

View File

@@ -433,11 +433,11 @@ def test_semantic_ontology_service_extracts_day_before_yesterday_from_client_loc
assert result.time_range.end_date == "2026-05-11"
def test_semantic_ontology_service_maps_office_supplies_to_office_expense_type() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
def test_semantic_ontology_service_maps_office_supplies_to_office_expense_type() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我买了办公用品和文具花了88元帮我报销",
user_id="pytest",
)
@@ -446,15 +446,33 @@ def test_semantic_ontology_service_maps_office_supplies_to_office_expense_type()
assert result.scenario == "expense"
assert result.intent == "draft"
assert any(
item.type == "expense_type" and item.normalized_value == "office"
for item in result.entities
)
def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None:
session_factory = build_session_factory()
with session_factory() as db:
service = SemanticOntologyService(db)
item.type == "expense_type" and item.normalized_value == "office"
for item in result.entities
)
def test_semantic_ontology_service_maps_riding_fare_to_transport_expense_type() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="业务发生时间:2026-03-04送客户去林萃小区办事请报销乘车费用",
user_id="pytest",
)
)
assert result.scenario == "expense"
assert result.intent == "draft"
assert any(
item.type == "expense_type" and item.normalized_value == "transport"
for item in result.entities
)
def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None:
session_factory = build_session_factory()
with session_factory() as db:
service = SemanticOntologyService(db)
monkeypatch.setattr(
service,
"_parse_with_model",

View File

@@ -289,6 +289,67 @@ def test_claim_item_attachment_upload_flags_non_invoice_image_as_high_risk(monke
assert any("附件内容" in point for point in analysis["points"])
def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review() -> None:
client, session_factory = build_client()
with session_factory() as db:
manager = Employee(
id="mgr-approve-1",
employee_no="E21001",
name="李经理",
email="manager-approve-api@example.com",
)
employee = Employee(
id="emp-approve-1",
employee_no="E11001",
name="张三",
email="zhangsan-approve-api@example.com",
manager=manager,
)
claim = ExpenseClaim(
id="claim-approve-1",
claim_no="EXP-APP-API-001",
employee_id=employee.id,
employee_name="张三",
department_id="dept-1",
department_name="市场部",
project_code=None,
expense_type="transport",
reason="交通报销",
location="上海",
amount=Decimal("88.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
submitted_at=datetime(2026, 5, 13, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
db.add_all([manager, employee, claim])
db.commit()
response = client.post(
"/api/v1/reimbursements/claims/claim-approve-1/approve",
json={"opinion": "情况属实,同意报销。"},
headers={
"X-Auth-Username": "manager-approve-api@example.com",
"X-Auth-Name": "manager-approve-api@example.com",
"X-Auth-Role-Codes": "manager",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "submitted"
assert payload["approval_stage"] == "财务审批"
assert any(
item["source"] == "manual_approval"
and item["opinion"] == "情况属实,同意报销。"
and item["next_approval_stage"] == "财务审批"
for item in payload["risk_flags_json"]
)
def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch, tmp_path) -> None:
preview_bytes = b"fake-preview-png"
preview_data_url = f"data:image/png;base64,{base64.b64encode(preview_bytes).decode('ascii')}"

View File

@@ -546,11 +546,11 @@ def test_user_agent_guides_implicit_expense_draft_request() -> None:
assert slot_map["amount"].value == "1000.00元"
def test_user_agent_guides_narrative_with_day_before_yesterday() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
def test_user_agent_guides_narrative_with_day_before_yesterday() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我前天请客户吃饭花了200元",
user_id="pytest",
context_json={
@@ -571,15 +571,106 @@ def test_user_agent_guides_narrative_with_day_before_yesterday() -> None:
assert response.review_payload is not None
slot_map = {item.key: item for item in response.review_payload.slot_cards}
assert slot_map["time_range"].raw_value == "前天"
assert slot_map["time_range"].value == "2026-05-11"
assert "时间为 2026-05-11" in response.review_payload.intent_summary
def test_user_agent_attachment_only_upload_uses_generic_scene_reason_without_fabrication() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
assert slot_map["time_range"].raw_value == "前天"
assert slot_map["time_range"].value == "2026-05-11"
assert "时间为 2026-05-11" in response.review_payload.intent_summary
def test_user_agent_guides_riding_fare_as_transport_expense() -> None:
session_factory = build_session_factory()
with session_factory() as db:
message = "业务发生时间:2026-03-04送客户去林萃小区办事请报销乘车费用"
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest",
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message=message,
ontology=ontology,
tool_payload={"draft_only": True},
)
)
assert response.review_payload is not None
slot_map = {item.key: item for item in response.review_payload.slot_cards}
assert slot_map["expense_type"].value == "交通费"
assert slot_map["expense_type"].normalized_value == "transport"
assert slot_map["time_range"].value == "2026-03-04"
assert slot_map["reason"].value == "送客户去林萃小区办事,请报销乘车费用"
assert "业务发生时间" not in slot_map["reason"].raw_value
assert "“交通费”" in response.review_payload.intent_summary
def test_user_agent_does_not_treat_draft_saved_message_as_precheck_risk_for_transport() -> None:
session_factory = build_session_factory()
with session_factory() as db:
message = "业务发生时间:2026-03-04送客户去林萃小区办事打车花了32元请报销乘车费用"
context_json = {
"name": "赵六",
"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",
"document_fields": [
{"key": "amount", "label": "支付金额", "value": "32.00"},
],
"warnings": [],
}
],
}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest",
context_json=context_json,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message=message,
ontology=ontology,
context_json=context_json,
tool_payload={
"draft_only": True,
"claim_id": "claim-1",
"claim_no": "EXP-202603-001",
"status": "draft",
"message": (
"已创建报销草稿 EXP-202603-001当前状态为 draft。"
"你可以继续补充费用明细、客户单位和票据附件。"
),
},
)
)
assert response.review_payload is not None
assert response.review_payload.can_proceed is True
assert "客户名称" not in response.review_payload.missing_slots
assert "参与人员" not in response.review_payload.missing_slots
assert "票据附件" not in response.review_payload.missing_slots
risk_text = "\n".join(
f"{item.title}\n{item.content}" for item in response.review_payload.risk_briefs
)
assert "AI预审未通过" not in risk_text
assert "已创建报销草稿" not in risk_text
def test_user_agent_attachment_only_upload_uses_generic_scene_reason_without_fabrication() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我上传了 1 份票据,请结合附件名称给出报销建议并尽量生成草稿。",
user_id="pytest",