feat: 报销审批流重构与管家计划全链路贯通

- 重构报销状态注册表、审批流路由与平台风险标记
- 完善管家意图规划器与模型计划构建器全链路
- 新增 OCR Worker 脚本、数据库会话管理与通知状态
- 优化文档中心、日志视图、预算中心与员工管理交互
- 增强工作台摘要、图标资源与全局主题样式
- 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-06 17:19:07 +08:00
parent f60cebadb8
commit e124e4bbcb
162 changed files with 9161 additions and 1941 deletions

View File

@@ -36,10 +36,13 @@ from app.services import agent_foundation as agent_foundation_module
from app.services.agent_asset_spreadsheet import (
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_PREAPPROVAL_RULE_CODE,
COMPANY_PREAPPROVAL_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
FINANCE_RULES_LIBRARY,
)
from app.services.agent_foundation_constants import COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON
from app.services.agent_assets import AgentAssetService
from app.services.agent_runs import AgentRunService
from app.services.audit import AuditLogService
@@ -62,6 +65,7 @@ def isolate_rule_file_storage(tmp_path, monkeypatch) -> None:
for file_name in (
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_PREAPPROVAL_RULE_FILENAME,
):
source_path = real_finance_rules / file_name
if source_path.exists():
@@ -181,8 +185,10 @@ def test_finance_rules_use_risk_rule_scenario_categories() -> None:
communication_rule = next(
item for item in rules if item.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE
)
preapproval_rule = next(item for item in rules if item.code == COMPANY_PREAPPROVAL_RULE_CODE)
travel_config = travel_rule.config_json or {}
communication_config = communication_rule.config_json or {}
preapproval_config = preapproval_rule.config_json or {}
assert travel_rule.scenario_json == ["差旅费"]
assert travel_config["scenario_category"] == "差旅费"
@@ -190,6 +196,12 @@ def test_finance_rules_use_risk_rule_scenario_categories() -> None:
assert communication_rule.scenario_json == ["通信费"]
assert communication_config["scenario_category"] == "通信费"
assert communication_config["ai_review_category"] == "通信费"
assert preapproval_rule.scenario_json == list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON)
assert preapproval_config["tag"] == "财务规则"
assert preapproval_config["finance_rule_code"] == "expense.preapproval.policy"
assert preapproval_config["finance_rule_sheet"] == "费用申请审批规则"
assert preapproval_config["expense_types"] == ["meal", "entertainment", "office", "all"]
assert preapproval_config["rule_document"]["file_name"] == COMPANY_PREAPPROVAL_RULE_FILENAME
def test_non_standard_finance_rule_spreadsheets_are_not_seeded() -> None:

View File

@@ -106,6 +106,68 @@ def test_agent_run_service_updates_existing_tool_call() -> None:
assert fetched.tool_calls[0].response_json == {"track_id": "insert_123"}
def test_agent_run_list_uses_lightweight_preview_and_detail_keeps_full_payload() -> None:
with build_session() as db:
service = AgentRunService(db)
run = service.create_run(
agent=AgentName.HERMES.value,
source=AgentRunSource.SCHEDULE.value,
status=AgentRunStatus.SUCCEEDED.value,
ontology_json={
"scenario": "knowledge",
"intent": "sync",
"parse_strategy": "rule_fallback",
"model_invocation_summary": {"tokens": 999},
},
route_json={
"job_type": "knowledge_index_sync",
"phase": "indexing",
"progress": {
"percent": 50,
"total_documents": 2,
"completed_documents": 1,
"documents": [{"id": "doc-1", "text": "x" * 2000}],
},
"knowledge_ingest": {"documents": [{"id": "doc-1", "text": "x" * 2000}]},
},
)
service.record_tool_call(
run_id=run.run_id,
tool_type=AgentToolType.LLM.value,
tool_name="lightrag.index_documents",
request_json={"prompt": "x" * 2000},
response_json={"documents": [{"id": "doc-1", "text": "x" * 2000}]},
status="succeeded",
duration_ms=123,
)
listed = next(item for item in service.list_runs(limit=20) if item.run_id == run.run_id)
detail = service.get_run(run.run_id)
assert listed.ontology_json == {
"scenario": "knowledge",
"intent": "sync",
"parse_strategy": "rule_fallback",
}
assert listed.route_json["job_type"] == "knowledge_index_sync"
assert listed.route_json["phase"] == "indexing"
assert listed.route_json["progress"] == {
"percent": 50,
"total_documents": 2,
"completed_documents": 1,
}
assert "knowledge_ingest" not in listed.route_json
assert len(listed.tool_calls) == 1
assert listed.tool_calls[0].tool_name == "lightrag.index_documents"
assert listed.tool_calls[0].request_json == {}
assert listed.tool_calls[0].response_json == {}
assert detail is not None
assert "knowledge_ingest" in detail.route_json
assert detail.tool_calls[0].request_json["prompt"]
assert detail.tool_calls[0].response_json["documents"]
def test_agent_run_service_summarizes_model_and_tool_failures() -> None:
with build_session() as db:
service = AgentRunService(db)

View File

@@ -16,7 +16,7 @@ from app.models.financial_record import ExpenseClaim
from app.models.organization import OrganizationUnit
from app.models.role import Role
from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE,
APPLICATION_LINK_STATUS_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
@@ -147,7 +147,7 @@ def test_low_risk_application_skips_budget_manager_and_generates_draft() -> None
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == APPROVAL_DONE_STAGE
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1
assert any(
isinstance(flag, dict)
@@ -160,7 +160,7 @@ def test_low_risk_application_skips_budget_manager_and_generates_draft() -> None
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_approval"
and flag.get("next_approval_stage") == APPROVAL_DONE_STAGE
and flag.get("next_approval_stage") == APPLICATION_LINK_STATUS_STAGE
and flag.get("route_decision", {}).get("requires_budget_review") is False
for flag in approved.risk_flags_json
)
@@ -218,7 +218,7 @@ def test_budget_warning_application_still_skips_budget_manager_when_not_over_bud
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == APPROVAL_DONE_STAGE
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
assert any(
isinstance(flag, dict)
and flag.get("source") == "approval_routing"
@@ -285,7 +285,7 @@ def test_application_route_ignores_reimbursement_stage_current_risks() -> None:
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == APPROVAL_DONE_STAGE
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
route_flag = [
flag
for flag in approved.risk_flags_json

View File

@@ -319,6 +319,44 @@ def test_expense_application_pre_review_runs_stage_rules(tmp_path, monkeypatch)
assert ai_pre_review["business_stage"] == "expense_application"
def test_preapproval_amount_rules_run_from_rule_library() -> None:
with build_session() as db:
claim = _build_claim(claim_no="RE-PREAPPROVAL-MEAL", expense_type="meal")
claim.amount = Decimal("501.00")
flags = ExpenseClaimService(db).evaluate_platform_risk_rules(
claim,
business_stage="reimbursement",
)["flags"]
meal_flags = [
flag
for flag in flags
if isinstance(flag, dict)
and flag.get("rule_code") == "risk.application.meal_high_value_without_preapproval"
]
assert len(meal_flags) == 1
assert meal_flags[0]["finance_rule_code"] == "expense.preapproval.policy"
assert "500" in meal_flags[0]["message"]
claim.risk_flags_json = [
{
"source": "application_link",
"application_claim_id": "application-preapproval-ok",
"application_claim_no": "AP-202606-OK",
}
]
flags = ExpenseClaimService(db).evaluate_platform_risk_rules(
claim,
business_stage="reimbursement",
)["flags"]
assert all(
flag.get("rule_code") != "risk.application.meal_high_value_without_preapproval"
for flag in flags
if isinstance(flag, dict)
)
def test_reimbursement_item_sync_persists_rule_center_risk_preview(
tmp_path,
monkeypatch,

View File

@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext
from app.core.config import get_settings
from app.db.base import Base
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.employee import Employee
@@ -31,11 +32,14 @@ from app.services.expense_claim_attachment_storage import ExpenseClaimAttachment
from app.services.expense_claims import ExpenseClaimService
from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE,
APPLICATION_ARCHIVE_STAGE,
APPLICATION_LINK_STATUS_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
)
from app.services.ontology import SemanticOntologyService
from app.services.ocr import OcrService
from app.services.receipt_folder import ReceiptFolderService
def build_claim(*, expense_type: str, location: str) -> ExpenseClaim:
@@ -3907,6 +3911,23 @@ def test_list_archived_claims_returns_company_archived_records_for_finance() ->
approval_stage="审批完成",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="AP-20260525121000-ARCHIVED",
employee_name="",
department_name="E部",
project_code="PRJ-E",
expense_type="travel_application",
reason="E 申请",
location="广州",
amount=Decimal("600.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 11, 15, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 16, 0, tzinfo=UTC),
status="approved",
approval_stage=APPLICATION_ARCHIVE_STAGE,
risk_flags_json=[],
),
ExpenseClaim(
claim_no="AP-20260525123000-HGFEDCBA",
employee_name="",
@@ -3933,7 +3954,7 @@ def test_list_archived_claims_returns_company_archived_records_for_finance() ->
assert {claim.claim_no for claim in claims} == {
"EXP-ARCH-101",
"EXP-ARCH-PAID",
"AP-20260525120000-ABCDEFGH",
"AP-20260525121000-ARCHIVED",
}
@@ -4288,6 +4309,65 @@ def test_admin_can_delete_archived_claim() -> None:
assert db.get(ExpenseClaim, claim_id) is None
def test_admin_delete_claim_unlinks_receipt_folder_items(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
try:
receipt_owner = CurrentUserContext(
username="emp-1",
name="Employee",
role_codes=[],
is_admin=False,
)
admin_user = CurrentUserContext(
username="superadmin",
name="Admin",
role_codes=["manager"],
is_admin=True,
)
with build_session() as db:
claim = build_claim(expense_type="travel", location="Shanghai")
db.add(claim)
db.commit()
claim_id = claim.id
claim_no = claim.claim_no
item_id = claim.items[0].id
receipt_service = ReceiptFolderService()
receipt = receipt_service.save_receipt(
filename="admin-delete-linked-receipt.pdf",
content=b"%PDF-1.4 linked",
media_type="application/pdf",
current_user=receipt_owner,
linked_claim_id=claim_id,
linked_claim_no=claim_no,
linked_item_id=item_id,
document=OcrRecognizeDocumentRead(
filename="admin-delete-linked-receipt.pdf",
media_type="application/pdf",
text="invoice number 123 amount 100",
document_type="vat_invoice",
document_type_label="invoice",
scene_code="other",
scene_label="receipt",
),
)
assert receipt.status == "linked"
deleted = ExpenseClaimService(db).delete_claim(claim_id, admin_user)
assert deleted is not None
assert db.get(ExpenseClaim, claim_id) is None
unlinked_receipt = receipt_service.get_receipt(receipt.id, receipt_owner)
assert unlinked_receipt.status == "unlinked"
assert unlinked_receipt.linked_claim_id == ""
assert unlinked_receipt.linked_claim_no == ""
assert unlinked_receipt.linked_at is None
finally:
get_settings.cache_clear()
def test_direct_manager_can_return_subordinate_claim_to_pending_submission() -> None:
current_user = CurrentUserContext(
username="manager-return@example.com",
@@ -4842,7 +4922,7 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == "审批完成"
assert approved.approval_stage == "关联单据状态"
archived_claims = ExpenseClaimService(db).list_archived_claims(
CurrentUserContext(
username="finance-archive@example.com",
@@ -4851,7 +4931,7 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
is_admin=False,
)
)
assert any(claim.claim_no == "APP-20260525-APPROVE" for claim in archived_claims)
assert all(claim.claim_no != "APP-20260525-APPROVE" for claim in archived_claims)
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
assert generated_draft.status == "draft"
assert generated_draft.approval_stage == "待提交"
@@ -4891,7 +4971,7 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
and flag.get("opinion") == "预算额度可承接,同意。"
and flag.get("previous_approval_stage") == "预算管理者审批"
and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == "审批完成"
and flag.get("next_approval_stage") == "关联单据状态"
and flag.get("generated_draft_claim_id") == generated_draft.id
and flag.get("generated_draft_claim_no") == generated_draft.claim_no
for flag in approved.risk_flags_json
@@ -5002,7 +5082,7 @@ def test_application_routes_to_department_p8_executive_with_approver_name() -> N
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == APPROVAL_DONE_STAGE
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
def test_direct_manager_cannot_route_application_to_missing_budget_approver() -> None:
@@ -5147,7 +5227,7 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == APPROVAL_DONE_STAGE
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1
assert not any(
isinstance(flag, dict)
@@ -5158,7 +5238,7 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud
isinstance(flag, dict)
and flag.get("source") == "manual_approval"
and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == APPROVAL_DONE_STAGE
and flag.get("next_approval_stage") == APPLICATION_LINK_STATUS_STAGE
and flag.get("budget_approval_merged") is True
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
for flag in approved.risk_flags_json
@@ -5235,7 +5315,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == "审批完成"
assert approved.approval_stage == "关联单据状态"
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1
assert not any(
isinstance(flag, dict)
@@ -5250,7 +5330,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
and flag.get("opinion") == "业务必要且预算可承接,同意申请。"
and flag.get("previous_approval_stage") == "直属领导审批"
and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == "审批完成"
and flag.get("next_approval_stage") == "关联单据状态"
and flag.get("budget_approval_merged") is True
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
for flag in approved.risk_flags_json
@@ -5819,6 +5899,94 @@ def test_finance_can_mark_pending_payment_claim_as_paid() -> None:
)
def test_marking_linked_reimbursement_paid_archives_application_claim() -> None:
current_user = CurrentUserContext(
username="finance-pay-linked-application@example.com",
name="财务付款",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
application_claim = ExpenseClaim(
claim_no="AP-202606050001-ARCHIVE",
employee_name="张三",
department_name="交付部",
project_code="PRJ-APP",
expense_type="travel_application",
reason="支撑国网部署",
location="上海",
amount=Decimal("3000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 6, 5, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 6, 5, 10, 0, tzinfo=UTC),
status="approved",
approval_stage=APPROVAL_DONE_STAGE,
risk_flags_json=[],
)
db.add(application_claim)
db.flush()
reimbursement_claim = ExpenseClaim(
claim_no="RE-202606050001-ARCHIVE",
employee_name="张三",
department_name="交付部",
project_code="PRJ-APP",
expense_type="travel",
reason="支撑国网部署报销",
location="上海",
amount=Decimal("3000.00"),
currency="CNY",
invoice_count=2,
occurred_at=datetime(2026, 6, 5, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 6, 6, 10, 0, tzinfo=UTC),
status="pending_payment",
approval_stage="待付款",
risk_flags_json=[
{
"source": "application_handoff",
"event_type": "expense_application_to_reimbursement_draft",
"application_claim_id": application_claim.id,
"application_claim_no": application_claim.claim_no,
}
],
)
db.add(reimbursement_claim)
db.commit()
archived_before = ExpenseClaimService(db).list_archived_claims(current_user)
assert all(claim.claim_no != application_claim.claim_no for claim in archived_before)
paid = ExpenseClaimService(db).mark_claim_paid(reimbursement_claim.id, current_user)
assert paid is not None
db.refresh(application_claim)
assert application_claim.status == "approved"
assert application_claim.approval_stage == APPLICATION_ARCHIVE_STAGE
assert any(
isinstance(flag, dict)
and flag.get("source") == "application_archive_sync"
and flag.get("event_type") == "expense_application_archived_by_reimbursement"
and flag.get("reimbursement_claim_no") == reimbursement_claim.claim_no
and flag.get("next_approval_stage") == APPLICATION_ARCHIVE_STAGE
for flag in application_claim.risk_flags_json
)
assert any(
isinstance(flag, dict)
and flag.get("source") == "payment"
and any(
item.get("application_claim_no") == application_claim.claim_no
for item in flag.get("archived_application_claims", [])
if isinstance(item, dict)
)
for flag in paid.risk_flags_json
)
archived_after = ExpenseClaimService(db).list_archived_claims(current_user)
assert any(claim.claim_no == application_claim.claim_no for claim in archived_after)
def test_return_claim_rejects_already_returned_claim_without_adding_event() -> None:
current_user = CurrentUserContext(
username="finance-returned@example.com",

View File

@@ -3,7 +3,8 @@ from app.services.expense_claim_status_registry import (
normalize_expense_claim_state,
)
from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE,
APPLICATION_ARCHIVE_STAGE,
APPLICATION_LINK_STATUS_STAGE,
ARCHIVE_ACCOUNTING_STAGE,
FINANCE_APPROVAL_STAGE,
PAYMENT_PAID_STAGE,
@@ -40,7 +41,19 @@ def test_normalize_reimbursement_archive_stage_differs_from_application_done() -
)
assert reimbursement_state.approval_stage == ARCHIVE_ACCOUNTING_STAGE
assert application_state.approval_stage == APPROVAL_DONE_STAGE
assert application_state.approval_stage == APPLICATION_LINK_STATUS_STAGE
def test_normalize_application_archive_stage_is_distinct_from_approval_done() -> None:
state = normalize_expense_claim_state(
"approved",
APPLICATION_ARCHIVE_STAGE,
claim_no="AP-20260602-0002",
expense_type="travel_application",
)
assert state.status == "approved"
assert state.approval_stage == APPLICATION_ARCHIVE_STAGE
def test_normalize_payment_stages_by_status() -> None:

View File

@@ -117,3 +117,28 @@ def test_notification_state_endpoint_reads_and_updates_current_user_state() -> N
assert payload["states"][0]["hidden_at"] is None
assert payload["states"][0]["context_json"]["kind"] == "workbench"
assert other_response.json()["states"] == []
def test_notification_state_endpoint_accepts_document_center_bulk_read_state() -> None:
client = build_client()
headers = {"x-auth-username": "alice", "x-auth-name": "Alice"}
states = [
{
"notification_id": f"document:owned:DOC-{index}",
"read": True,
"hidden": False,
"context_json": {"kind": "document", "target_type": "documents-center"},
}
for index in range(150)
]
response = client.post(
"/api/v1/notification-states",
json={"states": states},
headers=headers,
)
assert response.status_code == 200
payload = response.json()
assert len(payload["states"]) == 150
assert all(item["read_at"] for item in payload["states"])

View File

@@ -179,6 +179,64 @@ def test_ocr_service_converts_pdf_to_images_and_returns_image_preview(
assert recognized.lines[1].page_index == 1
def test_ocr_service_reuses_cached_document_for_same_content(
monkeypatch,
tmp_path: Path,
) -> None:
calls = {"count": 0}
def fake_invoke_worker(
self,
*,
python_bin: str,
worker_path: str,
input_paths: list[Path],
) -> dict:
calls["count"] += 1
return {
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"documents": [
{
"input_path": str(input_paths[0]),
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"text": "增值税电子发票 金额 20 元",
"summary": "增值税电子发票,金额 20 元。",
"avg_score": 0.97,
"line_count": 1,
"page_count": 1,
"warnings": [],
"lines": [
{
"text": "增值税电子发票 金额 20 元",
"score": 0.97,
"box": [[1, 2], [10, 2], [10, 8], [1, 8]],
}
],
}
],
}
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
monkeypatch.setattr(OcrService, "_resolve_python_bin", lambda self: "python")
monkeypatch.setattr(OcrService, "_resolve_worker_path", lambda self: "worker.py")
monkeypatch.setattr(OcrService, "_invoke_worker", fake_invoke_worker)
OcrService._result_cache.clear()
get_settings.cache_clear()
try:
first = OcrService().recognize_files([("first.png", b"same-image", "image/png")])
second = OcrService().recognize_files([("second.png", b"same-image", "image/png")])
finally:
OcrService._result_cache.clear()
get_settings.cache_clear()
assert calls["count"] == 1
assert first.documents[0].filename == "first.png"
assert second.documents[0].filename == "second.png"
assert second.documents[0].summary == first.documents[0].summary
def test_ocr_service_prefers_pdf_text_layer_when_rendered_ocr_is_placeholder_heavy(
monkeypatch,
tmp_path: Path,

View File

@@ -1,7 +1,5 @@
from __future__ import annotations
import pytest
from app.api.deps import CurrentUserContext
from app.core.config import get_settings
from app.schemas.ocr import OcrRecognizeDocumentRead
@@ -71,7 +69,7 @@ def test_receipt_folder_train_ticket_uses_invoice_date_and_enriches_fields(monke
get_settings.cache_clear()
def test_receipt_folder_delete_receipts_for_claim_removes_linked_receipts(monkeypatch, tmp_path) -> None:
def test_receipt_folder_unlink_receipts_for_claim_marks_linked_receipts_unlinked(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
try:
@@ -101,9 +99,17 @@ def test_receipt_folder_delete_receipts_for_claim_removes_linked_receipts(monkey
),
)
assert service.get_receipt(receipt.id, current_user).linked_claim_id == "claim-1"
assert service.delete_receipts_for_claim("claim-1") == 1
with pytest.raises(FileNotFoundError):
service.get_receipt(receipt.id, current_user)
linked_detail = service.get_receipt(receipt.id, current_user)
assert linked_detail.status == "linked"
assert linked_detail.linked_claim_id == "claim-1"
assert linked_detail.linked_claim_no == "RE-001"
assert service.unlink_receipts_for_claim("claim-1") == 1
unlinked_detail = service.get_receipt(receipt.id, current_user)
assert unlinked_detail.status == "unlinked"
assert unlinked_detail.linked_claim_id == ""
assert unlinked_detail.linked_claim_no == ""
assert unlinked_detail.linked_at is None
finally:
get_settings.cache_clear()

View File

@@ -1,10 +1,13 @@
from __future__ import annotations
import json
from datetime import UTC, date, datetime
from decimal import Decimal
from pathlib import Path
import pytest
from app.core.config import SERVER_DIR
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.services.risk_rule_dsl_examples import (
get_risk_rule_dsl_example,
@@ -166,6 +169,95 @@ def test_date_rule_uses_application_month_before_ticket_item_date() -> None:
assert condition["outside_dates"] == ["2026-02-20"]
def test_application_context_values_are_available_to_composite_rules() -> None:
claim = _claim(amount=Decimal("3000.00"))
claim.risk_flags_json = [
{
"source": "application_link",
"application_claim_id": "application-ctx-1",
"application_claim_no": "AP-202606-CTX",
"application_detail": {
"application_amount": "3000",
"application_expense_type": "office",
},
}
]
manifest = {
"template_key": COMPOSITE_RULE_TEMPLATE_KEY,
"params": {
"template_key": COMPOSITE_RULE_TEMPLATE_KEY,
"conditions": [
{
"id": "application_present",
"operator": "exists_any",
"fields": ["application.id", "application.claim_no"],
}
],
"hit_logic": "application_present",
"condition_summary": "application exists",
},
}
result = RiskRuleTemplateExecutor().evaluate(manifest, claim=claim, contexts=[])
assert result is not None
condition = result["evidence"]["conditions"][0]
assert condition["values"] == ["application-ctx-1", "AP-202606-CTX"]
@pytest.mark.parametrize(
("file_name", "expense_type", "amount"),
[
("risk.application.meal_high_value_without_preapproval.json", "meal", Decimal("501.00")),
("risk.application.office_bulk_without_purchase.json", "office", Decimal("2001.00")),
("risk.application.large_expense_without_preapproval.json", "software", Decimal("2001.00")),
],
)
def test_preapproval_amount_rules_hit_without_linked_application(
file_name: str,
expense_type: str,
amount: Decimal,
) -> None:
claim = _claim(amount=amount)
claim.expense_type = expense_type
manifest = _load_rule_manifest(file_name)
result = RiskRuleTemplateExecutor().evaluate(manifest, claim=claim, contexts=[])
assert result is not None
assert result["evidence"]["condition_results"]["amount_exceeds_preapproval_threshold"] is True
assert result["evidence"]["condition_results"]["application_present"] is False
@pytest.mark.parametrize(
("file_name", "expense_type", "amount"),
[
("risk.application.meal_high_value_without_preapproval.json", "entertainment", Decimal("800.00")),
("risk.application.office_bulk_without_purchase.json", "office", Decimal("2600.00")),
("risk.application.large_expense_without_preapproval.json", "software", Decimal("2600.00")),
],
)
def test_preapproval_amount_rules_skip_when_application_is_linked(
file_name: str,
expense_type: str,
amount: Decimal,
) -> None:
claim = _claim(amount=amount)
claim.expense_type = expense_type
claim.risk_flags_json = [
{
"source": "application_link",
"application_claim_id": "application-linked-ok",
"application_claim_no": "AP-202606-OK",
}
]
manifest = _load_rule_manifest(file_name)
result = RiskRuleTemplateExecutor().evaluate(manifest, claim=claim, contexts=[])
assert result is None
def _claim(*, amount: Decimal = Decimal("1000.00")) -> ExpenseClaim:
claim = ExpenseClaim(
claim_no="TEST-RISK-RULE-DSL",
@@ -193,3 +285,8 @@ def _claim(*, amount: Decimal = Decimal("1000.00")) -> ExpenseClaim:
)
]
return claim
def _load_rule_manifest(file_name: str) -> dict:
path = Path(SERVER_DIR) / "rules" / "risk-rules" / file_name
return json.loads(path.read_text(encoding="utf-8"))

View File

@@ -93,6 +93,38 @@ class EntertainmentFunctionCallingIntentAgent:
)
class ApplicationFunctionCallingIntentAgent:
def detect(self, request, *, base_date, canonical_fields):
return StewardIntentAgentResult(
payload={
"thinking_events": [
{
"stage": "task_split",
"title": "识别出差申请",
"content": "模型识别到用户要发起北京出差申请,并且后续还有报销事项。",
}
],
"tasks": [
{
"task_type": "expense_application",
"title": "北京出差申请",
"summary": "明天前往北京出差3天支撑国网仿生产部署。",
"confidence": 0.94,
"ontology_fields": {
"time_range": "明天",
"location": "北京",
"expense_type": "差旅",
"reason": "支撑国网仿生产部署",
},
"missing_fields": [],
}
],
"attachment_groups": [],
},
model_call_traces=[],
)
def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None:
payload = StewardPlanRequest(
message="我要报销昨天客户现场沟通的交通费",
@@ -136,6 +168,22 @@ def test_steward_planner_normalizes_llm_business_entertainment_expense_type() ->
assert result.tasks[0].ontology_fields["time_range"] == "2026-06-03"
def test_steward_planner_enforces_application_transport_gap_after_function_calling() -> None:
payload = StewardPlanRequest(
message="明天出差北京3天支撑国网仿生产部署",
client_now_iso="2026-06-04T09:30:00+08:00",
)
result = StewardPlannerService(intent_agent=ApplicationFunctionCallingIntentAgent()).build_plan(payload)
assert result.planning_source == "llm_function_call"
assert result.tasks[0].missing_fields == ["transport_mode"]
gap_events = [event for event in result.thinking_events if event.stage == "business_gap_check"]
assert gap_events
assert "没有说明出行方式" in gap_events[0].content
assert "火车、飞机或轮船" in gap_events[0].content
def test_steward_planner_falls_back_to_rules_when_function_calling_is_unavailable() -> None:
payload = StewardPlanRequest(
message="我要报销昨天的交通费",
@@ -197,6 +245,10 @@ def test_steward_planner_treats_future_travel_without_apply_word_as_application(
assert result.tasks[0].ontology_fields["location"] == "北京"
assert result.tasks[0].ontology_fields["expense_type"] == "travel"
assert result.tasks[0].ontology_fields["reason"] == "支撑国网仿生产部署"
assert result.tasks[0].missing_fields == ["transport_mode"]
gap_events = [event for event in result.thinking_events if event.stage == "business_gap_check"]
assert gap_events
assert "没有说明出行方式" in gap_events[0].content
assert result.tasks[1].assigned_agent == "reimbursement_assistant"
assert result.tasks[1].ontology_fields["time_range"] == "2026-06-03"
assert result.tasks[1].ontology_fields["expense_type"] == "entertainment"

View File

@@ -0,0 +1,96 @@
from app.schemas.steward import StewardRuntimeDecisionRequest
from app.services.steward_runtime_decision_agent import (
STEWARD_RUNTIME_DECISION_FUNCTION_NAME,
StewardRuntimeDecisionAgent,
)
class _FakeToolCall:
def __init__(self, name, arguments):
self.name = name
self.arguments = arguments
class _FakeRuntimeResult:
def __init__(self, tool_call=None):
self.tool_call = tool_call
def calls_as_dicts(self):
return [{"tool": self.tool_call.name if self.tool_call else ""}]
class _FakeRuntime:
def __init__(self, payload):
self.payload = payload
self.last_messages = []
self.last_tools = []
self.last_tool_choice = None
def complete_with_tool_call(self, messages, tools, tool_choice, **kwargs):
self.last_messages = messages
self.last_tools = tools
self.last_tool_choice = tool_choice
if self.payload is None:
return _FakeRuntimeResult()
return _FakeRuntimeResult(_FakeToolCall(STEWARD_RUNTIME_DECISION_FUNCTION_NAME, self.payload))
def test_steward_runtime_decision_uses_function_calling_context():
runtime = _FakeRuntime(
{
"next_action": "submit_current_application",
"target_task_id": "task-application-beijing",
"target_message_id": "msg-application-preview",
"field_key": "",
"field_value": "",
"confirmation_required": False,
"question": "",
"response_text": "",
"rationale": "用户确认当前申请核对表无误。",
}
)
result = StewardRuntimeDecisionAgent(runtime).decide(
StewardRuntimeDecisionRequest(
user_message="确认",
runtime_state={
"waiting_for": "application_submit_confirmation",
"pending_application": {
"message_id": "msg-application-preview",
"task_id": "task-application-beijing",
"ready_to_submit": True,
},
"remaining_tasks": [
{"task_id": "task-reimbursement-meal", "task_type": "reimbursement"}
],
},
)
)
assert result.decision_source == "llm_function_call"
assert result.next_action == "submit_current_application"
assert result.target_message_id == "msg-application-preview"
assert result.target_task_id == "task-application-beijing"
assert runtime.last_tool_choice["function"]["name"] == STEWARD_RUNTIME_DECISION_FUNCTION_NAME
assert "runtime_state" in runtime.last_messages[-1]["content"]
def test_steward_runtime_decision_fallback_keeps_current_context():
runtime = _FakeRuntime(None)
result = StewardRuntimeDecisionAgent(runtime).decide(
StewardRuntimeDecisionRequest(
user_message="确认",
runtime_state={
"pending_steward_action": {
"message_id": "msg-next-task",
"target_task_id": "task-reimbursement-meal",
}
},
)
)
assert result.decision_source == "rule_fallback"
assert result.next_action == "continue_next_task"
assert result.target_message_id == "msg-next-task"
assert result.target_task_id == "task-reimbursement-meal"

View File

@@ -0,0 +1,136 @@
from __future__ import annotations
from app.schemas.steward import StewardSlotDecisionRequest
from app.services.runtime_chat import RuntimeChatCallTrace, RuntimeChatToolCall, RuntimeToolCallResult
from app.services.steward_slot_decision_agent import (
STEWARD_SLOT_DECISION_FUNCTION_NAME,
StewardSlotDecisionAgent,
)
class FakeSlotRuntime:
def __init__(self, arguments=None):
self.arguments = arguments
self.messages = None
def complete_with_tool_call(self, messages, **kwargs):
self.messages = messages
if self.arguments is None:
return RuntimeToolCallResult(tool_call=None, calls=[])
return RuntimeToolCallResult(
tool_call=RuntimeChatToolCall(
name=STEWARD_SLOT_DECISION_FUNCTION_NAME,
arguments=self.arguments,
),
calls=[
RuntimeChatCallTrace(
slot="main",
provider="OpenAI Compatible",
model="fake",
attempt=1,
status="succeeded",
)
],
)
def test_steward_slot_decision_uses_function_calling_result() -> None:
runtime = FakeSlotRuntime(
{
"next_action": "ask_user",
"required_fields": ["expense_type", "time_range", "location", "reason", "transport_mode"],
"missing_fields": ["transport_mode"],
"question": "请问你这次打算怎么出行?",
"options": [
{"field_key": "transport_mode", "label": "飞机", "value": "飞机"},
{"field_key": "transport_mode", "label": "火车", "value": "火车"},
],
"rationale": "出行方式会影响交通费用测算。",
}
)
result = StewardSlotDecisionAgent(runtime).decide(
StewardSlotDecisionRequest(
task_type="expense_application",
user_message="明天出差北京3天支撑国网仿生产部署",
ontology_fields={
"expense_type": "travel",
"time_range": "2026-06-05 至 2026-06-07",
"location": "北京",
"reason": "支撑国网仿生产部署",
},
missing_fields=["transport_mode"],
)
)
assert result.decision_source == "llm_function_call"
assert result.next_action == "ask_user"
assert result.missing_fields == ["transport_mode"]
assert [item.value for item in result.options] == ["飞机", "火车"]
assert "出行方式会影响" in result.rationale
def test_steward_slot_decision_falls_back_to_intent_missing_fields_only() -> None:
runtime = FakeSlotRuntime(arguments=None)
result = StewardSlotDecisionAgent(runtime).decide(
StewardSlotDecisionRequest(
task_type="expense_application",
user_message="还需要补充:出行方式(例如高铁、飞机、自驾、出租车)",
ontology_fields={
"expense_type": "travel",
"location": "北京",
"reason": "支撑国网仿生产部署",
},
missing_fields=["transport_mode"],
)
)
assert result.decision_source == "rule_fallback"
assert result.next_action == "ask_user"
assert result.missing_fields == ["transport_mode"]
assert [item.value for item in result.options] == ["火车", "飞机", "轮船"]
assert "高铁" not in result.required_fields
def test_steward_slot_decision_does_not_ask_user_for_application_profile_or_attachments() -> None:
runtime = FakeSlotRuntime(
{
"next_action": "ask_user",
"required_fields": [
"expense_type",
"time_range",
"location",
"reason",
"amount",
"attachments",
"employee_no",
],
"missing_fields": ["attachments", "employee_no"],
"question": "请补充附件和员工编号。",
"options": [],
"rationale": "附件/凭证和员工编号为合规必需字段。",
}
)
result = StewardSlotDecisionAgent(runtime).decide(
StewardSlotDecisionRequest(
task_type="expense_application",
user_message="明天出差北京3天支撑国网仿生产部署",
ontology_fields={
"expense_type": "travel",
"time_range": "2026-06-05 至 2026-06-07",
"location": "北京",
"reason": "支撑国网仿生产部署",
},
missing_fields=["attachments", "employee_no"],
)
)
assert result.decision_source == "llm_function_call"
assert result.next_action == "render_preview"
assert result.missing_fields == []
assert "attachments" not in result.required_fields
assert "employee_no" not in result.required_fields
assert result.options == []
assert "合规必需字段" not in result.rationale

View File

@@ -693,6 +693,66 @@ def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
assert second_response.draft_payload is None
def test_user_agent_application_submit_blocks_overlapping_travel_dates() -> None:
session_factory = build_session_factory()
with session_factory() as db:
existing_claim = ExpenseClaim(
id="application-overlap-1",
claim_no="AP-202606050001-OVERLAP",
employee_name="pytest",
department_name="技术部",
expense_type="travel_application",
reason="支撑国网部署",
location="北京",
amount=Decimal("2700.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 6, 5, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[
{
"source": "application_detail",
"business_stage": "expense_application",
"application_detail": {
"application_type": "差旅费用申请",
"time": "2026-06-05 至 2026-06-07",
"location": "北京",
"reason": "支撑国网部署",
},
}
],
)
db.add(existing_claim)
db.commit()
response = build_application_user_agent_response(
db,
"确认提交",
context_overrides={
"manager_name": "向万红",
"application_preview": {
"fields": {
"applicationType": "差旅费用申请",
"time": "2026-06-06 至 2026-06-08",
"location": "北京",
"reason": "支撑国网仿生产部署",
"days": "3天",
"transportMode": "火车",
"amount": "2700元",
}
},
},
)
claims = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).all()
assert len(claims) == 1
assert "已存在申请单" in response.answer
assert "系统没有重复创建" in response.answer
assert existing_claim.claim_no in response.answer
assert response.draft_payload is None
def test_user_agent_application_edit_resubmits_returned_application_claim() -> None:
session_factory = build_session_factory()
with session_factory() as db: