feat: 优化差旅报销预审流程与个人工作台 UI 体系

- 完善 user_agent_application 申请差旅报销预审槽位与消息组装
- 增强预算助理报告与风险建议卡片交互
- 重构登录页视觉样式与移动端响应式适配
- 优化个人工作台、文档中心、政策中心、员工管理等页面布局
- 拆分 travelRequestDetailPreReviewModel 为 advice/submit 模型
- 补充报销草稿、风险复核、Item Sync 与模板执行器测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-02 14:01:51 +08:00
parent 92444e7eae
commit ca691f3ee0
107 changed files with 5663 additions and 1542 deletions

View File

@@ -209,7 +209,7 @@ def test_user_agent_application_context_uses_application_language() -> None:
assert "费用申请" in response.answer
assert "| 字段 | 内容 |" in response.answer
assert "| 发生时间 | 2026-05-25 至 2026-05-27 |" in response.answer
assert "| 行程时间 | 2026-05-25 至 2026-05-27 |" in response.answer
assert "支持上海国网服务器部署" in response.answer
assert "当前还需要补充:出行方式" in response.answer
assert "请先在下面选择报销场景" not in response.answer
@@ -224,7 +224,7 @@ def test_user_agent_application_infers_natural_reason_and_expands_single_date()
with session_factory() as db:
response = build_application_user_agent_response(db, message)
assert "| 发生时间 | 2026-05-25 至 2026-05-27 |" in response.answer
assert "| 行程时间 | 2026-05-25 至 2026-05-27 |" in response.answer
assert "| 地点 | 上海市 |" in response.answer
assert "| 事由 | 支撑上海国网服务器部署 |" in response.answer
assert "当前还需要先补充:申请事由" not in response.answer
@@ -250,7 +250,7 @@ def test_user_agent_application_normalizes_location_to_region_city() -> None:
yili_response = build_application_user_agent_response(db, yili_message)
beijing_response = build_application_user_agent_response(db, beijing_message)
assert "| 发生时间 | 2026-05-25 至 2026-05-27 |" in yili_response.answer
assert "| 行程时间 | 2026-05-25 至 2026-05-27 |" in yili_response.answer
assert "| 地点 | 新疆,伊犁 |" in yili_response.answer
assert "| 事由 | 支撑新疆电力仿生产部署 |" in yili_response.answer
assert "伊犁出差" not in yili_response.answer
@@ -289,7 +289,7 @@ def test_user_agent_application_uses_selected_time_and_natural_language_fields()
)
)
assert "| 发生时间 | 2026-05-25 |" in response.answer
assert "| 行程时间 | 2026-05-25 |" in response.answer
assert "| 地点 | 上海市 |" in response.answer
assert "| 事由 | 支撑国网服务器上线部署 |" in response.answer
assert "当前还需要补充:出行方式" in response.answer
@@ -325,6 +325,106 @@ def test_user_agent_application_builds_system_estimate_after_transport_choice()
assert response.suggested_actions == []
def test_user_agent_application_uses_selected_date_range_and_keeps_reason() -> None:
session_factory = build_session_factory()
message = "去上海出差4天支撑国网仿生产环境部署飞机"
context_json = {
"session_type": "application",
"entry_source": "application",
"business_time_context": {
"mode": "range",
"start_date": "2026-02-20",
"end_date": "2026-02-23",
"display_value": "2026-02-20 至 2026-02-23",
},
"name": "曹笑竹",
"department_name": "技术部",
"position": "财务智能化产品经理",
"manager_name": "向万红",
"grade": "P5",
}
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest",
context_json=context_json,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message=message,
ontology=ontology,
context_json=context_json,
tool_payload={"clarification_required": ontology.clarification_required},
)
)
assert "| 行程时间 | 2026-02-20 至 2026-02-23 |" in response.answer
assert "| 地点 | 上海市 |" in response.answer
assert "| 事由 | 支撑国网仿生产环境部署 |" in response.answer
assert "| 天数 | 4天 |" in response.answer
assert "| 发生时间 |" not in response.answer
assert "| 事由 | 2026-02-20 至 2026-02-23 |" not in response.answer
def test_user_agent_application_derives_days_from_selected_date_range() -> None:
session_factory = build_session_factory()
message = "去上海出差,支撑国网仿生产服务器部署,火车"
context_json = {
"session_type": "application",
"entry_source": "application",
"business_time_context": {
"mode": "range",
"start_date": "2026-02-20",
"end_date": "2026-02-23",
"display_value": "2026-02-20 至 2026-02-23",
},
"application_preview": {
"fields": {
"applicationType": "差旅费用申请",
"time": "2026-02-20 至 2026-02-23",
"location": "上海市",
"reason": "支撑国网仿生产服务器部署",
"days": "待补充",
"transportMode": "火车",
"grade": "P5",
}
},
"name": "曹笑竹",
"department_name": "技术部",
"position": "财务智能化产品经理",
"manager_name": "向万红",
"grade": "P5",
}
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest",
context_json=context_json,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message=message,
ontology=ontology,
context_json=context_json,
tool_payload={"clarification_required": ontology.clarification_required},
)
)
assert "| 行程时间 | 2026-02-20 至 2026-02-23 |" in response.answer
assert "| 天数 | 4天 |" in response.answer
assert "| 天数 | 待补充 |" not in response.answer
assert "4天" in response.answer
assert "1天" not in response.answer
def test_user_agent_application_missing_base_actions_prefill_composer() -> None:
session_factory = build_session_factory()
with session_factory() as db:
@@ -352,7 +452,7 @@ def test_user_agent_application_precomputes_time_from_today_and_days() -> None:
)
assert "这是费用申请核对结果" in response.answer
assert "| 发生时间 | 2026-05-29 至 2026-05-31 |" in response.answer
assert "| 行程时间 | 2026-05-29 至 2026-05-31 |" in response.answer
assert response.requires_confirmation is True
@@ -395,6 +495,45 @@ def test_user_agent_application_builds_preview_when_amount_is_ready() -> None:
assert response.suggested_actions == []
def test_user_agent_application_preview_uses_employee_grade_profile() -> None:
session_factory = build_session_factory()
initial_message = (
"发生时间2026-05-25\n"
"地点:上海\n"
"事由:支持上海国网服务器部署\n"
"天数3天"
)
with session_factory() as db:
employee = Employee(
employee_no="APP-GRADE-001",
name="李文静",
email="pytest-application-grade@example.com",
position="解决方案顾问",
grade="P5",
)
db.add(employee)
db.commit()
response = build_application_user_agent_response(
db,
"预计总费用12000元",
context_overrides={
"name": "李文静",
"manager_name": "王强",
},
history=[
{"role": "user", "content": initial_message},
{"role": "user", "content": "飞机"},
],
)
assert "这是费用申请核对结果" in response.answer
assert "| 姓名 | 李文静 |" in response.answer
assert "| 岗位 | 解决方案顾问 |" in response.answer
assert "| 职级 | P5 |" in response.answer
assert "| 职级 | 待补充 |" not in response.answer
def test_user_agent_application_submit_enters_leader_review() -> None:
session_factory = build_session_factory()
initial_message = (
@@ -408,7 +547,7 @@ def test_user_agent_application_submit_enters_leader_review() -> None:
"| 字段 | 内容 |\n"
"| --- | --- |\n"
"| 申请类型 | 差旅费用申请 |\n"
"| 发生时间 | 2026-05-25 |\n"
"| 行程时间 | 2026-05-25 |\n"
"| 地点 | 上海市 |\n"
"| 事由 | 支持上海国网服务器部署 |\n"
"| 天数 | 3天 |\n"
@@ -443,6 +582,58 @@ def test_user_agent_application_submit_enters_leader_review() -> None:
assert claim.employee_name == "pytest"
def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
session_factory = build_session_factory()
initial_message = (
"行程时间2026-05-25 至 2026-05-27\n"
"地点:上海\n"
"事由:支持上海国网服务器部署\n"
"天数3天\n"
"出行方式:飞机\n"
"预计总费用12000元"
)
preview_answer = (
"这是费用申请核对结果,请核对:\n"
"| 字段 | 内容 |\n"
"| --- | --- |\n"
"| 申请类型 | 差旅费用申请 |\n"
"| 行程时间 | 2026-05-25 至 2026-05-27 |\n"
"| 地点 | 上海市 |\n"
"| 事由 | 支持上海国网服务器部署 |\n"
"| 天数 | 3天 |\n"
"| 出行方式 | 飞机 |\n"
"| 系统预估费用 | 12000元 |\n\n"
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。"
)
history = [
{"role": "user", "content": initial_message},
{"role": "assistant", "content": preview_answer},
]
with session_factory() as db:
first_response = build_application_user_agent_response(
db,
"确认提交",
context_overrides={"manager_name": "陈硕"},
history=history,
)
first_claim = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).one()
second_response = build_application_user_agent_response(
db,
"确认提交",
context_overrides={"manager_name": "陈硕"},
history=history,
)
claims = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).all()
assert len(claims) == 1
assert "申请单据已生成" in first_response.answer
assert "已存在申请单" in second_response.answer
assert "系统没有重复创建" in second_response.answer
assert first_claim.claim_no in second_response.answer
assert second_response.draft_payload is None
def test_user_agent_knowledge_fallback_is_honest_and_personalized() -> None:
session_factory = build_session_factory()
with session_factory() as db:
@@ -1173,6 +1364,57 @@ def test_user_agent_continues_identification_after_expense_type_selection() -> N
assert "| 参考合计 |" in response.answer
def test_user_agent_uses_linked_application_context_for_review_slots() -> None:
session_factory = build_session_factory()
with session_factory() as db:
message = "请生成差旅费报销草稿"
context_json = {
"grade": "P4",
"expense_scene_selection": {
"expense_type": "travel",
"expense_type_label": "差旅费",
"original_message": message,
"application_claim_id": "application-linked-1",
"application_claim_no": "AP-202606-001",
},
"review_form_values": {
"expense_type": "差旅费",
"application_claim_id": "application-linked-1",
"application_claim_no": "AP-202606-001",
"application_reason": "支撑国网仿生产环境部署",
"application_location": "北京",
"application_amount": "3000元",
"application_business_time": "2026-06-01 至 2026-06-03",
},
"user_input_text": message,
}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest-linked-application-review@example.com",
context_json=context_json,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-linked-application-review@example.com",
message=message,
ontology=ontology,
context_json=context_json,
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["reason"].value == "支撑国网仿生产环境部署"
assert slot_map["location"].value == "北京"
assert slot_map["amount"].value == "3000.00元"
assert slot_map["time_range"].value == "2026-06-01 至 2026-06-03"
assert "事由说明" not in response.review_payload.missing_slots
def test_user_agent_guides_implicit_expense_draft_request() -> None:
session_factory = build_session_factory()
with session_factory() as db:
@@ -1422,6 +1664,12 @@ def test_user_agent_keeps_travel_range_when_user_adds_receipts_after_text_contex
assert followup_slots["time_range"].value == "2026-02-20 至 2026-02-23"
assert followup_slots["location"].value == "上海"
assert followup_slots["reason"].value == "去上海支撑上海电力服务器部署出差3天"
followup_risk_text = "\n".join(
f"{item.title}\n{item.content}\n{item.detail}"
for item in followup_response.review_payload.risk_briefs
)
assert "票据城市与申报目的地不一致" not in followup_risk_text
assert "差旅目的地与票据城市不一致" not in followup_risk_text
def test_user_agent_does_not_treat_draft_saved_message_as_precheck_risk_for_transport() -> None:
@@ -1697,7 +1945,9 @@ def test_user_agent_save_draft_answer_guides_followup_to_existing_draft() -> Non
assert response.draft_payload is not None
assert response.draft_payload.claim_no == "BX202605220001"
assert "已按您当前确认的信息保存为草稿 BX202605220001" in response.answer
assert "请关联这张草稿" in response.answer
assert "系统已完成草稿规则校验" in response.answer
assert "继续在当前对话上传" in response.answer
assert "请关联这张草稿" not in response.answer
assert "继续保存草稿" not in response.answer
@@ -2264,7 +2514,7 @@ def test_user_agent_review_payload_does_not_fill_hotel_name_from_train_ticket()
]
assert "继续下一步" not in [item.label for item in response.review_payload.confirmation_actions]
assert "酒店住宿发票/住宿清单(必须,当前待上传)" in response.answer
assert "市内交通/乘车票据(非必须" in response.answer
assert "市内交通/乘车票据(非必须" not in response.answer
assert "只能保存为草稿" in response.answer or "保存为草稿" in response.answer
assert "已识别信息:" in response.answer
assert "酒店住宿发票/住宿清单" in response.answer
@@ -2280,7 +2530,7 @@ def test_user_agent_review_payload_does_not_fill_hotel_name_from_train_ticket()
assert "列车出发时间" in field_labels
def test_user_agent_review_payload_allows_next_step_when_only_optional_ride_receipt_is_missing() -> None:
def test_user_agent_review_payload_does_not_prompt_when_only_optional_ride_receipt_is_missing() -> None:
session_factory = build_session_factory()
with session_factory() as db:
query = "我去北京出差,上传了火车票和酒店票,帮我生成差旅费报销草稿"
@@ -2341,14 +2591,11 @@ def test_user_agent_review_payload_allows_next_step_when_only_optional_ride_rece
assert response.review_payload is not None
assert response.review_payload.can_proceed is True
assert response.review_payload.missing_slots == []
receipt_brief = next(item for item in response.review_payload.risk_briefs if item.title == "差旅票据待补充")
assert receipt_brief.level == "info"
assert "市内交通/乘车票据可继续上传(非必须)" in receipt_brief.content
assert "酒店的报销票据待上传(必须)" not in receipt_brief.content
assert not any(item.title == "差旅票据待补充" for item in response.review_payload.risk_briefs)
action_types = [item.action_type for item in response.review_payload.confirmation_actions]
assert "save_draft" in action_types
assert "next_step" in action_types
assert "市内交通/乘车票据(非必须" in response.answer
assert "市内交通/乘车票据(非必须" not in response.answer
assert "继续下一步" in response.answer