feat: 增加差旅报销标准测算和财务终审流程
新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分 直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层 缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流 交互并补充单元测试。
This commit is contained in:
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
@@ -24,11 +24,14 @@ from app.core.agent_enums import (
|
||||
)
|
||||
from app.core.config import SERVER_DIR
|
||||
from app.db.base import Base
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.models.employee import Employee
|
||||
from app.schemas.agent_asset import (
|
||||
AgentAssetCreate,
|
||||
AgentAssetReviewCreate,
|
||||
AgentAssetVersionCreate,
|
||||
)
|
||||
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||
@@ -41,6 +44,7 @@ from app.services.agent_runs import AgentRunService
|
||||
from app.services.audit import AuditLogService
|
||||
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService
|
||||
from app.services.settings import OnlyOfficeRuntimeConfig
|
||||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -618,6 +622,126 @@ def test_agent_asset_service_returns_travel_policy_rule_detail() -> None:
|
||||
assert "住宿标准、飞机舱位和火车席别" in str(detail.current_version_content)
|
||||
|
||||
|
||||
def test_expense_rule_runtime_reads_amount_standards_from_travel_spreadsheet() -> None:
|
||||
with build_session() as db:
|
||||
AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value)
|
||||
travel_spreadsheet_rule = db.scalar(
|
||||
select(AgentAsset).where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
|
||||
)
|
||||
assert travel_spreadsheet_rule is not None
|
||||
travel_spreadsheet_rule.status = AgentAssetStatus.REVIEW.value
|
||||
db.commit()
|
||||
|
||||
catalog = ExpenseRuleRuntimeService(db).load_catalog()
|
||||
|
||||
assert catalog.travel_policy is not None
|
||||
assert catalog.travel_policy.standard_rule_code == COMPANY_TRAVEL_EXPENSE_RULE_CODE
|
||||
assert catalog.travel_policy.standard_rule_name == "公司差旅费报销规则"
|
||||
assert catalog.travel_policy.hotel_city_limits["北京"]["mid"] == 450
|
||||
assert catalog.travel_policy.hotel_city_limits["北京"]["junior"] == 450
|
||||
assert catalog.travel_policy.hotel_city_limits["北京"]["manager"] == 500
|
||||
assert catalog.travel_policy.allowance_limits["meal"]["直辖市/特区"] == 65
|
||||
assert catalog.travel_policy.allowance_limits["meal"]["其他地区"] == 55
|
||||
assert catalog.travel_policy.allowance_limits["total"]["其他地区"] == 90
|
||||
assert catalog.travel_policy.transport_limits["senior"]["flight"] == 1
|
||||
assert catalog.travel_policy.transport_limits["executive"]["train"] == 1
|
||||
|
||||
|
||||
def test_travel_reimbursement_calculator_uses_finance_spreadsheet_amounts() -> None:
|
||||
with build_session() as db:
|
||||
db.add(
|
||||
Employee(
|
||||
employee_no="E9001",
|
||||
name="测试员工",
|
||||
email="traveler@example.com",
|
||||
position="产品经理",
|
||||
grade="P4",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
result = TravelReimbursementCalculatorService(db).calculate(
|
||||
TravelReimbursementCalculatorRequest(days=3, location="北京市朝阳区"),
|
||||
CurrentUserContext(
|
||||
username="traveler@example.com",
|
||||
name="测试员工",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
),
|
||||
)
|
||||
|
||||
assert result.rule_name == "公司差旅费报销规则"
|
||||
assert result.grade == "P4"
|
||||
assert result.grade_band == "mid"
|
||||
assert result.matched_city == "北京"
|
||||
assert result.hotel_rate == 450
|
||||
assert result.hotel_amount == 1350
|
||||
assert result.allowance_region == "直辖市/特区"
|
||||
assert result.total_allowance_rate == 100
|
||||
assert result.allowance_amount == 300
|
||||
assert result.total_amount == 1650
|
||||
assert "住宿 450.00 × 3 天 + 补贴 100.00 × 3 天 = 1650.00" == result.formula_text
|
||||
assert "参考可报销总金额为 1650.00 元" in result.summary_text
|
||||
|
||||
|
||||
def test_travel_reimbursement_calculator_uses_other_region_for_known_unlisted_location() -> None:
|
||||
with build_session() as db:
|
||||
db.add(
|
||||
Employee(
|
||||
employee_no="E9002",
|
||||
name="其他地区员工",
|
||||
email="other-region@example.com",
|
||||
position="产品经理",
|
||||
grade="P4",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
result = TravelReimbursementCalculatorService(db).calculate(
|
||||
TravelReimbursementCalculatorRequest(days=2, location="吉林延边"),
|
||||
CurrentUserContext(
|
||||
username="other-region@example.com",
|
||||
name="其他地区员工",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
),
|
||||
)
|
||||
|
||||
assert result.matched_city == "延边(其他地区)"
|
||||
assert result.city_tier == "tier_3"
|
||||
assert result.hotel_rate == 380
|
||||
assert result.hotel_amount == 760
|
||||
assert result.allowance_region == "其他地区"
|
||||
assert result.total_allowance_rate == 90
|
||||
assert result.allowance_amount == 180
|
||||
assert result.total_amount == 940
|
||||
|
||||
|
||||
def test_travel_reimbursement_calculator_rejects_unrecognized_location() -> None:
|
||||
with build_session() as db:
|
||||
db.add(
|
||||
Employee(
|
||||
employee_no="E9003",
|
||||
name="无效地点员工",
|
||||
email="invalid-location@example.com",
|
||||
position="产品经理",
|
||||
grade="P4",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(ValueError, match="未识别为有效出差地区"):
|
||||
TravelReimbursementCalculatorService(db).calculate(
|
||||
TravelReimbursementCalculatorRequest(days=2, location="背景"),
|
||||
CurrentUserContext(
|
||||
username="invalid-location@example.com",
|
||||
name="无效地点员工",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_agent_run_service_lists_seeded_trace_data() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentRunService(db)
|
||||
|
||||
@@ -51,6 +51,27 @@ def test_document_intelligence_extracts_larger_decimal_amount_from_multiple_cand
|
||||
assert any(field.label == "金额" and field.value == "13.4元" for field in insight.fields)
|
||||
|
||||
|
||||
def test_document_intelligence_prefers_train_ticket_for_railway_e_ticket_invoice_text() -> None:
|
||||
insight = build_document_insight(
|
||||
filename="铁路电子客票.pdf",
|
||||
summary="电子发票(铁路电子客票)",
|
||||
text=(
|
||||
"电子发票(铁路电子客票)\n"
|
||||
"发票号码:26319166100006175398\n"
|
||||
"上海虹桥站\n"
|
||||
"武汉站\n"
|
||||
"G456\n"
|
||||
"二等座\n"
|
||||
"票价:¥354.00"
|
||||
),
|
||||
)
|
||||
|
||||
assert insight.document_type == "train_ticket"
|
||||
assert insight.document_type_label == "火车/高铁票"
|
||||
assert insight.scene_code == "travel"
|
||||
assert any(field.label == "金额" and field.value == "354元" for field in insight.fields)
|
||||
|
||||
|
||||
def test_document_intelligence_service_keeps_rule_fields_without_model_correction() -> None:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.models.organization import OrganizationUnit
|
||||
from app.schemas.ontology import OntologyParseRequest
|
||||
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
|
||||
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.ontology import SemanticOntologyService
|
||||
from app.services.ocr import OcrService
|
||||
@@ -722,6 +723,82 @@ def test_update_claim_item_reanalyzes_existing_attachment(monkeypatch, tmp_path)
|
||||
assert any("附件类型要求" in point for point in refreshed_meta["analysis"]["points"])
|
||||
|
||||
|
||||
def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_path) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-1",
|
||||
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="train-ticket.png",
|
||||
media_type="image/png",
|
||||
text="中国铁路电子客票 广州南-北京南 二等座 票价:¥354.00",
|
||||
summary="铁路电子客票,票价 354 元。",
|
||||
avg_score=0.98,
|
||||
line_count=1,
|
||||
page_count=1,
|
||||
document_type="train_ticket",
|
||||
document_type_label="火车/高铁票",
|
||||
scene_code="travel",
|
||||
scene_label="差旅费",
|
||||
document_fields=[
|
||||
{"key": "fare", "label": "票价", "value": "¥354.00"},
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
|
||||
monkeypatch.setattr(ExpenseClaimService, "_get_attachment_storage_root", lambda self: tmp_path)
|
||||
|
||||
with build_session() as db:
|
||||
claim = build_claim(expense_type="travel", location="北京")
|
||||
claim.amount = Decimal("0.00")
|
||||
claim.invoice_count = 0
|
||||
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="train-ticket.png",
|
||||
content=b"fake-image-bytes",
|
||||
media_type="image/png",
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
assert updated is not None
|
||||
assert updated["item_amount"] == Decimal("354.00")
|
||||
assert updated["claim_amount"] == Decimal("354.00")
|
||||
db.refresh(claim)
|
||||
assert claim.items[0].item_amount == Decimal("354.00")
|
||||
assert claim.amount == Decimal("354.00")
|
||||
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
|
||||
assert uploaded_meta["document_info"]["document_type"] == "train_ticket"
|
||||
assert any(
|
||||
field["label"] == "票价" and field["value"] == "¥354.00"
|
||||
for field in uploaded_meta["document_info"]["fields"]
|
||||
)
|
||||
|
||||
|
||||
def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_path) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-1",
|
||||
@@ -1502,7 +1579,7 @@ def test_list_claims_allows_executive_to_view_all_records() -> None:
|
||||
assert {claim.claim_no for claim in claims} == {"EXP-EXE-101", "EXP-EXE-102"}
|
||||
|
||||
|
||||
def test_privileged_user_can_return_and_delete_submitted_claim() -> None:
|
||||
def test_finance_can_return_but_cannot_delete_submitted_claim() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance@example.com",
|
||||
name="财务",
|
||||
@@ -1545,10 +1622,46 @@ def test_privileged_user_can_return_and_delete_submitted_claim() -> None:
|
||||
for flag in returned.risk_flags_json
|
||||
)
|
||||
|
||||
deleted = service.delete_claim(claim_id, current_user)
|
||||
with pytest.raises(ValueError, match="只有高级管理人员可以删除"):
|
||||
service.delete_claim(claim_id, current_user)
|
||||
|
||||
assert db.get(ExpenseClaim, claim_id) is not None
|
||||
|
||||
|
||||
def test_executive_can_delete_submitted_claim() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="executive-delete@example.com",
|
||||
name="高管",
|
||||
role_codes=["executive"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="EXP-DEL-EXEC-101",
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel",
|
||||
reason="差旅报销",
|
||||
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="submitted",
|
||||
approval_stage="财务审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
||||
|
||||
assert deleted is not None
|
||||
assert deleted.claim_no == "EXP-RET-101"
|
||||
assert deleted.claim_no == "EXP-DEL-EXEC-101"
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
|
||||
|
||||
@@ -1675,6 +1788,56 @@ def test_direct_manager_can_approve_subordinate_claim_to_finance_review() -> Non
|
||||
)
|
||||
|
||||
|
||||
def test_finance_can_approve_claim_to_archive_stage() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-approve@example.com",
|
||||
name="财务复核",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="EXP-FIN-APP-201",
|
||||
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 == "approved"
|
||||
assert approved.approval_stage == "归档入账"
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "finance_approval"
|
||||
and flag.get("event_type") == "expense_claim_finance_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",
|
||||
@@ -1836,6 +1999,16 @@ def test_submit_returned_claim_preserves_manual_return_events() -> None:
|
||||
claim.risk_flags_json = [return_flag]
|
||||
db.add_all([manager, employee, 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,
|
||||
},
|
||||
)
|
||||
conversation_id = conversation.conversation_id
|
||||
|
||||
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
|
||||
|
||||
@@ -1848,6 +2021,7 @@ def test_submit_returned_claim_preserves_manual_return_events() -> None:
|
||||
and flag.get("return_event_id") == "return-event-submit"
|
||||
for flag in list(submitted.risk_flags_json or [])
|
||||
)
|
||||
assert AgentConversationService(db).get_conversation(conversation_id) is None
|
||||
|
||||
|
||||
def test_manager_personal_claims_exclude_subordinate_pending_approval_claims() -> None:
|
||||
@@ -2001,3 +2175,57 @@ def test_list_approval_claims_allows_direct_manager_to_view_pending_claims_for_a
|
||||
|
||||
assert len(claims) == 1
|
||||
assert claims[0].claim_no == "EXP-MGR-201"
|
||||
|
||||
|
||||
def test_list_approval_claims_limits_finance_to_finance_stage_claims() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-approval-list@example.com",
|
||||
name="财务",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
db.add_all(
|
||||
[
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-FIN-LIST-201",
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-FIN",
|
||||
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=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-FIN-LIST-202",
|
||||
employee_name="李四",
|
||||
department_name="销售部",
|
||||
project_code="PRJ-FIN",
|
||||
expense_type="meal",
|
||||
reason="财务待审",
|
||||
location="杭州",
|
||||
amount=Decimal("188.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 12, 12, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 12, 13, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="财务审批",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
claims = ExpenseClaimService(db).list_approval_claims(current_user)
|
||||
|
||||
assert [claim.claim_no for claim in claims] == ["EXP-FIN-LIST-202"]
|
||||
|
||||
@@ -177,3 +177,80 @@ def test_ocr_service_converts_pdf_to_images_and_returns_image_preview(
|
||||
assert any(field.label == "车次/航班" and field.value == "G1234" for field in recognized.document_fields)
|
||||
assert recognized.lines[0].page_index == 0
|
||||
assert recognized.lines[1].page_index == 1
|
||||
|
||||
|
||||
def test_ocr_service_prefers_pdf_text_layer_when_rendered_ocr_is_placeholder_heavy(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
def fake_convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> list[Path]:
|
||||
page = output_dir / "page-1.png"
|
||||
page.write_bytes(b"fake-page")
|
||||
return [page]
|
||||
|
||||
def fake_invoke_worker(
|
||||
self,
|
||||
*,
|
||||
python_bin: str,
|
||||
worker_path: str,
|
||||
input_paths: list[Path],
|
||||
) -> dict:
|
||||
return {
|
||||
"engine": "paddleocr_mobile",
|
||||
"model": "PP-OCRv5_mobile",
|
||||
"documents": [
|
||||
{
|
||||
"input_path": str(input_paths[0]),
|
||||
"engine": "paddleocr_mobile",
|
||||
"model": "PP-OCRv5_mobile",
|
||||
"text": "□□□□□□\n□□□□:26319166100006175398\nG456\n□□:□354.00",
|
||||
"summary": "□□□□□□;□□□□:26319166100006175398",
|
||||
"avg_score": 0.88,
|
||||
"line_count": 4,
|
||||
"page_count": 1,
|
||||
"warnings": [],
|
||||
"lines": [
|
||||
{
|
||||
"text": "□□□□□□",
|
||||
"score": 0.88,
|
||||
"box": [[1, 2], [10, 2], [10, 8], [1, 8]],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
|
||||
monkeypatch.setattr(OcrService, "_resolve_python_bin", lambda self: "python")
|
||||
monkeypatch.setattr(OcrService, "_resolve_worker_path", lambda self: "worker.py")
|
||||
monkeypatch.setattr(OcrService, "_convert_pdf_to_images", fake_convert_pdf_to_images)
|
||||
monkeypatch.setattr(OcrService, "_invoke_worker", fake_invoke_worker)
|
||||
monkeypatch.setattr(
|
||||
OcrService,
|
||||
"_extract_pdf_text_layer",
|
||||
lambda self, pdf_path: (
|
||||
"电子发票(铁路电子客票)\n"
|
||||
"发票号码:26319166100006175398\n"
|
||||
"上海虹桥站\n"
|
||||
"武汉站\n"
|
||||
"G456\n"
|
||||
"票价:¥354.00"
|
||||
),
|
||||
)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
result = OcrService().recognize_files(
|
||||
[
|
||||
("train-ticket.pdf", b"%PDF-1.4 fake", "application/pdf"),
|
||||
]
|
||||
)
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
recognized = result.documents[0]
|
||||
assert "电子发票(铁路电子客票)" in recognized.text
|
||||
assert "上海虹桥站" in recognized.text
|
||||
assert "□□□□" not in recognized.summary
|
||||
assert recognized.document_type == "train_ticket"
|
||||
assert recognized.preview_kind == ""
|
||||
assert recognized.preview_data_url == ""
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.db.base import Base
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.schemas.orchestrator import OrchestratorRequest
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
from app.services.orchestrator import OrchestratorService
|
||||
|
||||
|
||||
@@ -96,6 +97,8 @@ def test_review_next_step_run_submits_existing_claim_and_returns_draft_payload(
|
||||
assert claim.status == "submitted"
|
||||
assert claim.approval_stage == "直属领导审批"
|
||||
assert claim.submitted_at is not None
|
||||
assert response.conversation_id
|
||||
assert AgentConversationService(db).get_conversation(response.conversation_id) is None
|
||||
|
||||
|
||||
def test_review_next_step_blocked_returns_reasons_and_removes_next_step_action(
|
||||
@@ -165,6 +168,8 @@ def test_review_next_step_blocked_returns_reasons_and_removes_next_step_action(
|
||||
|
||||
assert response.status == "succeeded"
|
||||
assert result["draft_payload"]["status"] == "draft"
|
||||
assert response.conversation_id
|
||||
assert AgentConversationService(db).get_conversation(response.conversation_id) is not None
|
||||
assert "AI预审暂未通过" in result["answer"]
|
||||
assert "所属部门未完善" in result["answer"]
|
||||
assert "next_step" not in actions
|
||||
|
||||
@@ -345,9 +345,17 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review()
|
||||
assert any(
|
||||
item["source"] == "manual_approval"
|
||||
and item["opinion"] == "情况属实,同意报销。"
|
||||
and item["operator"] == "李经理"
|
||||
and item["next_approval_stage"] == "财务审批"
|
||||
for item in payload["risk_flags_json"]
|
||||
)
|
||||
approval_events = [
|
||||
item
|
||||
for item in payload["risk_flags_json"]
|
||||
if item["source"] == "manual_approval"
|
||||
]
|
||||
assert approval_events[0]["operator"] == "李经理"
|
||||
assert "manager-approve-api@example.com" not in approval_events[0]["message"]
|
||||
|
||||
|
||||
def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch, tmp_path) -> None:
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.base import Base
|
||||
from app.schemas.ontology import OntologyParseRequest
|
||||
from app.schemas.user_agent import UserAgentRequest
|
||||
from app.services.ontology import SemanticOntologyService
|
||||
from app.services.user_agent import UserAgentService
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.core.agent_enums import AgentAssetType
|
||||
from app.schemas.ontology import OntologyParseRequest
|
||||
from app.schemas.user_agent import UserAgentCitation, UserAgentRequest, UserAgentReviewRiskBrief
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
from app.services.ontology import SemanticOntologyService
|
||||
from app.services.user_agent import UserAgentService
|
||||
|
||||
|
||||
def build_session_factory() -> sessionmaker[Session]:
|
||||
@@ -1096,11 +1101,11 @@ def test_user_agent_prefers_larger_decimal_amount_from_ocr_text_candidates() ->
|
||||
assert slot_map["amount"].value == "13.40元"
|
||||
|
||||
|
||||
def test_user_agent_review_payload_keeps_document_preview_data() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
def test_user_agent_review_payload_keeps_document_preview_data() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="我上传了打车票据,帮我生成报销草稿",
|
||||
user_id="pytest",
|
||||
context_json={
|
||||
@@ -1147,15 +1152,465 @@ def test_user_agent_review_payload_keeps_document_preview_data() -> None:
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
assert response.review_payload.document_cards[0].preview_kind == "image"
|
||||
assert response.review_payload.document_cards[0].preview_data_url.startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
def test_user_agent_prompts_existing_draft_association_choice_for_multi_documents() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
assert response.review_payload.document_cards[0].preview_kind == "image"
|
||||
assert response.review_payload.document_cards[0].preview_data_url.startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
def test_user_agent_review_payload_prechecks_travel_receipts_against_policy_and_hides_old_briefs(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
employee = Employee(
|
||||
employee_no="E-TRAVEL-001",
|
||||
name="张三",
|
||||
email="pytest-travel@example.com",
|
||||
position="实施顾问",
|
||||
grade="P4",
|
||||
)
|
||||
db.add(employee)
|
||||
db.flush()
|
||||
db.add(
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-HISTORY-001",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_name="交付部",
|
||||
expense_type="travel",
|
||||
reason="历史差旅记录",
|
||||
location="北京",
|
||||
amount=Decimal("680.00"),
|
||||
invoice_count=1,
|
||||
occurred_at=datetime.now(UTC) - timedelta(days=7),
|
||||
status="draft",
|
||||
risk_flags_json=[{"label": "历史风险"}],
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
query = "我去北京出差住酒店,上传了北京酒店发票,帮我生成差旅费报销草稿"
|
||||
context = {
|
||||
"name": "张三",
|
||||
"attachment_names": ["北京酒店发票.png"],
|
||||
"attachment_count": 1,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "北京酒店发票.png",
|
||||
"document_type": "hotel_invoice",
|
||||
"summary": "北京中心酒店 住宿 1 晚 金额 680 元",
|
||||
"text": "北京中心酒店 住宿 1 晚 金额 680 元",
|
||||
"avg_score": 0.96,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "金额", "value": "680"},
|
||||
{"key": "merchant", "label": "酒店", "value": "北京中心酒店"},
|
||||
],
|
||||
"warnings": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest-travel@example.com",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
service = UserAgentService(db)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_build_citations",
|
||||
lambda payload: [
|
||||
UserAgentCitation(
|
||||
source_type="rule",
|
||||
code="rule.expense.travel_risk_control_standard",
|
||||
title="差旅报销风险管控制度",
|
||||
version="v1.1.0",
|
||||
excerpt="住宿费按职级和城市分级限额执行。",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
response = service.respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest-travel@example.com",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
tool_payload={"draft_only": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
titles = [item.title for item in response.review_payload.risk_briefs]
|
||||
assert "历史报销画像" not in titles
|
||||
assert "制度注意事项" not in titles
|
||||
hotel_brief = next(item for item in response.review_payload.risk_briefs if "住宿超标" in item.title)
|
||||
combined = f"{hotel_brief.title}\n{hotel_brief.content}\n{hotel_brief.detail}\n{hotel_brief.suggestion}"
|
||||
assert "北京酒店发票.png" in combined
|
||||
assert "P4-P5" in combined
|
||||
assert "680.00" in combined
|
||||
assert "450.00" in combined
|
||||
assert "公司差旅费报销规则" in combined
|
||||
assert "补充超标说明" in combined
|
||||
slot_map = {item.key: item for item in response.review_payload.slot_cards}
|
||||
assert slot_map["merchant_name"].value == "北京中心酒店"
|
||||
|
||||
|
||||
def test_user_agent_review_payload_prefers_hotel_invoice_for_hotel_name() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
query = "我去北京出差,上传了火车票和酒店发票,帮我生成差旅费报销草稿"
|
||||
context = {
|
||||
"name": "张三",
|
||||
"attachment_names": ["北京南站火车票.png", "北京中心酒店发票.png"],
|
||||
"attachment_count": 2,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "北京南站火车票.png",
|
||||
"document_type": "train_ticket",
|
||||
"summary": "广州南至北京南 高铁二等座 金额 560 元",
|
||||
"text": "广州南至北京南 高铁二等座 金额 560 元",
|
||||
"avg_score": 0.95,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "金额", "value": "560"},
|
||||
],
|
||||
"warnings": [],
|
||||
},
|
||||
{
|
||||
"filename": "北京中心酒店发票.png",
|
||||
"document_type": "hotel_invoice",
|
||||
"summary": "北京中心酒店 住宿 1 晚 金额 450 元",
|
||||
"text": "北京中心酒店 住宿 1 晚 金额 450 元",
|
||||
"avg_score": 0.96,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "金额", "value": "450"},
|
||||
{"key": "merchant", "label": "酒店名称", "value": "北京中心酒店"},
|
||||
],
|
||||
"warnings": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest-travel-hotel-name@example.com",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest-travel-hotel-name@example.com",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
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["merchant_name"].value == "北京中心酒店"
|
||||
|
||||
|
||||
def test_user_agent_review_payload_prechecks_taxi_amount_against_rule_standard() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
query = "我去北京出差,上传了一张打车票,帮我生成差旅费报销草稿"
|
||||
context = {
|
||||
"name": "张三",
|
||||
"grade": "P4",
|
||||
"attachment_names": ["北京打车票.png"],
|
||||
"attachment_count": 1,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "北京打车票.png",
|
||||
"document_type": "taxi_receipt",
|
||||
"summary": "北京网约车 打车票 支付金额 360 元",
|
||||
"text": "北京网约车 打车票 支付金额 360 元",
|
||||
"avg_score": 0.95,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "支付金额", "value": "360"},
|
||||
],
|
||||
"warnings": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
tool_payload={"draft_only": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
amount_brief = next(item for item in response.review_payload.risk_briefs if "交通费金额超标" in item.title)
|
||||
combined = f"{amount_brief.title}\n{amount_brief.content}\n{amount_brief.detail}\n{amount_brief.suggestion}"
|
||||
assert "北京打车票.png" in combined
|
||||
assert "360.00" in combined
|
||||
assert "300.00" in combined
|
||||
assert "单笔交通金额" in combined
|
||||
assert "报销场景提交与附件标准" in combined
|
||||
assert amount_brief.level == "high"
|
||||
assert any(item.title == "附件金额测算结果" for item in response.review_payload.risk_briefs)
|
||||
|
||||
|
||||
def test_user_agent_review_payload_uses_finance_spreadsheet_hotel_amount_standard() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value)
|
||||
employee = Employee(
|
||||
employee_no="E-TRAVEL-XLSX-001",
|
||||
name="测算员工",
|
||||
email="pytest-travel-xlsx@example.com",
|
||||
position="基层经理",
|
||||
grade="P4",
|
||||
)
|
||||
db.add(employee)
|
||||
db.commit()
|
||||
|
||||
query = "测算员工去北京出差住宿,上传了北京酒店发票,帮我生成差旅费报销草稿"
|
||||
context = {
|
||||
"name": "测算员工",
|
||||
"attachment_names": ["北京酒店发票.png"],
|
||||
"attachment_count": 1,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "北京酒店发票.png",
|
||||
"document_type": "hotel_invoice",
|
||||
"summary": "北京酒店 住宿 1 晚 金额 480 元",
|
||||
"text": "北京酒店 住宿 1 晚 金额 480 元",
|
||||
"avg_score": 0.96,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "金额", "value": "480"},
|
||||
],
|
||||
"warnings": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest-travel-xlsx@example.com",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest-travel-xlsx@example.com",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
tool_payload={"draft_only": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
hotel_brief = next(item for item in response.review_payload.risk_briefs if "住宿超标" in item.title)
|
||||
combined = f"{hotel_brief.content}\n{hotel_brief.detail}"
|
||||
assert "480.00" in combined
|
||||
assert "450.00" in combined
|
||||
assert "公司差旅费报销规则" in combined
|
||||
|
||||
|
||||
def test_user_agent_review_payload_uses_spreadsheet_city_hotel_standard_not_default_tier() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value)
|
||||
|
||||
query = "我去张家口出差住宿,上传了张家口酒店发票,帮我生成差旅费报销草稿"
|
||||
context = {
|
||||
"name": "张三",
|
||||
"grade": "P4",
|
||||
"attachment_names": ["张家口酒店发票.png"],
|
||||
"attachment_count": 1,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "张家口酒店发票.png",
|
||||
"document_type": "hotel_invoice",
|
||||
"summary": "张家口酒店 住宿 1 晚 金额 320 元",
|
||||
"text": "张家口酒店 住宿 1 晚 金额 320 元",
|
||||
"avg_score": 0.96,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "金额", "value": "320"},
|
||||
],
|
||||
"warnings": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest-travel-city@example.com",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest-travel-city@example.com",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
tool_payload={"draft_only": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
hotel_brief = next(item for item in response.review_payload.risk_briefs if "住宿超标" in item.title)
|
||||
combined = f"{hotel_brief.content}\n{hotel_brief.detail}"
|
||||
assert "320.00" in combined
|
||||
assert "300.00" in combined
|
||||
assert "公司差旅费报销规则" in combined
|
||||
|
||||
|
||||
def test_user_agent_review_payload_uses_finance_spreadsheet_meal_allowance_standard() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value)
|
||||
|
||||
query = "我去北京出差,上传了一张餐饮发票,帮我生成差旅费报销草稿"
|
||||
context = {
|
||||
"name": "张三",
|
||||
"grade": "P4",
|
||||
"attachment_names": ["北京餐饮发票.png"],
|
||||
"attachment_count": 1,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "北京餐饮发票.png",
|
||||
"document_type": "meal_receipt",
|
||||
"summary": "北京餐饮发票 金额 90 元",
|
||||
"text": "北京餐饮发票 金额 90 元",
|
||||
"avg_score": 0.96,
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "金额", "value": "90"},
|
||||
],
|
||||
"warnings": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest-travel-meal@example.com",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest-travel-meal@example.com",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
tool_payload={"draft_only": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
meal_brief = next(item for item in response.review_payload.risk_briefs if "伙食补助标准" in item.title)
|
||||
combined = f"{meal_brief.title}\n{meal_brief.content}\n{meal_brief.detail}\n{meal_brief.suggestion}"
|
||||
assert "北京餐饮发票.png" in combined
|
||||
assert "90.00" in combined
|
||||
assert "65.00" in combined
|
||||
assert "直辖市/特区" in combined
|
||||
assert "公司差旅费报销规则" in combined
|
||||
assert meal_brief.level == "high"
|
||||
measurement = next(item for item in response.review_payload.risk_briefs if item.title == "附件金额测算结果")
|
||||
assert "伙食补助标准 65.00" in measurement.detail
|
||||
|
||||
|
||||
def test_user_agent_filters_deprecated_review_risk_briefs() -> None:
|
||||
filtered = UserAgentService._filter_deprecated_review_risk_briefs(
|
||||
[
|
||||
UserAgentReviewRiskBrief(title="历史报销画像", level="info", content="旧画像"),
|
||||
UserAgentReviewRiskBrief(title="用户画像", level="info", content="旧画像"),
|
||||
UserAgentReviewRiskBrief(title="制度注意事项", level="info", content="旧制度提示"),
|
||||
UserAgentReviewRiskBrief(title="住宿超标待说明", level="high", content="保留"),
|
||||
]
|
||||
)
|
||||
|
||||
assert [item.title for item in filtered] == ["住宿超标待说明"]
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_user_agent_review_payload_shows_ai_precheck_failure_in_main_message() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
query = "我去北京出差住酒店,帮我生成差旅费报销草稿并进入下一步提交"
|
||||
context = {
|
||||
"name": "张三",
|
||||
"attachment_names": ["北京酒店发票.png"],
|
||||
"attachment_count": 1,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "北京酒店发票.png",
|
||||
"document_type": "hotel_invoice",
|
||||
"summary": "北京酒店 住宿 1 晚 金额 680 元",
|
||||
"text": "北京酒店 住宿 1 晚 金额 680 元",
|
||||
"avg_score": 0.94,
|
||||
}
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest",
|
||||
context_json=context,
|
||||
)
|
||||
)
|
||||
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest",
|
||||
message=query,
|
||||
ontology=ontology,
|
||||
context_json=context,
|
||||
tool_payload={
|
||||
"submission_blocked": True,
|
||||
"submission_blocked_reasons": ["住宿金额超出当前职级差标,且未补充超标说明。"],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
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.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 not any(item.title == "AI预审未通过" for item in response.review_payload.risk_briefs)
|
||||
|
||||
|
||||
def test_user_agent_prompts_existing_draft_association_choice_for_multi_documents() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="我上传了两张票据,帮我生成报销草稿",
|
||||
user_id="pytest",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user