feat: 新增归档中心页面并完善知识库与报销查询能力
新增前端归档中心视图及相关工具函数,扩充知识库文档分类和 提取器支持多种格式,增强编排器报销查询的多维度检索,优 化本体规则和用户代理审核消息,前端完善报销创建和审批详 情交互细节,补充单元测试覆盖。
This commit is contained in:
@@ -1516,8 +1516,21 @@ def test_upload_hotel_attachment_flags_amount_over_travel_policy(monkeypatch, tm
|
||||
analysis = uploaded_meta["analysis"]
|
||||
assert analysis["severity"] == "high"
|
||||
assert analysis["headline"] == "AI提示:住宿金额超出报销标准"
|
||||
assert "保留在单据中" in analysis["summary"]
|
||||
assert "特殊情况" in analysis["summary"]
|
||||
assert any("住宿标准" in point and "800.00 元" in point for point in analysis["points"])
|
||||
assert any("住宿费按员工职级" in basis for basis in analysis["rule_basis"])
|
||||
db.refresh(claim)
|
||||
hotel_item = next(item for item in claim.items if str(item.invoice_id or "").strip())
|
||||
assert hotel_item.item_amount == Decimal("800.00")
|
||||
assert claim.invoice_count == 1
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "attachment_analysis"
|
||||
and flag.get("item_id") == hotel_item.id
|
||||
and str(flag.get("severity") or "").strip() == "high"
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
)
|
||||
|
||||
|
||||
def test_attachment_analysis_does_not_compare_business_purpose_with_ticket_scene() -> None:
|
||||
@@ -2433,8 +2446,8 @@ def test_list_claims_allows_finance_to_view_all_records() -> None:
|
||||
|
||||
claims = ExpenseClaimService(db).list_claims(current_user)
|
||||
|
||||
assert len(claims) == 2
|
||||
assert {claim.claim_no for claim in claims} == {"EXP-FIN-101", "EXP-FIN-102"}
|
||||
assert len(claims) == 1
|
||||
assert claims[0].claim_no == "EXP-FIN-101"
|
||||
|
||||
|
||||
def test_list_claims_allows_executive_to_view_all_records() -> None:
|
||||
@@ -2488,8 +2501,134 @@ def test_list_claims_allows_executive_to_view_all_records() -> None:
|
||||
|
||||
claims = ExpenseClaimService(db).list_claims(current_user)
|
||||
|
||||
assert len(claims) == 2
|
||||
assert {claim.claim_no for claim in claims} == {"EXP-EXE-101", "EXP-EXE-102"}
|
||||
assert len(claims) == 1
|
||||
assert claims[0].claim_no == "EXP-EXE-101"
|
||||
|
||||
|
||||
def test_list_claims_keeps_own_archived_claim_for_finance_applicant() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance@example.com",
|
||||
name="财务",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
db.add(
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-FIN-OWN-ARCH",
|
||||
employee_name="财务",
|
||||
department_name="财务部",
|
||||
project_code="PRJ-FIN",
|
||||
expense_type="meal",
|
||||
reason="本人报销",
|
||||
location="上海",
|
||||
amount=Decimal("88.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="approved",
|
||||
approval_stage="归档入账",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
claims = ExpenseClaimService(db).list_claims(current_user)
|
||||
|
||||
assert len(claims) == 1
|
||||
assert claims[0].claim_no == "EXP-FIN-OWN-ARCH"
|
||||
|
||||
|
||||
def test_list_archived_claims_returns_company_archived_records_for_finance() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance@example.com",
|
||||
name="财务",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
db.add_all(
|
||||
[
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-ARCH-101",
|
||||
employee_name="甲",
|
||||
department_name="A部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel",
|
||||
reason="A 报销",
|
||||
location="上海",
|
||||
amount=Decimal("120.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
|
||||
status="approved",
|
||||
approval_stage="归档入账",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-ARCH-102",
|
||||
employee_name="乙",
|
||||
department_name="B部",
|
||||
project_code="PRJ-B",
|
||||
expense_type="meal",
|
||||
reason="B 报销",
|
||||
location="杭州",
|
||||
amount=Decimal("300.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 11, 13, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="财务审批",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
claims = ExpenseClaimService(db).list_archived_claims(current_user)
|
||||
|
||||
assert len(claims) == 1
|
||||
assert claims[0].claim_no == "EXP-ARCH-101"
|
||||
|
||||
|
||||
def test_list_archived_claims_is_empty_for_regular_employee() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="zhangsan@example.com",
|
||||
name="张三",
|
||||
role_codes=["employee"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
db.add(
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-ARCH-EMP",
|
||||
employee_name="张三",
|
||||
department_name="研发部",
|
||||
project_code="PRJ-EMP",
|
||||
expense_type="travel",
|
||||
reason="本人报销",
|
||||
location="北京",
|
||||
amount=Decimal("200.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 10, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 10, 10, 0, tzinfo=UTC),
|
||||
status="approved",
|
||||
approval_stage="归档入账",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
claims = ExpenseClaimService(db).list_archived_claims(current_user)
|
||||
|
||||
assert claims == []
|
||||
|
||||
|
||||
def test_finance_can_return_but_cannot_delete_submitted_claim() -> None:
|
||||
|
||||
96
server/tests/test_knowledge_document_extractors.py
Normal file
96
server/tests/test_knowledge_document_extractors.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from zipfile import ZipFile
|
||||
|
||||
from app.services.knowledge_document_extractors import _extract_document_text_from_path
|
||||
|
||||
|
||||
def test_extract_xlsx_document_text_builds_markdown_with_row_clues(tmp_path) -> None:
|
||||
file_path = tmp_path / "company-expense-rules.xlsx"
|
||||
_write_minimal_xlsx(
|
||||
file_path,
|
||||
sheet_name="报销标准",
|
||||
rows=[
|
||||
["费用类型", "标准", "说明"],
|
||||
["住宿费", "500", "超标准需事前审批"],
|
||||
["交通费", "据实", "保留发票"],
|
||||
],
|
||||
)
|
||||
|
||||
text = _extract_document_text_from_path(
|
||||
file_path=file_path,
|
||||
original_name="公司支出管理办法.xlsx",
|
||||
mime_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
|
||||
assert "# Excel 工作簿:公司支出管理办法.xlsx" in text
|
||||
assert "## 工作表 1:报销标准" in text
|
||||
assert "| 费用类型 | 标准 | 说明 |" in text
|
||||
assert "费用类型=住宿费;标准=500;说明=超标准需事前审批" in text
|
||||
assert "费用类型=交通费;标准=据实;说明=保留发票" in text
|
||||
|
||||
|
||||
def test_extract_pptx_document_text_builds_markdown_slides(tmp_path) -> None:
|
||||
file_path = tmp_path / "training.pptx"
|
||||
slide_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
||||
<p:cSld>
|
||||
<p:spTree>
|
||||
<p:sp><p:txBody><a:p><a:r><a:t>差旅报销培训</a:t></a:r></a:p></p:txBody></p:sp>
|
||||
<p:sp><p:txBody><a:p><a:r><a:t>发票、审批、预算三项要素必须齐全</a:t></a:r></a:p></p:txBody></p:sp>
|
||||
</p:spTree>
|
||||
</p:cSld>
|
||||
</p:sld>
|
||||
"""
|
||||
with ZipFile(file_path, "w") as archive:
|
||||
archive.writestr("ppt/slides/slide1.xml", slide_xml)
|
||||
|
||||
text = _extract_document_text_from_path(
|
||||
file_path=file_path,
|
||||
original_name="报销培训.pptx",
|
||||
mime_type="application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
)
|
||||
|
||||
assert "# PowerPoint 演示文稿:报销培训.pptx" in text
|
||||
assert "## 幻灯片 1" in text
|
||||
assert "- 差旅报销培训" in text
|
||||
assert "- 发票、审批、预算三项要素必须齐全" in text
|
||||
|
||||
|
||||
def _write_minimal_xlsx(file_path, *, sheet_name: str, rows: list[list[str]]) -> None:
|
||||
workbook_xml = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<sheets>
|
||||
<sheet name="{sheet_name}" sheetId="1" r:id="rId1"/>
|
||||
</sheets>
|
||||
</workbook>
|
||||
"""
|
||||
rels_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1"
|
||||
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
|
||||
Target="worksheets/sheet1.xml"/>
|
||||
</Relationships>
|
||||
"""
|
||||
row_xml = "\n".join(
|
||||
f'<row r="{row_index}">'
|
||||
+ "".join(
|
||||
f'<c r="{chr(65 + column_index)}{row_index}" t="inlineStr"><is><t>{cell}</t></is></c>'
|
||||
for column_index, cell in enumerate(row)
|
||||
)
|
||||
+ "</row>"
|
||||
for row_index, row in enumerate(rows, start=1)
|
||||
)
|
||||
sheet_xml = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<sheetData>
|
||||
{row_xml}
|
||||
</sheetData>
|
||||
</worksheet>
|
||||
"""
|
||||
with ZipFile(file_path, "w") as archive:
|
||||
archive.writestr("xl/workbook.xml", workbook_xml)
|
||||
archive.writestr("xl/_rels/workbook.xml.rels", rels_xml)
|
||||
archive.writestr("xl/worksheets/sheet1.xml", sheet_xml)
|
||||
@@ -28,6 +28,15 @@ def build_session() -> Session:
|
||||
return session_factory()
|
||||
|
||||
|
||||
def test_list_library_returns_closed_folder_icons_by_default(tmp_path) -> None:
|
||||
service = KnowledgeService(storage_root=tmp_path)
|
||||
|
||||
library = service.list_library()
|
||||
|
||||
assert library.folders
|
||||
assert {folder.icon for folder in library.folders} == {"mdi mdi-folder"}
|
||||
|
||||
|
||||
def test_reconcile_document_ingest_status_keeps_failed_when_linked_run_failed(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
|
||||
@@ -534,6 +534,28 @@ def test_semantic_ontology_service_maps_riding_fare_to_transport_expense_type()
|
||||
)
|
||||
|
||||
|
||||
def test_semantic_ontology_service_maps_taxi_ticket_reimbursement_to_transport_draft() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
result = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="送客户去机场,报销的士票",
|
||||
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
|
||||
)
|
||||
assert not any(
|
||||
item.type == "expense_type" and item.normalized_value == "entertainment"
|
||||
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:
|
||||
|
||||
@@ -228,6 +228,60 @@ def test_conversation_hydration_does_not_reuse_review_type_for_fresh_expense_pro
|
||||
assert continued_context["review_form_values"]["expense_type"] == "差旅费"
|
||||
|
||||
|
||||
def test_conversation_scope_creates_new_session_for_different_claim() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
service = AgentConversationService(db)
|
||||
old_conversation = service.get_or_create_conversation(
|
||||
conversation_id="conv-old-claim-scope",
|
||||
user_id="emp-scope@example.com",
|
||||
source="user_message",
|
||||
context_json={
|
||||
"session_type": "expense",
|
||||
"draft_claim_id": "claim-old",
|
||||
"attachment_names": ["old-hotel.pdf"],
|
||||
"attachment_count": 1,
|
||||
"review_form_values": {
|
||||
"expense_type": "住宿票",
|
||||
"merchant_name": "旧酒店",
|
||||
},
|
||||
},
|
||||
)
|
||||
service.append_message(
|
||||
conversation_id=old_conversation.conversation_id,
|
||||
role="user",
|
||||
content="继续补充旧酒店发票",
|
||||
)
|
||||
|
||||
scoped_conversation = service.get_or_create_conversation(
|
||||
conversation_id=old_conversation.conversation_id,
|
||||
user_id="emp-scope@example.com",
|
||||
source="user_message",
|
||||
context_json={
|
||||
"session_type": "expense",
|
||||
"draft_claim_id": "claim-current",
|
||||
},
|
||||
)
|
||||
conflict_context = service.hydrate_context_json(
|
||||
conversation=old_conversation,
|
||||
context_json={"draft_claim_id": "claim-current"},
|
||||
message="继续补充当前单据的火车票",
|
||||
)
|
||||
scoped_context = service.hydrate_context_json(
|
||||
conversation=scoped_conversation,
|
||||
context_json={"draft_claim_id": "claim-current"},
|
||||
message="继续补充当前单据的火车票",
|
||||
)
|
||||
|
||||
db.refresh(old_conversation)
|
||||
assert scoped_conversation.conversation_id != old_conversation.conversation_id
|
||||
assert scoped_conversation.draft_claim_id == "claim-current"
|
||||
assert old_conversation.draft_claim_id == "claim-old"
|
||||
assert conflict_context == {"draft_claim_id": "claim-current"}
|
||||
assert scoped_context["draft_claim_id"] == "claim-current"
|
||||
assert scoped_context["conversation_history"] == []
|
||||
|
||||
|
||||
def test_orchestrator_history_query_filters_location_time_and_returns_real_amount(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
@@ -322,6 +376,89 @@ def test_orchestrator_history_query_filters_location_time_and_returns_real_amoun
|
||||
assert "321.45" in response.result["answer"]
|
||||
|
||||
|
||||
def test_orchestrator_archive_query_filters_archived_claims_and_limits_preview(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.services.runtime_chat.RuntimeChatService.complete",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
employee = Employee(
|
||||
id="emp-archive-query",
|
||||
employee_no="E9021",
|
||||
name="归档员工",
|
||||
email="archive-query@example.com",
|
||||
)
|
||||
claims = []
|
||||
for index in range(6):
|
||||
claims.append(
|
||||
ExpenseClaim(
|
||||
id=f"claim-archive-query-{index}",
|
||||
claim_no=f"EXP-ARCHIVE-{index + 1:03d}",
|
||||
employee=employee,
|
||||
employee_id=employee.id,
|
||||
employee_name="归档员工",
|
||||
department_name="交付部",
|
||||
expense_type="travel",
|
||||
reason=f"归档差旅 {index + 1}",
|
||||
location="上海",
|
||||
amount=Decimal("100.00") + Decimal(index),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 2, index + 1, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 2, index + 2, 10, 0, tzinfo=UTC),
|
||||
status="approved",
|
||||
approval_stage="归档入账",
|
||||
)
|
||||
)
|
||||
draft_claim = ExpenseClaim(
|
||||
id="claim-archive-query-draft",
|
||||
claim_no="EXP-ARCHIVE-DRAFT",
|
||||
employee=employee,
|
||||
employee_id=employee.id,
|
||||
employee_name="归档员工",
|
||||
department_name="交付部",
|
||||
expense_type="travel",
|
||||
reason="未归档草稿",
|
||||
location="上海",
|
||||
amount=Decimal("999.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 3, 1, 9, 0, tzinfo=UTC),
|
||||
submitted_at=None,
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
)
|
||||
db.add_all([employee, *claims, draft_claim])
|
||||
db.commit()
|
||||
|
||||
response = OrchestratorService(db).run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="archive-query@example.com",
|
||||
message="帮我查询一下我的归档的单据有哪些?",
|
||||
context_json={
|
||||
"client_now_iso": "2026-05-21T04:00:00.000Z",
|
||||
"client_timezone_offset_minutes": -480,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
query_payload = response.result["query_payload"]
|
||||
assert response.status == "succeeded"
|
||||
assert response.trace_summary.intent == "query"
|
||||
assert query_payload["record_count"] == 6
|
||||
assert query_payload["preview_count"] == 5
|
||||
assert query_payload["preview_limit"] == 5
|
||||
assert query_payload["title"] == "最近 5 条你的归档报销单"
|
||||
assert all(record["status"] == "approved" for record in query_payload["records"])
|
||||
assert "EXP-ARCHIVE-DRAFT" not in [record["claim_no"] for record in query_payload["records"]]
|
||||
assert response.result["suggested_actions"] == []
|
||||
assert "下面先列出最近 5 条记录" in response.result["answer"]
|
||||
|
||||
|
||||
def test_orchestrator_expense_preview_does_not_persist_claim_before_user_action(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
|
||||
@@ -700,6 +700,37 @@ def test_user_agent_guides_riding_fare_as_transport_expense() -> None:
|
||||
assert "“交通费”" in response.review_payload.intent_summary
|
||||
|
||||
|
||||
def test_user_agent_keeps_taxi_ticket_for_customer_dropoff_as_transport_expense() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
message = "送客户去机场,报销的士票"
|
||||
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 ontology.intent == "draft"
|
||||
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["reason"].value == "送客户去机场,报销的士票"
|
||||
assert "业务招待费" not in response.review_payload.intent_summary
|
||||
assert "客户名称" not in response.review_payload.missing_slots
|
||||
assert "参与人员" not in response.review_payload.missing_slots
|
||||
|
||||
|
||||
def test_user_agent_keeps_travel_range_when_user_adds_receipts_after_text_context() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
@@ -1060,6 +1091,40 @@ def test_user_agent_returns_draft_limit_message_when_save_is_blocked() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_user_agent_save_draft_answer_guides_followup_to_existing_draft() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
context_json = {"review_action": "save_draft"}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="请按当前识别信息保存报销草稿",
|
||||
user_id="pytest",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest",
|
||||
message="请按当前识别信息保存报销草稿",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload={
|
||||
"claim_id": "claim-1",
|
||||
"claim_no": "BX202605220001",
|
||||
"status": "draft",
|
||||
"approval_stage": "待提交",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.draft_payload is not None
|
||||
assert response.draft_payload.claim_no == "BX202605220001"
|
||||
assert "已按您当前确认的信息保存为草稿 BX202605220001" in response.answer
|
||||
assert "请关联这张草稿" in response.answer
|
||||
assert "继续保存草稿" not in response.answer
|
||||
|
||||
|
||||
def test_user_agent_returns_submitted_draft_payload_for_review_next_step() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
@@ -2022,6 +2087,8 @@ def test_user_agent_filters_deprecated_review_risk_briefs() -> None:
|
||||
def test_user_agent_submission_blocked_risk_level_only_marks_amount_reasons_high() -> None:
|
||||
assert UserAgentService._resolve_submission_blocked_risk_level("住宿金额超出当前职级差标") == "high"
|
||||
assert UserAgentService._resolve_submission_blocked_risk_level("缺少直属领导或参与人员信息") == "warning"
|
||||
assert UserAgentService._is_submission_exception_explanation_reason("住宿金额超出当前职级差标,且未补充超标说明。")
|
||||
assert not UserAgentService._is_submission_exception_explanation_reason("缺少直属领导或参与人员信息")
|
||||
|
||||
|
||||
def test_user_agent_review_payload_shows_ai_precheck_failure_in_main_message() -> None:
|
||||
@@ -2066,11 +2133,13 @@ def test_user_agent_review_payload_shows_ai_precheck_failure_in_main_message() -
|
||||
|
||||
assert response.review_payload is not None
|
||||
assert response.answer == response.review_payload.body_message
|
||||
assert response.answer.startswith("AI预审未通过:住宿金额超出当前职级差标")
|
||||
assert "整改后再继续提交" in response.answer
|
||||
assert response.answer.startswith("检测到当前单据存在需要说明的超标风险")
|
||||
assert "票据会先正常归集到单据中" in response.answer
|
||||
assert "附加说明" in response.answer
|
||||
assert response.review_payload.can_proceed is False
|
||||
blocked_brief = next(item for item in response.review_payload.risk_briefs if item.title == "提交风险提示")
|
||||
assert blocked_brief.level == "high"
|
||||
assert "不是票据归集阻断条件" in blocked_brief.detail
|
||||
assert not any(item.title == "AI预审未通过" for item in response.review_payload.risk_briefs)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user