Files
X-Financial/server/tests/test_orchestrator_review_flow.py
caoxiaozhu 88ff04bef8 feat: 新增归档中心页面并完善知识库与报销查询能力
新增前端归档中心视图及相关工具函数,扩充知识库文档分类和
提取器支持多种格式,增强编排器报销查询的多维度检索,优
化本体规则和用户代理审核消息,前端完善报销创建和审批详
情交互细节,补充单元测试覆盖。
2026-05-22 16:00:19 +08:00

546 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from datetime import UTC, date, datetime
from decimal import Decimal
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
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
def build_session_factory() -> sessionmaker[Session]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
@pytest.fixture(autouse=True)
def skip_agent_foundation_bootstrap(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"app.services.agent_foundation.AgentFoundationService.ensure_foundation_ready",
lambda *_args, **_kwargs: None,
)
def test_review_next_step_run_submits_existing_claim_and_returns_draft_payload(
monkeypatch,
) -> None:
monkeypatch.setattr(
"app.services.runtime_chat.RuntimeChatService.complete",
lambda *_args, **_kwargs: None,
)
session_factory = build_session_factory()
with session_factory() as db:
manager = Employee(
employee_no="E9000",
name="李经理",
email="manager-next@example.com",
)
employee = Employee(
employee_no="E9001",
name="张三",
email="emp-next@example.com",
manager=manager,
)
claim = ExpenseClaim(
id="claim-next-step",
claim_no="EXP-202605-001",
employee=employee,
employee_id=employee.id,
employee_name="张三",
department_name="销售部",
expense_type="office",
reason="采购办公用品",
location="上海",
amount=Decimal("128.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 20, 9, 0, tzinfo=UTC),
status="draft",
approval_stage="待提交",
items=[
ExpenseClaimItem(
item_date=date(2026, 5, 20),
item_type="office",
item_reason="采购办公用品",
item_location="上海",
item_amount=Decimal("128.00"),
invoice_id="office-invoice.png",
)
],
)
db.add_all([manager, employee, claim])
db.commit()
response = OrchestratorService(db).run(
OrchestratorRequest(
source="user_message",
user_id="emp-next@example.com",
message="我已核对右侧识别结果,请进入下一步。",
context_json={
"review_action": "next_step",
"draft_claim_id": claim.id,
"attachment_count": 1,
"name": "张三",
},
)
)
db.refresh(claim)
assert response.status == "succeeded"
assert response.requires_confirmation is False
assert response.result["draft_payload"]["status"] == "submitted"
assert response.result["draft_payload"]["approval_stage"] == "直属领导审批"
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(
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(
employee_no="E9011",
name="张三",
email="emp-blocked@example.com",
)
claim = ExpenseClaim(
id="claim-next-step-blocked",
claim_no="EXP-202605-002",
employee=employee,
employee_id=employee.id,
employee_name="张三",
department_name="待补充",
expense_type="office",
reason="采购办公用品",
location="上海",
amount=Decimal("128.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 20, 9, 0, tzinfo=UTC),
status="draft",
approval_stage="待提交",
items=[
ExpenseClaimItem(
item_date=date(2026, 5, 20),
item_type="office",
item_reason="采购办公用品",
item_location="上海",
item_amount=Decimal("128.00"),
invoice_id="office-invoice.png",
)
],
)
db.add_all([employee, claim])
db.commit()
response = OrchestratorService(db).run(
OrchestratorRequest(
source="user_message",
user_id="emp-blocked@example.com",
message="我已核对右侧识别结果,请进入下一步。",
context_json={
"review_action": "next_step",
"draft_claim_id": claim.id,
"attachment_count": 1,
"name": "张三",
},
)
)
result = response.result
review_payload = result["review_payload"]
actions = {
str(item.get("action_type") or "").strip()
for item in review_payload["confirmation_actions"]
}
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
assert "save_draft" in actions
assert any(
"所属部门未完善" in str(item.get("content") or "")
for item in review_payload["risk_briefs"]
)
def test_conversation_hydration_does_not_reuse_review_type_for_fresh_expense_prompt() -> None:
session_factory = build_session_factory()
with session_factory() as db:
service = AgentConversationService(db)
conversation = service.get_or_create_conversation(
conversation_id="conv-review-type-lock",
user_id="emp-review-type@example.com",
source="user_message",
context_json={
"session_type": "expense",
"draft_claim_id": "claim-old",
"attachment_names": ["old-train-ticket.pdf"],
"attachment_count": 1,
"review_form_values": {
"expense_type": "差旅费",
"business_location": "北京",
},
},
)
fresh_context = service.hydrate_context_json(
conversation=conversation,
context_json={"draft_claim_id": "claim-old"},
message="业务发生时间:2026-02-20 至 2026-02-23去上海支持上海电力部署项目申请报销",
)
continued_context = service.hydrate_context_json(
conversation=conversation,
context_json={},
message="继续补充酒店发票",
)
assert "draft_claim_id" not in fresh_context
assert "attachment_names" not in fresh_context
assert "review_form_values" not in fresh_context
assert fresh_context["conversation_state"]["review_form_values"]["expense_type"] == "差旅费"
assert continued_context["draft_claim_id"] == "claim-old"
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:
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-history-query",
employee_no="E9020",
name="张三",
email="history-query@example.com",
)
beijing_claim = ExpenseClaim(
id="claim-history-beijing",
claim_no="EXP-202506-001",
employee=employee,
employee_id=employee.id,
employee_name="张三",
department_name="交付部",
expense_type="travel",
reason="去北京支持客户项目",
location="北京",
amount=Decimal("321.45"),
currency="CNY",
invoice_count=2,
occurred_at=datetime(2025, 6, 18, 9, 0, tzinfo=UTC),
submitted_at=datetime(2025, 6, 19, 10, 0, tzinfo=UTC),
status="paid",
approval_stage="已入账",
)
shanghai_claim = ExpenseClaim(
id="claim-history-shanghai",
claim_no="EXP-202507-001",
employee=employee,
employee_id=employee.id,
employee_name="张三",
department_name="交付部",
expense_type="travel",
reason="去上海支持项目",
location="上海",
amount=Decimal("888.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2025, 7, 8, 9, 0, tzinfo=UTC),
submitted_at=datetime(2025, 7, 9, 10, 0, tzinfo=UTC),
status="paid",
approval_stage="已入账",
)
current_year_claim = ExpenseClaim(
id="claim-history-beijing-current",
claim_no="EXP-202601-001",
employee=employee,
employee_id=employee.id,
employee_name="张三",
department_name="交付部",
expense_type="travel",
reason="去北京支持年度项目",
location="北京",
amount=Decimal("666.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 1, 8, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 1, 9, 10, 0, tzinfo=UTC),
status="paid",
approval_stage="已入账",
)
db.add_all([employee, beijing_claim, shanghai_claim, current_year_claim])
db.commit()
response = OrchestratorService(db).run(
OrchestratorRequest(
source="user_message",
user_id="history-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.scenario == "expense"
assert response.trace_summary.intent == "query"
assert query_payload["record_count"] == 1
assert query_payload["total_amount"] == 321.45
assert [item["claim_no"] for item in query_payload["records"]] == ["EXP-202506-001"]
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:
monkeypatch.setattr(
"app.services.runtime_chat.RuntimeChatService.complete",
lambda *_args, **_kwargs: None,
)
session_factory = build_session_factory()
with session_factory() as db:
employee = Employee(
employee_no="E9030",
name="预览员工",
email="preview-orchestrator@example.com",
)
db.add(employee)
db.commit()
response = OrchestratorService(db).run(
OrchestratorRequest(
source="user_message",
user_id="preview-orchestrator@example.com",
message="业务发生时间:2026-03-04打车去客户现场交通费32元请帮我看看怎么报",
context_json={
"name": "预览员工",
"user_input_text": "业务发生时间:2026-03-04打车去客户现场交通费32元请帮我看看怎么报",
},
)
)
user_claims = [
claim
for claim in db.query(ExpenseClaim).all()
if claim.employee_name == "预览员工"
]
assert response.status == "succeeded"
assert response.result.get("review_payload") is not None
assert response.result.get("draft_payload") is None
assert "交通费通常以实际票据金额为基础" in response.result["answer"]
assert user_claims == []
def test_orchestrator_prompts_scene_choices_before_review_for_fresh_ambiguous_expense(
monkeypatch,
) -> None:
monkeypatch.setattr(
"app.services.runtime_chat.RuntimeChatService.complete",
lambda *_args, **_kwargs: None,
)
session_factory = build_session_factory()
with session_factory() as db:
service = AgentConversationService(db)
conversation = service.get_or_create_conversation(
conversation_id="conv-scene-choice",
user_id="emp-scene-choice@example.com",
source="user_message",
context_json={
"session_type": "expense",
"draft_claim_id": "claim-old",
"review_form_values": {
"expense_type": "差旅费",
"business_location": "北京",
},
},
)
response = OrchestratorService(db).run(
OrchestratorRequest(
source="user_message",
user_id="emp-scene-choice@example.com",
conversation_id=conversation.conversation_id,
message="业务发生时间:2026-02-20 至 2026-02-23去上海支持上海电力部署项目申请报销",
context_json={
"session_type": "expense",
"draft_claim_id": "claim-old",
},
)
)
result = response.result
assert response.status == "succeeded"
assert result.get("review_payload") is None
assert result.get("draft_payload") is None
assert "请先在下面选择报销场景" in result["answer"]
assert [item["label"] for item in result["suggested_actions"][:3]] == ["差旅费", "交通费", "住宿费"]