feat: 增加差旅报销标准测算和财务终审流程
新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分 直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层 缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流 交互并补充单元测试。
This commit is contained in:
@@ -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