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

@@ -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",