feat: 优化差旅报销预审流程与个人工作台 UI 体系
- 完善 user_agent_application 申请差旅报销预审槽位与消息组装 - 增强预算助理报告与风险建议卡片交互 - 重构登录页视觉样式与移动端响应式适配 - 优化个人工作台、文档中心、政策中心、员工管理等页面布局 - 拆分 travelRequestDetailPreReviewModel 为 advice/submit 模型 - 补充报销草稿、风险复核、Item Sync 与模板执行器测试覆盖
This commit is contained in:
@@ -165,6 +165,32 @@ def test_validate_claim_for_submission_still_requires_location_for_travel_claim(
|
||||
assert any("缺少地点" in item for item in issues)
|
||||
|
||||
|
||||
def test_validate_claim_for_submission_does_not_require_optional_ride_receipt() -> None:
|
||||
service = ExpenseClaimService.__new__(ExpenseClaimService)
|
||||
claim = build_claim(expense_type="transport", location="待补充")
|
||||
claim.invoice_count = 0
|
||||
claim.items[0].item_type = "ride_ticket"
|
||||
claim.items[0].invoice_id = ""
|
||||
|
||||
issues = service._validate_claim_for_submission(claim)
|
||||
|
||||
assert "票据附件数量不足" not in issues
|
||||
assert not any("缺少票据标识" in item for item in issues)
|
||||
|
||||
|
||||
def test_validate_claim_for_submission_still_requires_hotel_receipt() -> None:
|
||||
service = ExpenseClaimService.__new__(ExpenseClaimService)
|
||||
claim = build_claim(expense_type="hotel", location="北京")
|
||||
claim.invoice_count = 0
|
||||
claim.items[0].item_type = "hotel_ticket"
|
||||
claim.items[0].invoice_id = ""
|
||||
|
||||
issues = service._validate_claim_for_submission(claim)
|
||||
|
||||
assert "票据附件数量不足" in issues
|
||||
assert any("缺少票据标识" in item for item in issues)
|
||||
|
||||
|
||||
def test_save_or_submit_preview_does_not_create_claim_without_explicit_action() -> None:
|
||||
user_id = "preview-only@example.com"
|
||||
message = "业务发生时间:2026-03-04,打车去客户现场,交通费32元,请帮我看看怎么报"
|
||||
@@ -342,6 +368,80 @@ def test_upsert_draft_from_ontology_persists_linked_application_context() -> Non
|
||||
assert link_flag["application_detail"]["application_reason"] == "支撑国网仿生产环境部署"
|
||||
|
||||
|
||||
def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_item() -> None:
|
||||
user_id = "linked-application-no-receipt@example.com"
|
||||
message = (
|
||||
"报销类型:差旅费\n"
|
||||
"关联申请单:AP-202606-001 / 支撑国网仿生产服务器部署 / 2026-02-20 至 2026-02-23 / 上海 / ¥3,000\n"
|
||||
"报销票据:草稿生成后在详情中上传"
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
employee = Employee(
|
||||
employee_no="E5104",
|
||||
name="关联员工",
|
||||
email=user_id,
|
||||
grade="P5",
|
||||
)
|
||||
db.add(employee)
|
||||
db.commit()
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=message,
|
||||
user_id=user_id,
|
||||
)
|
||||
)
|
||||
|
||||
result = ExpenseClaimService(db).upsert_draft_from_ontology(
|
||||
run_id=ontology.run_id,
|
||||
user_id=user_id,
|
||||
message=message,
|
||||
ontology=ontology,
|
||||
context_json={
|
||||
"name": "关联员工",
|
||||
"user_input_text": message,
|
||||
"review_action": "save_draft",
|
||||
"review_form_values": {
|
||||
"expense_type": "差旅费",
|
||||
"amount": "¥3,000",
|
||||
"reason": "支撑国网仿生产服务器部署",
|
||||
"location": "上海",
|
||||
"business_location": "上海",
|
||||
"time_range": "2026-02-20 至 2026-02-23",
|
||||
"business_time": "2026-02-20 至 2026-02-23",
|
||||
"application_claim_id": "application-linked-no-receipt",
|
||||
"application_claim_no": "AP-202606-001",
|
||||
"application_reason": "支撑国网仿生产服务器部署",
|
||||
"application_location": "上海",
|
||||
"application_amount": "3000",
|
||||
"application_amount_label": "¥3,000",
|
||||
"application_business_time": "2026-02-20 至 2026-02-23",
|
||||
},
|
||||
"expense_scene_selection": {
|
||||
"expense_type": "travel",
|
||||
"application_claim_id": "application-linked-no-receipt",
|
||||
"application_claim_no": "AP-202606-001",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
claim = db.get(ExpenseClaim, result["claim_id"])
|
||||
assert claim is not None
|
||||
assert claim.expense_type == "travel"
|
||||
assert claim.reason == "支撑国网仿生产服务器部署"
|
||||
assert claim.location == "上海"
|
||||
assert claim.amount == Decimal("0.00")
|
||||
assert claim.invoice_count == 0
|
||||
assert claim.items == []
|
||||
link_flag = next(
|
||||
flag
|
||||
for flag in claim.risk_flags_json
|
||||
if isinstance(flag, dict) and flag.get("source") == "application_link"
|
||||
)
|
||||
assert link_flag["application_claim_no"] == "AP-202606-001"
|
||||
assert link_flag["application_detail"]["application_amount"] == "3000"
|
||||
|
||||
|
||||
def test_unsaved_conversation_expires_after_retention_but_saved_conversation_stays() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentConversationService(db)
|
||||
@@ -2165,7 +2265,7 @@ def test_pre_review_claim_records_ai_result_without_submitting() -> None:
|
||||
|
||||
assert reviewed is not None
|
||||
assert reviewed.status == "draft"
|
||||
assert reviewed.approval_stage == "AI预审"
|
||||
assert reviewed.approval_stage == "待提交"
|
||||
assert reviewed.submitted_at is None
|
||||
pre_review_flag = next(
|
||||
flag
|
||||
@@ -3098,6 +3198,93 @@ def test_executive_can_delete_submitted_claim() -> None:
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
|
||||
|
||||
def test_direct_manager_cannot_delete_application_claim() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="manager-delete-application@example.com",
|
||||
name="李经理",
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="E-APP-DEL-MANAGER",
|
||||
name="李经理",
|
||||
email="manager-delete-application@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E-APP-DEL-EMP",
|
||||
name="张三",
|
||||
email="zhangsan-application-delete@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
db.add_all([manager, employee])
|
||||
db.flush()
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-DEL-MANAGER-101",
|
||||
employee_id=employee.id,
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code=None,
|
||||
expense_type="travel_application",
|
||||
reason="差旅申请",
|
||||
location="上海",
|
||||
amount=Decimal("1200.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
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
|
||||
|
||||
with pytest.raises(ValueError, match="申请单只有系统管理员可以删除"):
|
||||
ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
||||
|
||||
assert db.get(ExpenseClaim, claim_id) is not None
|
||||
|
||||
|
||||
def test_admin_can_delete_application_claim() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="superadmin",
|
||||
name="系统管理员",
|
||||
role_codes=["manager"],
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-DEL-ADMIN-101",
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code=None,
|
||||
expense_type="travel_application",
|
||||
reason="差旅申请",
|
||||
location="上海",
|
||||
amount=Decimal("1200.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
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 == "APP-DEL-ADMIN-101"
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
|
||||
|
||||
def test_executive_cannot_delete_archived_claim() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="executive-archive-delete@example.com",
|
||||
|
||||
@@ -268,7 +268,7 @@ def test_review_next_step_blocked_returns_reasons_and_removes_next_step_action(
|
||||
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 "所属部门未完善" in result["answer"]
|
||||
assert "next_step" not in actions
|
||||
assert "save_draft" in actions
|
||||
@@ -710,7 +710,7 @@ def test_orchestrator_application_session_does_not_use_reimbursement_scene_promp
|
||||
assert response.status == "blocked"
|
||||
assert response.trace_summary.scenario == "expense"
|
||||
assert "费用申请" in result["answer"]
|
||||
assert "| 发生时间 | 2026-05-25" in result["answer"]
|
||||
assert "| 行程时间 | 2026-05-25" in result["answer"]
|
||||
assert "请先在下面选择报销场景" not in result["answer"]
|
||||
assert result.get("review_payload") is None
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.main import create_app
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
||||
from app.models.role import Role
|
||||
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
@@ -594,6 +595,31 @@ def test_claim_delete_allows_draft_owner_by_employee_id_without_employee_no_head
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
claim, _ = seed_claim(db)
|
||||
observation = RiskObservation(
|
||||
id="risk-observation-delete-1",
|
||||
observation_key="claim-delete-risk-observation-1",
|
||||
subject_type="expense_claim",
|
||||
subject_key=claim.id,
|
||||
subject_label=claim.claim_no,
|
||||
claim_id=claim.id,
|
||||
claim_no=claim.claim_no,
|
||||
risk_type="policy",
|
||||
risk_signal="draft_pre_review",
|
||||
title="草稿预审风险",
|
||||
description="删除草稿时应同步清理关联风险观察。",
|
||||
risk_score=70,
|
||||
risk_level="medium",
|
||||
confidence_score=0.8,
|
||||
)
|
||||
feedback = RiskObservationFeedback(
|
||||
id="risk-observation-feedback-delete-1",
|
||||
observation=observation,
|
||||
feedback_type="confirm",
|
||||
actor="auditor",
|
||||
)
|
||||
db.add(observation)
|
||||
db.add(feedback)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
response = client.delete(
|
||||
@@ -608,3 +634,5 @@ def test_claim_delete_allows_draft_owner_by_employee_id_without_employee_no_head
|
||||
|
||||
with session_factory() as db:
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
assert db.get(RiskObservation, "risk-observation-delete-1") is None
|
||||
assert db.get(RiskObservationFeedback, "risk-observation-feedback-delete-1") is None
|
||||
|
||||
@@ -666,6 +666,82 @@ def test_current_keyword_city_consistency_rule_hits_ticket_city_mismatch() -> No
|
||||
assert result["evidence"]["city_consistency"]["reference_values"] == ["北京"]
|
||||
|
||||
|
||||
def test_travel_route_city_consistency_allows_normal_round_trip_to_declared_destination() -> None:
|
||||
manifest = {
|
||||
"template_key": "field_compare_v1",
|
||||
"params": {
|
||||
"template_key": "field_compare_v1",
|
||||
"semantic_type": "travel_route_city_consistency",
|
||||
"field_keys": [
|
||||
"attachment.route_cities",
|
||||
"claim.location",
|
||||
"item.item_location",
|
||||
"employee.location",
|
||||
"claim.reason",
|
||||
],
|
||||
"attachment_city_fields": ["attachment.route_cities"],
|
||||
"reference_city_fields": ["claim.location", "item.item_location"],
|
||||
"home_city_fields": ["employee.location"],
|
||||
"exception_fields": ["claim.reason"],
|
||||
"exception_keywords": ["绕行", "跨城办事", "临时改签"],
|
||||
},
|
||||
"outcomes": {"fail": {"severity": "high"}},
|
||||
}
|
||||
claim = ExpenseClaim(
|
||||
claim_no="TEST-ROUND-TRIP",
|
||||
employee_name="测试员工",
|
||||
department_name="测试部门",
|
||||
expense_type="差旅费",
|
||||
reason="去上海支撑项目部署",
|
||||
location="上海",
|
||||
amount=Decimal("708.00"),
|
||||
currency="CNY",
|
||||
invoice_count=2,
|
||||
occurred_at=datetime.now(UTC),
|
||||
status="draft",
|
||||
)
|
||||
claim.employee = Employee(
|
||||
employee_no="TEST-ROUND-TRIP-EMP",
|
||||
name="测试员工",
|
||||
email="round-trip@example.com",
|
||||
location="武汉",
|
||||
)
|
||||
claim.items = [
|
||||
ExpenseClaimItem(
|
||||
item_date=date.today(),
|
||||
item_type="交通费",
|
||||
item_reason="去上海支撑项目部署",
|
||||
item_location="上海",
|
||||
item_amount=Decimal("354.00"),
|
||||
)
|
||||
]
|
||||
|
||||
result = RiskRuleTemplateExecutor().evaluate(
|
||||
manifest,
|
||||
claim=claim,
|
||||
contexts=[
|
||||
{
|
||||
"document_info": {
|
||||
"fields": [
|
||||
{"key": "route", "label": "行程", "value": "武汉-上海"},
|
||||
],
|
||||
},
|
||||
"ocr_text": "铁路电子客票 2026-02-20 武汉-上海 二等座",
|
||||
},
|
||||
{
|
||||
"document_info": {
|
||||
"fields": [
|
||||
{"key": "route", "label": "行程", "value": "上海-武汉"},
|
||||
],
|
||||
},
|
||||
"ocr_text": "铁路电子客票 2026-02-23 上海-武汉 二等座",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_generate_complex_travel_route_rule_uses_formula_not_keyword_match(tmp_path) -> None:
|
||||
text = (
|
||||
"差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user