Files
X-Financial/server/tests/test_linked_reimbursement_draft_jobs.py
caoxiaozhu 332f77389d feat(server): 新增附件关联/关联报销草稿后台任务与申请位置语义
- attachment_association_jobs:从票据夹批量关联附件到报销单,识别城市/日期并创建明细项,内存态 job 跟踪
- linked_reimbursement_draft_jobs:基于申请单异步生成关联报销草稿,调用 Orchestrator 编排,区分 succeeded/failed 终态
- application_location_semantics:抽取差旅出发/到达城市、判断具体地址/业务动作等位置语义,供申请单校验复用
- router 注册两个 job 端点,新增对应 job/语义单元测试
2026-06-24 10:42:05 +08:00

297 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from collections.abc import Generator
from datetime import UTC, datetime
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.api.v1.endpoints import linked_reimbursement_draft_jobs as draft_jobs_endpoint
from app.main import create_app
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.schemas.orchestrator import OrchestratorResponse, OrchestratorTraceSummary
from app.services.linked_reimbursement_draft_jobs import clear_linked_reimbursement_draft_jobs_for_tests
from app.services.orchestrator import OrchestratorService
from app.test_helpers.db import build_in_memory_session_factory
def seed_employee_and_application(db: Session) -> None:
employee = Employee(
id="emp-linked-draft-fast",
employee_no="E10001",
name="张三",
email="zhangsan@example.com",
position="实施顾问",
grade="P5",
)
application = ExpenseClaim(
id="application-linked-draft-fast",
claim_no="AP-202606-FAST",
employee_id=employee.id,
employee_name=employee.name,
department_id="dept-delivery",
department_name="交付部",
project_code=None,
expense_type="travel_application",
reason="支撑国网仿生产服务器部署",
location="上海",
amount=3000,
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
submitted_at=None,
status="approved",
approval_stage="已完成",
risk_flags_json=[],
)
db.add_all([employee, application])
db.commit()
def build_client(monkeypatch) -> tuple[TestClient, object]:
session_factory = build_in_memory_session_factory()
app = create_app()
def override_db() -> Generator[Session, None, None]:
db = session_factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
monkeypatch.setattr(draft_jobs_endpoint, "get_session_factory", lambda: session_factory)
return TestClient(app), session_factory
def test_linked_reimbursement_draft_job_runs_after_conversation_leaves(monkeypatch) -> None:
clear_linked_reimbursement_draft_jobs_for_tests()
captured_messages = []
def fake_run(self, payload):
captured_messages.append(payload.message)
return OrchestratorResponse(
run_id="run-linked-draft-job",
conversation_id=None,
selected_agent="user_agent",
route_reason="测试后台生成报销草稿。",
permission_level="draft_write",
status="succeeded",
result={
"message": "报销草稿已生成。",
"draft_payload": {
"claim_id": "draft-linked-1",
"claim_no": "RE-202606-009",
"status": "draft",
"expense_type": "travel",
},
},
requires_confirmation=False,
trace_summary=OrchestratorTraceSummary(
scenario="expense",
intent="draft",
tool_count=1,
failed_tool_count=0,
selected_capability_codes=[],
degraded=False,
),
)
monkeypatch.setattr(OrchestratorService, "run", fake_run)
try:
client, _session_factory = build_client(monkeypatch)
headers = {
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
}
response = client.post(
"/api/v1/reimbursements/linked-reimbursement-draft-jobs",
headers=headers,
json={
"message": "我要报销\n用户选择报销场景:差旅费\n关联申请单AP-202606-001",
"conversation_id": "inline-test",
"context_json": {
"review_action": "save_draft",
"expense_scene_selection": {
"expense_type": "travel",
"expense_type_label": "差旅费",
"application_claim_no": "AP-202606-001",
},
"review_form_values": {
"application_claim_no": "AP-202606-001",
},
},
},
)
assert response.status_code == 202
job_id = response.json()["job_id"]
status_response = client.get(
f"/api/v1/reimbursements/linked-reimbursement-draft-jobs/{job_id}",
headers=headers,
)
assert status_response.status_code == 200
payload = status_response.json()
assert payload["status"] == "succeeded"
assert payload["draft_payload"]["claim_no"] == "RE-202606-009"
assert payload["run_id"] == "run-linked-draft-job"
assert captured_messages == ["我要报销\n用户选择报销场景:差旅费\n关联申请单AP-202606-001"]
finally:
clear_linked_reimbursement_draft_jobs_for_tests()
def test_linked_reimbursement_draft_job_uses_direct_save_path(monkeypatch) -> None:
clear_linked_reimbursement_draft_jobs_for_tests()
def fail_if_orchestrator_runs(self, payload):
raise AssertionError("linked draft job should not run full orchestrator")
monkeypatch.setattr(OrchestratorService, "run", fail_if_orchestrator_runs)
try:
client, session_factory = build_client(monkeypatch)
with session_factory() as db:
seed_employee_and_application(db)
headers = {
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
}
response = client.post(
"/api/v1/reimbursements/linked-reimbursement-draft-jobs",
headers=headers,
json={
"message": "我要报销\n用户选择报销场景:差旅费\n关联申请单AP-202606-FAST",
"conversation_id": "inline-fast-test",
"context_json": {
"name": "张三",
"review_action": "save_draft",
"expense_scene_selection": {
"expense_type": "travel",
"expense_type_label": "差旅费",
"application_claim_id": "application-linked-draft-fast",
"application_claim_no": "AP-202606-FAST",
},
"review_form_values": {
"expense_type": "差旅费",
"reason": "支撑国网仿生产服务器部署",
"location": "上海",
"time_range": "2026-02-20 至 2026-02-23",
"application_claim_id": "application-linked-draft-fast",
"application_claim_no": "AP-202606-FAST",
"application_reason": "支撑国网仿生产服务器部署",
"application_location": "上海",
"application_amount": "3000",
"application_amount_label": "¥3,000",
"application_business_time": "2026-02-20 至 2026-02-23",
},
},
},
)
assert response.status_code == 202
job_id = response.json()["job_id"]
status_response = client.get(
f"/api/v1/reimbursements/linked-reimbursement-draft-jobs/{job_id}",
headers=headers,
)
assert status_response.status_code == 200
payload = status_response.json()
assert payload["status"] == "succeeded"
assert payload["draft_payload"]["claim_no"]
assert payload["draft_payload"]["claim_id"]
assert payload["run_id"].startswith("linked-reimbursement-draft-")
with session_factory() as db:
draft = db.get(ExpenseClaim, payload["draft_payload"]["claim_id"])
assert draft is not None
assert draft.status == "draft"
assert draft.expense_type == "travel"
assert draft.reason == "支撑国网仿生产服务器部署"
assert draft.items == []
finally:
clear_linked_reimbursement_draft_jobs_for_tests()
def test_linked_reimbursement_draft_job_uses_direct_save_path_with_application_no_only(monkeypatch) -> None:
clear_linked_reimbursement_draft_jobs_for_tests()
def fail_if_orchestrator_runs(self, payload):
raise AssertionError("linked draft job should resolve application no without full orchestrator")
monkeypatch.setattr(OrchestratorService, "run", fail_if_orchestrator_runs)
try:
client, session_factory = build_client(monkeypatch)
with session_factory() as db:
seed_employee_and_application(db)
headers = {
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
}
response = client.post(
"/api/v1/reimbursements/linked-reimbursement-draft-jobs",
headers=headers,
json={
"message": "我要报销\n用户选择报销场景:差旅费\n关联申请单AP-202606-FAST",
"conversation_id": "inline-fast-no-id-test",
"context_json": {
"name": "张三",
"review_action": "save_draft",
"expense_scene_selection": {
"expense_type": "travel",
"expense_type_label": "差旅费",
"application_claim_no": "AP-202606-FAST",
},
"review_form_values": {
"expense_type": "差旅费",
"reason": "支撑国网仿生产服务器部署",
"location": "上海",
"time_range": "2026-02-20 至 2026-02-23",
"application_claim_no": "AP-202606-FAST",
"application_reason": "支撑国网仿生产服务器部署",
"application_location": "上海",
"application_amount": "3000",
},
},
},
)
assert response.status_code == 202
job_id = response.json()["job_id"]
status_response = client.get(
f"/api/v1/reimbursements/linked-reimbursement-draft-jobs/{job_id}",
headers=headers,
)
assert status_response.status_code == 200
payload = status_response.json()
assert payload["status"] == "succeeded"
assert payload["draft_payload"]["claim_no"]
assert payload["draft_payload"]["claim_id"]
with session_factory() as db:
draft = db.get(ExpenseClaim, payload["draft_payload"]["claim_id"])
assert draft is not None
link_flag = next(
flag
for flag in draft.risk_flags_json
if flag.get("source") == "application_link"
)
assert link_flag["application_claim_no"] == "AP-202606-FAST"
assert link_flag["application_claim_id"] == "application-linked-draft-fast"
finally:
clear_linked_reimbursement_draft_jobs_for_tests()