feat: 报销预审会话状态管理与工作台交互增强

- 新增差旅报销会话状态管理与对话模型重构
- 增强风险观测服务与运行时聊天上下文作用域
- 优化工作台图标资源、助理意图识别与摘要工具
- 完善报销创建视图样式与差旅详情页标准调整交互
- 补充风险观测、运行时聊天与报销端点测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-04 11:03:29 +08:00
parent 87da5df91b
commit 1cbf3fee44
60 changed files with 4156 additions and 393 deletions

View File

@@ -1918,6 +1918,77 @@ def test_update_claim_item_reanalyzes_existing_attachment(monkeypatch, tmp_path)
assert refreshed_meta["requirement_check"]["matches"] is False
assert any("附件类型要求" in point for point in refreshed_meta["analysis"]["points"])
def test_upload_attachment_refreshes_claim_pre_review(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-1",
name="submitter",
role_codes=[],
is_admin=False,
)
review_calls: list[str] = []
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="receipt.png",
media_type="image/png",
text="office receipt amount 88 2026-05-13",
summary="recognized office receipt",
avg_score=0.98,
line_count=1,
page_count=1,
warnings=[],
)
],
)
def fake_review(self, reviewed_claim):
review_calls.append(reviewed_claim.id)
return {
"risk_flags": [
*list(reviewed_claim.risk_flags_json or []),
{
"source": "submission_review",
"severity": "high",
"label": "upload-time-risk",
"message": "risk generated after attachment upload",
},
]
}
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
monkeypatch.setattr(ExpenseClaimService, "_run_ai_submission_review", fake_review)
with build_session() as db:
claim = build_claim(expense_type="office", location="Shanghai")
claim.invoice_count = 0
claim.items[0].invoice_id = None
db.add(claim)
db.commit()
payload = ExpenseClaimService(db).upload_claim_item_attachment(
claim_id=claim.id,
item_id=claim.items[0].id,
filename="receipt.png",
content=b"fake-image-bytes",
media_type="image/png",
current_user=current_user,
)
flags = payload["claim_risk_flags"]
assert review_calls == [claim.id]
assert any(flag.get("label") == "upload-time-risk" for flag in flags)
pre_review = next(flag for flag in flags if flag.get("source") == "ai_pre_review")
assert pre_review["status"] == "failed"
assert pre_review["blocking_risk_count"] >= 1
def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
@@ -2619,6 +2690,60 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
assert submitted.approval_stage == "直属领导审批"
assert submitted.submitted_at is not None
def test_submit_claim_reuses_upload_pre_review_without_rerunning_review(monkeypatch) -> None:
current_user = CurrentUserContext(
username="emp-submit@example.com",
name="submitter",
role_codes=[],
is_admin=False,
)
def fail_review(self, reviewed_claim):
raise AssertionError("submit should reuse upload-time pre-review")
monkeypatch.setattr(ExpenseClaimService, "_run_ai_submission_review", fail_review)
with build_session() as db:
manager = Employee(
employee_no="E7010",
name="Manager",
email="manager-reuse@example.com",
)
employee = Employee(
employee_no="E7011",
name="submitter",
email="emp-submit@example.com",
manager=manager,
)
claim = build_claim(expense_type="transport", location="Shanghai")
claim.employee = employee
claim.employee_id = employee.id
claim.items[0].invoice_id = "taxi-ticket.png"
claim.risk_flags_json = [
{
"source": "submission_review",
"severity": "medium",
"label": "upload-time-warning",
"message": "generated before submit",
},
{
"source": "ai_pre_review",
"status": "passed",
"passed": True,
"severity": "info",
"blocking_risk_count": 0,
},
]
db.add_all([manager, employee, claim])
db.commit()
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert any(flag.get("label") == "upload-time-warning" for flag in submitted.risk_flags_json)
assert any(flag.get("source") == "ai_pre_review" for flag in submitted.risk_flags_json)
def test_accept_standard_adjustment_recalculates_claim_amount_and_preserves_on_submit() -> None:
current_user = CurrentUserContext(
@@ -2669,28 +2794,92 @@ def test_accept_standard_adjustment_recalculates_claim_amount_and_preserves_on_s
)
assert adjusted is not None
assert adjusted.amount == Decimal("600.00")
assert adjusted.amount == Decimal("450.00")
standard_flag = next(
flag
for flag in adjusted.risk_flags_json
if isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
)
assert standard_flag["original_amount"] == "880.00"
assert standard_flag["reimbursable_amount"] == "600.00"
assert standard_flag["employee_absorbed_amount"] == "280.00"
assert standard_flag["reimbursable_amount"] == "450.00"
assert standard_flag["employee_absorbed_amount"] == "430.00"
assert standard_flag["visibility_scope"] == "leader"
submitted = service.submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.amount == Decimal("600.00")
assert submitted.amount == Decimal("450.00")
assert any(
isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
for flag in submitted.risk_flags_json
)
def test_accept_standard_adjustment_uses_policy_amount_when_payload_has_no_downgrade() -> None:
current_user = CurrentUserContext(
username="emp-policy-standard@example.com",
name="张三",
role_codes=[],
is_admin=False,
grade="P4",
)
with build_session() as db:
manager = Employee(
employee_no="E7032",
name="李经理",
email="manager-policy-standard@example.com",
)
employee = Employee(
employee_no="E7033",
name="张三",
email="emp-policy-standard@example.com",
grade="P4",
manager=manager,
)
claim = build_claim(expense_type="hotel", location="北京")
claim.employee = employee
claim.employee_id = employee.id
claim.amount = Decimal("1000.00")
claim.items[0].item_type = "hotel_ticket"
claim.items[0].item_reason = "北京住宿"
claim.items[0].item_location = "北京"
claim.items[0].item_amount = Decimal("1000.00")
db.add_all([manager, employee, claim])
db.commit()
adjusted = ExpenseClaimService(db).accept_standard_adjustment(
claim_id=claim.id,
payload=ExpenseClaimStandardAdjustmentPayload(
risks=[
{
"risk_id": "risk-hotel-policy-1",
"item_id": claim.items[0].id,
"title": "住宿超标待说明",
"risk": "住宿票据金额超过职级标准。",
"application_days": 2,
"original_amount": Decimal("1000.00"),
"reimbursable_amount": Decimal("1000.00"),
}
]
),
current_user=current_user,
)
assert adjusted is not None
assert adjusted.amount == Decimal("900.00")
standard_flag = next(
flag
for flag in adjusted.risk_flags_json
if isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
)
assert standard_flag["original_amount"] == "1000.00"
assert standard_flag["reimbursable_amount"] == "900.00"
assert standard_flag["employee_absorbed_amount"] == "100.00"
assert standard_flag["visibility_scope"] == "leader"
def test_pre_review_claim_records_ai_result_without_submitting() -> None:
current_user = CurrentUserContext(
username="emp-pre-review@example.com",

View File

@@ -113,6 +113,53 @@ def seed_claim(db: Session) -> tuple[ExpenseClaim, ExpenseClaimItem]:
return claim, item
def test_claim_standard_adjustment_endpoint_recalculates_and_marks_reviewer_notice() -> None:
client, session_factory = build_client()
with session_factory() as db:
claim, item = seed_claim(db)
claim.expense_type = "hotel"
claim.location = "北京"
claim.amount = Decimal("1000.00")
item.item_type = "hotel_ticket"
item.item_reason = "北京住宿"
item.item_location = "北京"
item.item_amount = Decimal("1000.00")
db.commit()
claim_id = claim.id
item_id = item.id
response = client.post(
f"/api/v1/reimbursements/claims/{claim_id}/standard-adjustment",
json={
"risks": [
{
"risk_id": "risk-hotel-endpoint-1",
"item_id": item_id,
"title": "住宿超标待说明",
"risk": "住宿票据金额超过职级标准。",
"application_days": 2,
"original_amount": "1000.00",
"reimbursable_amount": "1000.00",
}
]
},
headers={"x-auth-username": "emp-1", "x-auth-name": "Zhang San", "x-auth-grade": "P4"},
)
assert response.status_code == 200
payload = response.json()
assert payload["amount"] == "900.00"
standard_flag = next(
flag
for flag in payload["risk_flags_json"]
if isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
)
assert standard_flag["original_amount"] == "1000.00"
assert standard_flag["reimbursable_amount"] == "900.00"
assert standard_flag["employee_absorbed_amount"] == "100.00"
assert standard_flag["visibility_scope"] == "leader"
def test_claim_item_attachment_upload_preview_and_delete(monkeypatch, tmp_path) -> None:
def fake_recognize(
self,

View File

@@ -4,6 +4,7 @@ from collections.abc import Generator
from datetime import UTC, datetime
from decimal import Decimal
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
@@ -124,6 +125,27 @@ def test_platform_rule_flags_are_persisted_as_risk_observations() -> None:
assert persisted.contribution_scores_json == {"S_rule": 100}
def test_risk_observation_storage_ready_is_cached_per_bind(monkeypatch: pytest.MonkeyPatch) -> None:
with _build_session() as db:
RiskObservationService._storage_ready_cache.clear()
create_all_calls = []
original_create_all = Base.metadata.create_all
def spy_create_all(*args, **kwargs):
create_all_calls.append(kwargs.get("bind"))
return original_create_all(*args, **kwargs)
monkeypatch.setattr(Base.metadata, "create_all", spy_create_all)
service = RiskObservationService(db)
service.ensure_storage_ready()
service.ensure_storage_ready()
RiskObservationService(db).ensure_storage_ready()
assert len(create_all_calls) == 1
RiskObservationService._storage_ready_cache.clear()
def test_risk_observation_endpoints_return_list_detail_dashboard_and_feedback() -> None:
client, session_factory = _build_client()
with session_factory() as db:

View File

@@ -150,6 +150,62 @@ def test_runtime_chat_disables_glm_thinking_for_direct_user_answers(monkeypatch)
assert captured["timeout_seconds"] == 17
def test_runtime_chat_openai_compatible_tool_call_payload(monkeypatch) -> None:
_clear_runtime_chat_cooldown()
session_factory = build_session_factory()
with session_factory() as db:
service = RuntimeChatService(db)
captured: dict[str, object] = {}
def fake_send_json_request(method, url, *, headers, payload, timeout_seconds):
captured["method"] = method
captured["url"] = url
captured["headers"] = headers
captured["payload"] = payload
captured["timeout_seconds"] = timeout_seconds
return 200, {
"choices": [
{
"message": {
"tool_calls": [
{
"id": "call_001",
"type": "function",
"function": {
"name": "submit_steward_intent_plan",
"arguments": "{\"tasks\": []}",
},
}
]
}
}
]
}
monkeypatch.setattr("app.services.runtime_chat._send_json_request", fake_send_json_request)
tool_call = service._request_openai_compatible_tool_call(
provider="OpenAI Compatible",
endpoint="https://api.example.com/v1",
model="gpt-test",
api_key="secret",
messages=[{"role": "user", "content": "hello"}],
tools=[{"type": "function", "function": {"name": "submit_steward_intent_plan"}}],
tool_choice={"type": "function", "function": {"name": "submit_steward_intent_plan"}},
max_tokens=128,
temperature=0.1,
timeout_seconds=19,
)
assert tool_call is not None
assert tool_call.name == "submit_steward_intent_plan"
assert tool_call.arguments == {"tasks": []}
assert captured["url"] == "https://api.example.com/v1/chat/completions"
assert captured["payload"]["tools"][0]["function"]["name"] == "submit_steward_intent_plan"
assert captured["payload"]["tool_choice"]["function"]["name"] == "submit_steward_intent_plan"
assert captured["headers"]["Authorization"] == "Bearer secret"
def test_runtime_chat_supports_single_pass_fast_failover(monkeypatch) -> None:
_clear_runtime_chat_cooldown()
session_factory = build_session_factory()

View File

@@ -0,0 +1,214 @@
from __future__ import annotations
import json
from fastapi.testclient import TestClient
from app.main import create_app
from app.schemas.steward import StewardAttachmentInput, StewardPlanRequest
from app.services.steward_intent_agent import StewardIntentAgentResult
from app.services.steward_planner import StewardPlannerService
class FakeFunctionCallingIntentAgent:
def detect(self, request, *, base_date, canonical_fields):
assert "expense_type" in canonical_fields
assert base_date.isoformat() == "2026-06-04"
return StewardIntentAgentResult(
payload={
"thinking_events": [
{
"stage": "task_split",
"title": "识别复合报销意图",
"content": "模型工具调用识别出 1 个报销任务,并关联本次上传的交通附件。",
}
],
"tasks": [
{
"task_type": "reimbursement",
"title": "费用报销 2026-06-03 交通",
"summary": "报销昨天客户现场沟通产生的交通费。",
"confidence": 0.91,
"ontology_fields": {
"occurred_date": "昨天",
"transport_type": "出租车",
"reason_value": "客户现场沟通",
"expense_type": "交通费",
"unregistered_field": "不能进入业务字段",
},
"missing_fields": ["amount", "transport_type"],
}
],
"attachment_groups": [
{
"target_task_index": 1,
"scene": "transport",
"scene_label": "交通费用",
"attachment_names": ["出租车票.png"],
"excluded_attachment_names": ["客户招待发票.jpg"],
"confidence": 0.86,
"rationale": "出租车票与交通报销任务匹配,招待发票不归入该任务。",
}
],
},
model_call_traces=[
{
"slot": "main",
"provider": "OpenAI Compatible",
"model": "gpt-test",
"attempt": 1,
"status": "succeeded",
}
],
)
class EmptyFunctionCallingIntentAgent:
def detect(self, request, *, base_date, canonical_fields):
return None
def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None:
payload = StewardPlanRequest(
message="我要报销昨天客户现场沟通的交通费",
client_now_iso="2026-06-04T09:30:00+08:00",
attachments=[
StewardAttachmentInput(name="出租车票.png"),
StewardAttachmentInput(name="客户招待发票.jpg"),
],
)
result = StewardPlannerService(intent_agent=FakeFunctionCallingIntentAgent()).build_plan(payload)
assert result.planning_source == "llm_function_call"
assert result.model_call_traces[0]["status"] == "succeeded"
assert len(result.tasks) == 1
fields = result.tasks[0].ontology_fields
assert fields["time_range"] == "2026-06-03"
assert fields["transport_mode"] == "taxi"
assert fields["reason"] == "客户现场沟通"
assert fields["expense_type"] == "transport"
assert "occurred_date" not in fields
assert "transport_type" not in fields
assert "reason_value" not in fields
assert "unregistered_field" not in fields
assert result.tasks[0].missing_fields == ["amount"]
assert result.attachment_groups[0].attachment_names == ["出租车票.png"]
assert result.attachment_groups[0].excluded_attachment_names == ["客户招待发票.jpg"]
assert result.thinking_events[0].stage == "llm_function_call"
def test_steward_planner_falls_back_to_rules_when_function_calling_is_unavailable() -> None:
payload = StewardPlanRequest(
message="我要报销昨天的交通费",
client_now_iso="2026-06-04T09:30:00+08:00",
)
result = StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent()).build_plan(payload)
assert result.planning_source == "rule_fallback"
assert result.tasks[0].ontology_fields["time_range"] == "2026-06-03"
assert result.thinking_events[0].stage == "rule_fallback"
def test_steward_planner_splits_application_and_reimbursement_tasks() -> None:
payload = StewardPlanRequest(
message=(
"我想要申请7月2日去北京出差辅助北京供电局的税务审核任务"
"并且我要报销昨天的交通费还需要报销6月3日出差去上海的费用"
),
user_id="u001",
client_now_iso="2026-06-04T09:30:00+08:00",
)
result = StewardPlannerService().build_plan(payload)
assert len(result.tasks) == 3
assert [task.task_type for task in result.tasks] == [
"expense_application",
"reimbursement",
"reimbursement",
]
assert result.tasks[0].assigned_agent == "application_assistant"
assert result.tasks[0].ontology_fields["time_range"] == "2026-07-02"
assert result.tasks[0].ontology_fields["location"] == "北京"
assert result.tasks[0].ontology_fields["reason"] == "辅助北京供电局的税务审核任务"
assert result.tasks[1].ontology_fields["time_range"] == "2026-06-03"
assert result.tasks[1].ontology_fields["expense_type"] == "transport"
assert result.tasks[2].ontology_fields["time_range"] == "2026-06-03"
assert result.tasks[2].ontology_fields["location"] == "上海"
assert result.tasks[2].ontology_fields["expense_type"] == "travel"
assert all(action.status == "pending" for action in result.confirmation_groups)
def test_steward_planner_outputs_only_canonical_ontology_fields() -> None:
payload = StewardPlanRequest(
message="我要报销昨天的交通费",
client_now_iso="2026-06-04T09:30:00+08:00",
context_json={
"review_form_values": {
"occurred_date": "2026-06-03",
"transport_type": "taxi",
"reason_value": "客户现场沟通",
}
},
)
result = StewardPlannerService().build_plan(payload)
fields = result.tasks[0].ontology_fields
assert fields["time_range"] == "2026-06-03"
assert fields["transport_mode"] == "taxi"
assert fields["reason"] == "客户现场沟通"
assert "occurred_date" not in fields
assert "transport_type" not in fields
assert "reason_value" not in fields
def test_steward_planner_builds_travel_attachment_group_with_exclusions() -> None:
payload = StewardPlanRequest(
message="还需要报销6月3日出差去上海的费用",
client_now_iso="2026-06-04T09:30:00+08:00",
attachments=[
StewardAttachmentInput(name="上海高铁票.jpg"),
StewardAttachmentInput(name="上海酒店发票.pdf"),
StewardAttachmentInput(name="出租车票.png"),
StewardAttachmentInput(name="客户招待发票.jpg"),
],
)
result = StewardPlannerService().build_plan(payload)
assert len(result.attachment_groups) == 1
group = result.attachment_groups[0]
assert group.scene == "travel"
assert group.attachment_names == ["上海高铁票.jpg", "上海酒店发票.pdf", "出租车票.png"]
assert group.excluded_attachment_names == ["客户招待发票.jpg"]
assert group.confirmation_required is True
attachment_actions = [
action for action in result.confirmation_groups if action.action_type == "confirm_attachment_group"
]
assert len(attachment_actions) == 1
def test_steward_stream_endpoint_emits_thinking_before_plan() -> None:
client = TestClient(create_app())
with client.stream(
"POST",
"/api/v1/steward/plans/stream",
json={
"message": "我要报销昨天的交通费",
"client_now_iso": "2026-06-04T09:30:00+08:00",
},
) as response:
assert response.status_code == 200
events = [
json.loads(line.decode("utf-8") if isinstance(line, bytes) else line)
for line in response.iter_lines()
if line
]
assert [event["event"] for event in events][:2] == ["thinking", "thinking"]
assert events[-1]["event"] == "plan"
assert events[-1]["data"]["tasks"][0]["ontology_fields"]["time_range"] == "2026-06-03"