feat: 增加差旅报销标准测算和财务终审流程

新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分
直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层
缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流
交互并补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-21 09:28:33 +08:00
parent 002bf4f756
commit 8f65661809
43 changed files with 4366 additions and 410 deletions

View File

@@ -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)

View File

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

View File

@@ -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"]

View File

@@ -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 == ""

View File

@@ -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

View File

@@ -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:

View File

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