feat: 完善文档中心与报销申请交互及侧边栏重构
后端优化编排器报销查询和本体检测精度,增强报销单草稿保 存和附件回填逻辑,前端重构侧边栏组件支持折叠和图标导 航,完善文档中心状态筛选和详情提示,报销创建和审批详情 页优化会话管理和费用明细交互,新增助手应用服务和预设动 作工具函数,补充单元测试覆盖。
This commit is contained in:
@@ -11,6 +11,7 @@ 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.ontology import OntologyParseResult, OntologyPermission
|
||||
from app.schemas.orchestrator import OrchestratorRequest
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
from app.services.orchestrator import OrchestratorService
|
||||
@@ -228,6 +229,50 @@ def test_conversation_hydration_does_not_reuse_review_type_for_fresh_expense_pro
|
||||
assert continued_context["review_form_values"]["expense_type"] == "差旅费"
|
||||
|
||||
|
||||
def test_conversation_hydration_preserves_incoming_application_time_context() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
service = AgentConversationService(db)
|
||||
conversation = service.get_or_create_conversation(
|
||||
conversation_id="conv-application-time-context",
|
||||
user_id="emp-application-time@example.com",
|
||||
source="user_message",
|
||||
context_json={
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"business_time_context": {
|
||||
"mode": "single",
|
||||
"start_date": "2026-05-01",
|
||||
"end_date": "2026-05-01",
|
||||
"display_value": "2026-05-01",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
stale_context = service.hydrate_context_json(
|
||||
conversation=conversation,
|
||||
context_json={"session_type": "application", "entry_source": "application"},
|
||||
message="apply travel expense",
|
||||
)
|
||||
fresh_context = service.hydrate_context_json(
|
||||
conversation=conversation,
|
||||
context_json={
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"business_time_context": {
|
||||
"mode": "single",
|
||||
"start_date": "2026-05-25",
|
||||
"end_date": "2026-05-25",
|
||||
"display_value": "2026-05-25",
|
||||
},
|
||||
},
|
||||
message="apply travel expense",
|
||||
)
|
||||
|
||||
assert "business_time_context" not in stale_context
|
||||
assert fresh_context["business_time_context"]["start_date"] == "2026-05-25"
|
||||
|
||||
|
||||
def test_conversation_scope_creates_new_session_for_different_claim() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
@@ -543,3 +588,225 @@ def test_orchestrator_prompts_scene_choices_before_review_for_fresh_ambiguous_ex
|
||||
assert result.get("draft_payload") is None
|
||||
assert "请先在下面选择报销场景" in result["answer"]
|
||||
assert [item["label"] for item in result["suggested_actions"][:3]] == ["差旅费", "交通费", "住宿费"]
|
||||
|
||||
|
||||
def test_orchestrator_application_session_does_not_use_reimbursement_scene_prompt(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.services.runtime_chat.RuntimeChatService.complete",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
session_factory = build_session_factory()
|
||||
message = (
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
)
|
||||
with session_factory() as db:
|
||||
response = OrchestratorService(db).run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-session@example.com",
|
||||
message=message,
|
||||
context_json={
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"name": "申请员工",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
result = response.result
|
||||
assert response.status == "blocked"
|
||||
assert response.trace_summary.scenario == "expense"
|
||||
assert "费用申请" in result["answer"]
|
||||
assert "| 发生时间 | 2026-05-25" in result["answer"]
|
||||
assert "请先在下面选择报销场景" not in result["answer"]
|
||||
assert result.get("review_payload") is None
|
||||
|
||||
|
||||
def test_orchestrator_application_session_guides_transport_amount_and_submit(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.services.runtime_chat.RuntimeChatService.complete",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
session_factory = build_session_factory()
|
||||
initial_message = (
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
)
|
||||
context_json = {
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"name": "申请员工",
|
||||
"manager_name": "陈硕",
|
||||
}
|
||||
with session_factory() as db:
|
||||
service = OrchestratorService(db)
|
||||
|
||||
first = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-flow@example.com",
|
||||
message=initial_message,
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
second = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-flow@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="飞机",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
third = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-flow@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="预计总费用:12000元",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
fourth = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-flow@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="确认提交",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
|
||||
assert first.status == "blocked"
|
||||
assert "当前还需要补充:出行方式、预计金额/预算" in first.result["answer"]
|
||||
assert [item["label"] for item in first.result["suggested_actions"]] == ["一次性补充申请信息"]
|
||||
assert first.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "出行方式:\n预计总费用:"
|
||||
|
||||
assert "当前还需要补充:预计金额/预算" in second.result["answer"]
|
||||
assert [item["label"] for item in second.result["suggested_actions"]] == ["一次性补充申请信息"]
|
||||
assert second.result["suggested_actions"][0]["action_type"] == "prefill_composer"
|
||||
assert second.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "预计总费用:"
|
||||
|
||||
assert "这是模拟的费用申请结果" in third.result["answer"]
|
||||
assert "| 事由 | 支持上海国网服务器部署 |" in third.result["answer"]
|
||||
assert "请核对上述信息无误" in third.result["answer"]
|
||||
assert "[确认](#application-submit)" in third.result["answer"]
|
||||
assert third.status == "blocked"
|
||||
assert third.result["requires_confirmation"] is True
|
||||
assert third.result["suggested_actions"] == []
|
||||
|
||||
assert fourth.status == "succeeded"
|
||||
assert fourth.result["clarification_required"] is False
|
||||
assert fourth.result["missing_slots"] == []
|
||||
assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in fourth.result["answer"]
|
||||
assert "当前状态:陈硕审核中" in fourth.result["answer"]
|
||||
assert fourth.result["suggested_actions"] == []
|
||||
application_claims = [
|
||||
claim
|
||||
for claim in db.query(ExpenseClaim).all()
|
||||
if claim.claim_no.startswith("APP-20260525-")
|
||||
]
|
||||
assert len(application_claims) == 1
|
||||
assert application_claims[0].status == "submitted"
|
||||
assert application_claims[0].approval_stage == "直属领导审批"
|
||||
assert fourth.result["draft_payload"]["claim_no"] == application_claims[0].claim_no
|
||||
|
||||
|
||||
def test_orchestrator_application_submit_bypasses_generic_operation_block(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.services.runtime_chat.RuntimeChatService.complete",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
session_factory = build_session_factory()
|
||||
initial_message = (
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
)
|
||||
context_json = {
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"name": "申请员工",
|
||||
"manager_name": "陈硕",
|
||||
}
|
||||
with session_factory() as db:
|
||||
service = OrchestratorService(db)
|
||||
|
||||
first = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-approval-required@example.com",
|
||||
message=initial_message,
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-approval-required@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="飞机",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
preview = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-approval-required@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="预计总费用:12000元",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
|
||||
def approval_required_parse_for_run(self, request, run_id): # noqa: ANN001
|
||||
return OntologyParseResult(
|
||||
scenario="expense",
|
||||
intent="operate",
|
||||
entities=[],
|
||||
permission=OntologyPermission(
|
||||
level="approval_required",
|
||||
allowed=False,
|
||||
reason="操作类请求需要人工审批确认。",
|
||||
),
|
||||
confidence=0.95,
|
||||
missing_slots=[],
|
||||
ambiguity=[],
|
||||
clarification_required=False,
|
||||
clarification_question=None,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.ontology.SemanticOntologyService.parse_for_run",
|
||||
approval_required_parse_for_run,
|
||||
)
|
||||
submitted = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-approval-required@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="确认提交",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
|
||||
assert preview.status == "blocked"
|
||||
assert submitted.status == "succeeded"
|
||||
assert submitted.requires_confirmation is False
|
||||
assert "操作类请求需要人工审批确认" not in submitted.result["answer"]
|
||||
assert "当前仅返回确认摘要" not in submitted.result["answer"]
|
||||
assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in submitted.result["answer"]
|
||||
assert submitted.result["draft_payload"]["status"] == "submitted"
|
||||
|
||||
Reference in New Issue
Block a user