feat: 报销审批流重构与管家计划全链路贯通
- 重构报销状态注册表、审批流路由与平台风险标记 - 完善管家意图规划器与模型计划构建器全链路 - 新增 OCR Worker 脚本、数据库会话管理与通知状态 - 优化文档中心、日志视图、预算中心与员工管理交互 - 增强工作台摘要、图标资源与全局主题样式 - 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"
|
||||
|
||||
96
server/tests/test_steward_runtime_decision_agent.py
Normal file
96
server/tests/test_steward_runtime_decision_agent.py
Normal 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"
|
||||
136
server/tests/test_steward_slot_decision_agent.py
Normal file
136
server/tests/test_steward_slot_decision_agent.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user