feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造
- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制 - 引入费用审批动态路由、平台风险分级、预审与风险阶段管理 - 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板 - 新增 Hermes 风险线索收集器、Agent 链路追踪中心 - 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估 - 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
@@ -212,18 +212,16 @@ def test_demo_budget_risk_rules_sync_with_finance_rule_references() -> None:
|
||||
AgentAsset.code == "risk.budget.available_balance_insufficient"
|
||||
)
|
||||
)
|
||||
marketing_rule = db.scalar(
|
||||
communication_rule = db.scalar(
|
||||
select(AgentAsset).where(
|
||||
AgentAsset.code == "risk.application.marketing_without_campaign"
|
||||
AgentAsset.code == "risk.standard.communication_amount_over_policy"
|
||||
)
|
||||
)
|
||||
|
||||
assert budget_rule is not None
|
||||
assert "差旅费" in budget_rule.scenario_json
|
||||
assert "市场推广费" in budget_rule.scenario_json
|
||||
assert "软件服务费" in budget_rule.scenario_json
|
||||
assert budget_rule.scenario_json == ["全部"]
|
||||
assert budget_rule.config_json["budget_required"] is True
|
||||
assert "marketing" in budget_rule.config_json["expense_types"]
|
||||
assert budget_rule.config_json["expense_types"] == ["all"]
|
||||
assert budget_rule.config_json["business_stage"] == [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
@@ -231,12 +229,12 @@ def test_demo_budget_risk_rules_sync_with_finance_rule_references() -> None:
|
||||
]
|
||||
assert budget_rule.config_json["finance_rule_code"] == "budget.execution.policy"
|
||||
|
||||
assert marketing_rule is not None
|
||||
assert marketing_rule.scenario_json == ["市场推广费"]
|
||||
assert marketing_rule.config_json["finance_rule_code"] == "expense.application.policy"
|
||||
assert marketing_rule.config_json["finance_rule_sheet"] == "费用申请前置规则"
|
||||
assert marketing_rule.config_json["expense_types"] == ["marketing"]
|
||||
assert marketing_rule.config_json["budget_required"] is True
|
||||
assert communication_rule is not None
|
||||
assert communication_rule.scenario_json == ["通信费"]
|
||||
assert communication_rule.config_json["finance_rule_code"] == "expense.communication.policy"
|
||||
assert communication_rule.config_json["finance_rule_sheet"] == "通信费报销规则"
|
||||
assert communication_rule.config_json["expense_types"] == ["communication"]
|
||||
assert communication_rule.config_json["budget_required"] is True
|
||||
|
||||
|
||||
def test_agent_asset_service_can_activate_rule_after_review() -> None:
|
||||
|
||||
205
server/tests/test_agent_trace_service.py
Normal file
205
server/tests/test_agent_trace_service.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.core.agent_enums import AgentName, AgentRunSource, AgentRunStatus, AgentToolType
|
||||
from app.db.base import Base
|
||||
from app.main import create_app
|
||||
from app.models.agent_conversation import AgentConversation, AgentConversationMessage
|
||||
from app.services.agent_runs import AgentRunService
|
||||
from app.services.agent_traces import AgentTraceService
|
||||
|
||||
|
||||
def build_session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
return session_factory()
|
||||
|
||||
|
||||
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
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
|
||||
return TestClient(app), session_factory
|
||||
|
||||
|
||||
def test_agent_trace_service_records_events_and_reads_detail() -> None:
|
||||
with build_session() as db:
|
||||
run_service = AgentRunService(db)
|
||||
trace_service = AgentTraceService(db)
|
||||
started_at = datetime.now(UTC) - timedelta(seconds=2)
|
||||
run = run_service.create_run(
|
||||
agent=AgentName.ORCHESTRATOR.value,
|
||||
source=AgentRunSource.USER_MESSAGE.value,
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
route_json={"conversation_id": "conv-trace-1"},
|
||||
result_summary="expense answer ready",
|
||||
started_at=started_at,
|
||||
finished_at=started_at + timedelta(seconds=1),
|
||||
)
|
||||
db.add(
|
||||
AgentConversation(
|
||||
conversation_id="conv-trace-1",
|
||||
user_id="u-1",
|
||||
source=AgentRunSource.USER_MESSAGE.value,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
AgentConversationMessage(
|
||||
conversation_id="conv-trace-1",
|
||||
run_id=run.run_id,
|
||||
role="user",
|
||||
content="帮我看报销风险",
|
||||
message_json={"source": "test"},
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
first = trace_service.record_event(
|
||||
run_id=run.run_id,
|
||||
conversation_id="conv-trace-1",
|
||||
stage="orchestrator",
|
||||
event_name="request_received",
|
||||
title="接收请求",
|
||||
summary="用户消息进入编排",
|
||||
input_json={"message": "帮我看报销风险"},
|
||||
output_json={"run_id": run.run_id},
|
||||
started_at=started_at,
|
||||
finished_at=started_at + timedelta(milliseconds=20),
|
||||
)
|
||||
second = trace_service.record_event(
|
||||
run_id=run.run_id,
|
||||
conversation_id="conv-trace-1",
|
||||
stage="response",
|
||||
event_name="response_built",
|
||||
title="生成回复",
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
output_json={"message": "已完成"},
|
||||
started_at=started_at + timedelta(milliseconds=500),
|
||||
finished_at=started_at + timedelta(milliseconds=650),
|
||||
)
|
||||
|
||||
items = trace_service.list_traces(keyword=run.run_id, limit=10)
|
||||
detail = trace_service.get_trace(run.run_id)
|
||||
|
||||
assert first.sequence == 1
|
||||
assert second.sequence == 2
|
||||
assert len(items) == 1
|
||||
assert items[0].event_count == 2
|
||||
assert detail is not None
|
||||
assert detail.fallback_generated is False
|
||||
assert [event.event_name for event in detail.events] == [
|
||||
"request_received",
|
||||
"response_built",
|
||||
]
|
||||
assert detail.conversation_id == "conv-trace-1"
|
||||
assert detail.conversation_messages[0].content == "帮我看报销风险"
|
||||
|
||||
|
||||
def test_agent_trace_service_builds_fallback_timeline_for_legacy_runs() -> None:
|
||||
with build_session() as db:
|
||||
run_service = AgentRunService(db)
|
||||
trace_service = AgentTraceService(db)
|
||||
run = run_service.create_run(
|
||||
agent=AgentName.HERMES.value,
|
||||
source=AgentRunSource.SCHEDULE.value,
|
||||
status=AgentRunStatus.FAILED.value,
|
||||
route_json={"conversation_id": "conv-trace-legacy", "stage": "tooling"},
|
||||
result_summary="sync failed",
|
||||
error_message="boom",
|
||||
)
|
||||
run_service.record_semantic_parse(
|
||||
run_id=run.run_id,
|
||||
user_id="u-1",
|
||||
raw_query="同步知识库",
|
||||
scenario="knowledge",
|
||||
intent="sync",
|
||||
confidence=0.92,
|
||||
)
|
||||
run_service.record_tool_call(
|
||||
run_id=run.run_id,
|
||||
tool_type=AgentToolType.LLM.value,
|
||||
tool_name="lightrag.index_documents",
|
||||
request_json={"document_ids": ["doc-1"]},
|
||||
response_json={"fallback": True},
|
||||
status=AgentRunStatus.FAILED.value,
|
||||
duration_ms=31,
|
||||
error_message="boom",
|
||||
)
|
||||
|
||||
detail = trace_service.get_trace(run.run_id)
|
||||
conversation_detail = trace_service.get_conversation_trace("conv-trace-legacy")
|
||||
|
||||
assert detail is not None
|
||||
assert detail.fallback_generated is True
|
||||
assert detail.conversation_id == "conv-trace-legacy"
|
||||
assert "semantic_parsed" in [event.event_name for event in detail.events]
|
||||
assert "tool_invoked" in [event.event_name for event in detail.events]
|
||||
assert detail.events[-1].event_name == "failed"
|
||||
assert detail.tool_calls[0].tool_name == "lightrag.index_documents"
|
||||
assert [item.run.run_id for item in conversation_detail.runs] == [run.run_id]
|
||||
|
||||
|
||||
def test_agent_trace_endpoints_return_admin_trace_detail() -> None:
|
||||
client, session_factory = build_client()
|
||||
|
||||
with session_factory() as db:
|
||||
run_service = AgentRunService(db)
|
||||
trace_service = AgentTraceService(db)
|
||||
run = run_service.create_run(
|
||||
agent=AgentName.ORCHESTRATOR.value,
|
||||
source=AgentRunSource.USER_MESSAGE.value,
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
route_json={"conversation_id": "conv-api-trace"},
|
||||
result_summary="api trace ready",
|
||||
)
|
||||
trace_service.record_event(
|
||||
run_id=run.run_id,
|
||||
conversation_id="conv-api-trace",
|
||||
stage="response",
|
||||
event_name="response_built",
|
||||
title="生成回复",
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
output_json={"message": "ok"},
|
||||
)
|
||||
|
||||
headers = {
|
||||
"x-auth-username": "admin",
|
||||
"x-auth-name": "admin",
|
||||
"x-auth-is-admin": "true",
|
||||
}
|
||||
list_response = client.get("/api/v1/agent-traces", headers=headers)
|
||||
detail_response = client.get(f"/api/v1/agent-traces/{run.run_id}", headers=headers)
|
||||
|
||||
assert list_response.status_code == 200
|
||||
assert any(item["run_id"] == run.run_id for item in list_response.json())
|
||||
assert detail_response.status_code == 200
|
||||
payload = detail_response.json()
|
||||
assert payload["run"]["run_id"] == run.run_id
|
||||
assert payload["events"][0]["event_name"] == "response_built"
|
||||
@@ -23,11 +23,11 @@ def build_session() -> Session:
|
||||
return session_factory()
|
||||
|
||||
|
||||
def test_employee_can_login_with_seed_default_password() -> None:
|
||||
with build_session() as db:
|
||||
employee = EmployeeService(db).list_employees()[0]
|
||||
result = AuthService(db).login(
|
||||
LoginRequest(username=employee.email, password="123456")
|
||||
def test_employee_can_login_with_seed_default_password() -> None:
|
||||
with build_session() as db:
|
||||
employee = EmployeeService(db).list_employees()[0]
|
||||
result = AuthService(db).login(
|
||||
LoginRequest(username=employee.email, password="123456")
|
||||
)
|
||||
|
||||
assert result.ok is True
|
||||
@@ -36,10 +36,23 @@ def test_employee_can_login_with_seed_default_password() -> None:
|
||||
assert result.user.position == employee.position
|
||||
assert result.user.grade == employee.grade
|
||||
assert result.user.roleCodes
|
||||
assert result.user.isAdmin is False
|
||||
|
||||
|
||||
def test_admin_can_login_with_database_password() -> None:
|
||||
assert result.user.isAdmin is False
|
||||
|
||||
|
||||
def test_current_user_snapshot_refreshes_employee_position() -> None:
|
||||
with build_session() as db:
|
||||
employee = EmployeeService(db).list_employees()[0]
|
||||
result = AuthService(db).get_user_snapshot(employee.email)
|
||||
|
||||
assert result is not None
|
||||
assert result.username == employee.email
|
||||
assert result.name == employee.name
|
||||
assert result.department == employee.department
|
||||
assert result.position == employee.position
|
||||
assert result.grade == employee.grade
|
||||
|
||||
|
||||
def test_admin_can_login_with_database_password() -> None:
|
||||
with build_session() as db:
|
||||
settings_service = SettingsService(db)
|
||||
payload = settings_service.get_settings_snapshot().model_dump()
|
||||
|
||||
159
server/tests/test_digital_employee_dashboard_service.py
Normal file
159
server/tests/test_digital_employee_dashboard_service.py
Normal file
@@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.agent_run import AgentRun, AgentToolCall
|
||||
from app.services.digital_employee_dashboard import DigitalEmployeeDashboardService
|
||||
|
||||
|
||||
def build_session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
return session_factory()
|
||||
|
||||
|
||||
def test_digital_employee_dashboard_aggregates_daily_work_from_agent_runs() -> None:
|
||||
now = datetime.now(UTC)
|
||||
|
||||
with build_session() as db:
|
||||
db.add_all(
|
||||
[
|
||||
AgentRun(
|
||||
run_id="run-digital-risk-001",
|
||||
agent="hermes",
|
||||
source="schedule",
|
||||
user_id="system",
|
||||
status="succeeded",
|
||||
route_json={"task_code": "task.hermes.global_risk_scan"},
|
||||
result_summary="财务风险图谱巡检完成。",
|
||||
started_at=now - timedelta(hours=4),
|
||||
finished_at=now - timedelta(hours=4, minutes=-3),
|
||||
tool_calls=[
|
||||
AgentToolCall(
|
||||
run_id="run-digital-risk-001",
|
||||
tool_type="rule_engine",
|
||||
tool_name="digital_employee.financial_risk_graph.scan",
|
||||
request_json={"task_type": "global_risk_scan"},
|
||||
response_json={
|
||||
"scanned_claim_count": 18,
|
||||
"risk_observation_count": 3,
|
||||
},
|
||||
status="succeeded",
|
||||
duration_ms=1200,
|
||||
created_at=now - timedelta(hours=4),
|
||||
)
|
||||
],
|
||||
),
|
||||
AgentRun(
|
||||
run_id="run-digital-clue-001",
|
||||
agent="hermes",
|
||||
source="schedule",
|
||||
user_id="system",
|
||||
status="failed",
|
||||
route_json={"report_type": "risk_clue_collect"},
|
||||
result_summary="风险线索归集失败。",
|
||||
started_at=now - timedelta(hours=3),
|
||||
finished_at=now - timedelta(hours=3, minutes=-1),
|
||||
tool_calls=[
|
||||
AgentToolCall(
|
||||
run_id="run-digital-clue-001",
|
||||
tool_type="database",
|
||||
tool_name="digital_employee.risk_clue.collect",
|
||||
request_json={"task_type": "risk_clue_collect"},
|
||||
response_json={
|
||||
"fact_count": 12,
|
||||
"rule_hit_count": 5,
|
||||
"risk_clue_count": 2,
|
||||
},
|
||||
status="failed",
|
||||
duration_ms=800,
|
||||
error_message="collector failed",
|
||||
created_at=now - timedelta(hours=3),
|
||||
)
|
||||
],
|
||||
),
|
||||
AgentRun(
|
||||
run_id="run-digital-knowledge-001",
|
||||
agent="hermes",
|
||||
source="user_message",
|
||||
user_id="admin",
|
||||
status="running",
|
||||
route_json={
|
||||
"job_type": "knowledge_index_sync",
|
||||
"requested_document_ids": ["doc-1", "doc-2"],
|
||||
},
|
||||
result_summary="知识归纳任务已入队。",
|
||||
started_at=now - timedelta(hours=1),
|
||||
),
|
||||
AgentRun(
|
||||
run_id="run-user-001",
|
||||
agent="user_agent",
|
||||
source="user_message",
|
||||
user_id="employee",
|
||||
status="succeeded",
|
||||
result_summary="普通报销预审。",
|
||||
started_at=now - timedelta(hours=2),
|
||||
finished_at=now - timedelta(hours=2, minutes=-1),
|
||||
tool_calls=[
|
||||
AgentToolCall(
|
||||
run_id="run-user-001",
|
||||
tool_type="llm",
|
||||
tool_name="expense_claim.review",
|
||||
request_json={},
|
||||
response_json={},
|
||||
status="succeeded",
|
||||
duration_ms=500,
|
||||
created_at=now - timedelta(hours=2),
|
||||
)
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
dashboard = DigitalEmployeeDashboardService(db).build_dashboard(days=7)
|
||||
|
||||
assert dashboard.has_real_data is True
|
||||
assert dashboard.totals["totalRuns"] == 3
|
||||
assert dashboard.totals["successRuns"] == 1
|
||||
assert dashboard.totals["failedRuns"] == 1
|
||||
assert dashboard.totals["runningRuns"] == 1
|
||||
assert dashboard.totals["toolCalls"] == 2
|
||||
assert dashboard.totals["riskObservations"] == 3
|
||||
assert dashboard.totals["riskClues"] == 2
|
||||
assert dashboard.totals["knowledgeDocuments"] == 2
|
||||
assert dashboard.totals["businessOutputs"] == 7
|
||||
assert dashboard.totals["successRate"] == 33.3
|
||||
|
||||
category_counts = {item["name"]: item["count"] for item in dashboard.category_distribution}
|
||||
assert category_counts["评估"] == 1
|
||||
assert category_counts["升级"] == 1
|
||||
assert category_counts["整理"] == 1
|
||||
assert category_counts["积累"] == 0
|
||||
|
||||
task_names = {item["name"] for item in dashboard.task_distribution}
|
||||
assert task_names == {"财务风险图谱巡检", "风险线索归集", "知识制度整理"}
|
||||
|
||||
assert sum(item["total"] for item in dashboard.daily_work) == 3
|
||||
assert dashboard.recent_runs[0]["runId"] == "run-digital-knowledge-001"
|
||||
assert dashboard.recent_runs[0]["statusLabel"] == "运行中"
|
||||
|
||||
|
||||
def test_digital_employee_dashboard_keeps_empty_payload_without_fake_data() -> None:
|
||||
with build_session() as db:
|
||||
dashboard = DigitalEmployeeDashboardService(db).build_dashboard(days=7)
|
||||
|
||||
assert dashboard.has_real_data is False
|
||||
assert dashboard.totals["totalRuns"] == 0
|
||||
assert dashboard.daily_work
|
||||
assert dashboard.task_distribution == []
|
||||
130
server/tests/test_digital_employee_skill_catalog.py
Normal file
130
server/tests/test_digital_employee_skill_catalog.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.core.agent_enums import AgentName
|
||||
from app.services.agent_foundation_constants import (
|
||||
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_SKILL_CATEGORIES,
|
||||
DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP,
|
||||
)
|
||||
from app.services.agent_foundation_digital_employee_tasks import (
|
||||
AgentFoundationDigitalEmployeeTaskMixin,
|
||||
DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY,
|
||||
)
|
||||
|
||||
|
||||
class _CatalogHarness(AgentFoundationDigitalEmployeeTaskMixin):
|
||||
def _digital_employee_task_config(self, code: str, cron: str) -> dict[str, Any]:
|
||||
return {
|
||||
"cron": cron,
|
||||
"agent": AgentName.HERMES.value,
|
||||
"task_type": code.replace("task.hermes.", "").replace(".", "_"),
|
||||
"skill_category": DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP.get(code, "整理"),
|
||||
"skill_category_options": list(DIGITAL_EMPLOYEE_SKILL_CATEGORIES),
|
||||
}
|
||||
|
||||
def _read_domain_skill_markdown(
|
||||
self,
|
||||
skill_name: str,
|
||||
fallback_lines: list[str],
|
||||
) -> str:
|
||||
skill_path = _skill_root() / skill_name / "SKILL.md"
|
||||
if skill_path.exists():
|
||||
return skill_path.read_text(encoding="utf-8")
|
||||
return "\n".join(fallback_lines)
|
||||
|
||||
def _financial_risk_graph_scan_skill_markdown(self) -> str:
|
||||
return self._read_domain_skill_markdown("financial-risk-graph-scanner", [])
|
||||
|
||||
def _employee_behavior_profile_scan_skill_markdown(self) -> str:
|
||||
return self._read_domain_skill_markdown("employee-behavior-profile-scanner", [])
|
||||
|
||||
def _risk_rule_discovery_skill_markdown(self) -> str:
|
||||
return self._read_domain_skill_markdown("risk-rule-discovery", [])
|
||||
|
||||
def _risk_clue_collector_skill_markdown(self) -> str:
|
||||
return self._read_domain_skill_markdown("risk-clue-collector", [])
|
||||
|
||||
|
||||
def test_digital_employee_skill_catalog_has_complete_categories_and_packages() -> None:
|
||||
harness = _CatalogHarness()
|
||||
specs = harness._runtime_digital_employee_task_specs()
|
||||
codes = [str(spec["code"]) for spec in specs]
|
||||
categories = [str(spec["skill_category"]) for spec in specs]
|
||||
skill_names = [str(dict(spec["config"])["skill_name"]) for spec in specs]
|
||||
|
||||
assert len(specs) == 16
|
||||
assert len(set(codes)) == len(codes)
|
||||
assert set(categories) == set(DIGITAL_EMPLOYEE_SKILL_CATEGORIES)
|
||||
assert DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP[DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE] == "积累"
|
||||
assert len(set(codes + [DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE])) == 17
|
||||
|
||||
for skill_name in ["finance-policy-knowledge-organizer", *skill_names]:
|
||||
skill_file = _skill_root() / skill_name / "SKILL.md"
|
||||
assert skill_file.exists(), skill_name
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert f"name: {skill_name}" in content
|
||||
|
||||
|
||||
def test_digital_employee_runtime_specs_build_display_ready_config() -> None:
|
||||
harness = _CatalogHarness()
|
||||
forbidden_rule_execution = "执行" + "规则"
|
||||
|
||||
for spec in harness._runtime_digital_employee_task_specs():
|
||||
config = harness._build_runtime_digital_employee_config(spec)
|
||||
spec_config = dict(spec["config"])
|
||||
markdown = spec["markdown"]()
|
||||
|
||||
assert config["agent"] == AgentName.HERMES.value
|
||||
assert config["skill_category"] == spec["skill_category"]
|
||||
assert config["skill_category_options"] == list(DIGITAL_EMPLOYEE_SKILL_CATEGORIES)
|
||||
assert config["skill_name"] == spec_config["skill_name"]
|
||||
assert config["output_format"] == spec_config["output_format"]
|
||||
assert config["schedule"] == spec["cron"]
|
||||
assert config["cron_expression"] == spec["cron"]
|
||||
assert config["writes_rules"] is False
|
||||
assert isinstance(config["role_boundary"], str)
|
||||
assert config["role_boundary"] == DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY
|
||||
assert "主流程由外层智能体执行" in config["role_boundary"]
|
||||
assert forbidden_rule_execution not in config["role_boundary"]
|
||||
assert "human_review_required" in config["allowed_outputs"]
|
||||
assert f"name: {config['skill_name']}" in markdown
|
||||
assert str(spec["name"]) in markdown
|
||||
|
||||
|
||||
def test_digital_employee_skills_do_not_cross_rule_governance_boundary() -> None:
|
||||
harness = _CatalogHarness()
|
||||
forbidden_rule_execution = "执行" + "规则"
|
||||
specs = harness._runtime_digital_employee_task_specs()
|
||||
skill_names = {str(dict(spec["config"])["skill_name"]) for spec in specs}
|
||||
output_formats = {str(dict(spec["config"])["output_format"]) for spec in specs}
|
||||
text_contract = "\n".join(
|
||||
str(value)
|
||||
for spec in specs
|
||||
for value in (
|
||||
spec["name"],
|
||||
spec["description"],
|
||||
dict(spec["config"])["skill_name"],
|
||||
dict(spec["config"])["output_format"],
|
||||
dict(spec["config"])["role_boundary"],
|
||||
)
|
||||
)
|
||||
|
||||
assert "risk-clue-collector" in skill_names
|
||||
assert "rule-execution-case-organizer" in skill_names
|
||||
assert "policy-reference-gap-hinter" in skill_names
|
||||
assert "risk-rule-discovery" not in skill_names
|
||||
assert "risk-rule-template-organizer" not in skill_names
|
||||
assert "policy-gap-rule-optimizer" not in skill_names
|
||||
assert "candidate_risk_rules" not in output_formats
|
||||
assert "risk_rule_template_library" not in output_formats
|
||||
assert "policy_gap_rule_optimization_report" not in output_formats
|
||||
assert "auto_publish" not in text_contract
|
||||
assert forbidden_rule_execution not in text_contract
|
||||
|
||||
|
||||
def _skill_root() -> Path:
|
||||
return Path(__file__).resolve().parents[1] / "src" / "app" / "skills" / "domain"
|
||||
@@ -223,6 +223,26 @@ def test_service_scans_snapshots_and_filters_approval_scene() -> None:
|
||||
assert latest.radar.dimensions
|
||||
|
||||
|
||||
def test_service_resolves_latest_profile_by_employee_name_identifier() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
seed_profile_data(db)
|
||||
employee = db.get(Employee, "emp-main")
|
||||
assert employee is not None
|
||||
|
||||
latest = EmployeeBehaviorProfileService(db).get_latest_profile(
|
||||
employee_id=employee.name,
|
||||
scene="approval",
|
||||
claim_id="claim-main-1",
|
||||
window_days=90,
|
||||
expense_type_scope="travel",
|
||||
)
|
||||
|
||||
assert latest.employee_id == "emp-main"
|
||||
assert latest.empty_reason == ""
|
||||
assert {item.profile_type for item in latest.profiles} == {"expense", "process_quality"}
|
||||
|
||||
|
||||
def test_latest_profile_endpoint_returns_approval_payload() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
|
||||
380
server/tests/test_expense_claim_approval_routing.py
Normal file
380
server/tests/test_expense_claim_approval_routing.py
Normal file
@@ -0,0 +1,380 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.db.base import Base
|
||||
from app.models.budget import BudgetAllocation
|
||||
from app.models.employee import Employee
|
||||
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,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
FINANCE_APPROVAL_STAGE,
|
||||
)
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
|
||||
|
||||
def build_session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
return session_factory()
|
||||
|
||||
|
||||
def _seed_budget_monitor_role(db: Session) -> Role:
|
||||
role = Role(role_code="budget_monitor", name="预算管理员")
|
||||
db.add(role)
|
||||
db.flush()
|
||||
return role
|
||||
|
||||
|
||||
def _seed_budget_allocation(
|
||||
db: Session,
|
||||
*,
|
||||
department_id: str,
|
||||
department_name: str,
|
||||
amount: Decimal = Decimal("50000.00"),
|
||||
) -> None:
|
||||
db.add(
|
||||
BudgetAllocation(
|
||||
budget_no=f"BUD-ROUTE-{uuid.uuid4().hex[:8]}",
|
||||
fiscal_year=2026,
|
||||
period_type="quarter",
|
||||
period_key="2026Q2",
|
||||
department_id=department_id,
|
||||
department_name=department_name,
|
||||
cost_center=None,
|
||||
project_code=None,
|
||||
subject_code="travel",
|
||||
subject_name="差旅",
|
||||
original_amount=amount,
|
||||
adjusted_amount=Decimal("0.00"),
|
||||
status="active",
|
||||
warning_threshold=Decimal("80.00"),
|
||||
control_action="block",
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
|
||||
|
||||
def _seed_people(db: Session, *, suffix: str) -> tuple[OrganizationUnit, Employee, Employee, Employee]:
|
||||
budget_role = _seed_budget_monitor_role(db)
|
||||
department = OrganizationUnit(
|
||||
unit_code=f"ROUTE-{suffix}",
|
||||
name=f"动态路由部{suffix}",
|
||||
unit_type="department",
|
||||
)
|
||||
manager = Employee(
|
||||
employee_no=f"M-{suffix}",
|
||||
name=f"直属领导{suffix}",
|
||||
email=f"manager-{suffix}@example.com",
|
||||
organization_unit=department,
|
||||
)
|
||||
budget_manager = Employee(
|
||||
employee_no=f"B-{suffix}",
|
||||
name=f"预算管理员{suffix}",
|
||||
email=f"budget-{suffix}@example.com",
|
||||
grade="P8",
|
||||
organization_unit=department,
|
||||
roles=[budget_role],
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no=f"E-{suffix}",
|
||||
name=f"申请人{suffix}",
|
||||
email=f"employee-{suffix}@example.com",
|
||||
manager=manager,
|
||||
organization_unit=department,
|
||||
)
|
||||
db.add_all([department, manager, budget_manager, employee])
|
||||
db.flush()
|
||||
return department, manager, budget_manager, employee
|
||||
|
||||
|
||||
def test_low_risk_application_skips_budget_manager_and_generates_draft() -> None:
|
||||
with build_session() as db:
|
||||
department, manager, _budget_manager, employee = _seed_people(db, suffix="LOW-APP")
|
||||
_seed_budget_allocation(
|
||||
db,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260530-LOW-ROUTE",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
project_code=None,
|
||||
expense_type="travel_application",
|
||||
reason="客户现场沟通",
|
||||
location="上海",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
approved = ExpenseClaimService(db).approve_claim(
|
||||
claim.id,
|
||||
CurrentUserContext(
|
||||
username=manager.email,
|
||||
name=manager.name,
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
),
|
||||
opinion="业务必要,同意申请",
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == APPROVAL_DONE_STAGE
|
||||
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "approval_routing"
|
||||
and flag.get("requires_budget_review") is False
|
||||
and flag.get("route") == "approval_done"
|
||||
and flag.get("business_stage") == "expense_application"
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "manual_approval"
|
||||
and flag.get("next_approval_stage") == APPROVAL_DONE_STAGE
|
||||
and flag.get("route_decision", {}).get("requires_budget_review") is False
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_budget_warning_application_still_skips_budget_manager_when_not_over_budget() -> None:
|
||||
with build_session() as db:
|
||||
department, manager, _budget_manager, employee = _seed_people(db, suffix="WARN-APP")
|
||||
_seed_budget_allocation(
|
||||
db,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
amount=Decimal("10000.00"),
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260530-WARN-ROUTE",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
project_code=None,
|
||||
expense_type="travel_application",
|
||||
reason="客户现场支持",
|
||||
location="上海",
|
||||
amount=Decimal("8500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "budget_control",
|
||||
"event_type": "budget_warning",
|
||||
"severity": "medium",
|
||||
"label": "预算接近预警线",
|
||||
"message": "预算仍可承接,但审批后使用率将接近预警线。",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
approved = ExpenseClaimService(db).approve_claim(
|
||||
claim.id,
|
||||
CurrentUserContext(
|
||||
username=manager.email,
|
||||
name=manager.name,
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
),
|
||||
opinion="业务必要,同意申请。",
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == APPROVAL_DONE_STAGE
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "approval_routing"
|
||||
and flag.get("requires_budget_review") is False
|
||||
and flag.get("route") == "approval_done"
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
assert not any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "manual_approval"
|
||||
and flag.get("next_approval_stage") == BUDGET_MANAGER_APPROVAL_STAGE
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_application_route_ignores_reimbursement_stage_current_risks() -> None:
|
||||
with build_session() as db:
|
||||
department, manager, _budget_manager, employee = _seed_people(db, suffix="MIXED-STAGE")
|
||||
_seed_budget_allocation(
|
||||
db,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260530-MIXED-STAGE",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
project_code=None,
|
||||
expense_type="travel_application",
|
||||
reason="客户现场沟通",
|
||||
location="上海",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "报销票据风险",
|
||||
"message": "报销票据城市与行程城市不一致。",
|
||||
"business_stage": "reimbursement",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
approved = ExpenseClaimService(db).approve_claim(
|
||||
claim.id,
|
||||
CurrentUserContext(
|
||||
username=manager.email,
|
||||
name=manager.name,
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
),
|
||||
opinion="业务必要,同意申请。",
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == APPROVAL_DONE_STAGE
|
||||
route_flag = [
|
||||
flag
|
||||
for flag in approved.risk_flags_json
|
||||
if isinstance(flag, dict) and flag.get("source") == "approval_routing"
|
||||
][0]
|
||||
assert route_flag["requires_budget_review"] is False
|
||||
assert route_flag["current_risk_count"] == 0
|
||||
assert route_flag["business_stage"] == "expense_application"
|
||||
|
||||
|
||||
def test_risky_reimbursement_routes_to_budget_then_finance() -> None:
|
||||
with build_session() as db:
|
||||
department, manager, budget_manager, employee = _seed_people(db, suffix="RISK-CLAIM")
|
||||
_seed_budget_allocation(
|
||||
db,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
claim_no="RE-20260530-RISK-ROUTE",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
project_code=None,
|
||||
expense_type="travel",
|
||||
reason="客户现场沟通",
|
||||
location="上海",
|
||||
amount=Decimal("600.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "行程城市异常",
|
||||
"message": "票据城市与申报目的地不一致",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
service = ExpenseClaimService(db)
|
||||
|
||||
routed = service.approve_claim(
|
||||
claim.id,
|
||||
CurrentUserContext(
|
||||
username=manager.email,
|
||||
name=manager.name,
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
),
|
||||
opinion="业务属实,同意报账",
|
||||
)
|
||||
|
||||
assert routed is not None
|
||||
assert routed.status == "submitted"
|
||||
assert routed.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "approval_routing"
|
||||
and flag.get("requires_budget_review") is True
|
||||
and flag.get("route") == "budget_manager"
|
||||
and any("行程城市异常" in item for item in flag.get("reasons", []))
|
||||
for flag in routed.risk_flags_json
|
||||
)
|
||||
|
||||
budget_approved = service.approve_claim(
|
||||
claim.id,
|
||||
CurrentUserContext(
|
||||
username=budget_manager.email,
|
||||
name=budget_manager.name,
|
||||
role_codes=["budget_monitor"],
|
||||
is_admin=False,
|
||||
),
|
||||
opinion="预算影响已复核,同意进入财务审批",
|
||||
)
|
||||
|
||||
assert budget_approved is not None
|
||||
assert budget_approved.status == "submitted"
|
||||
assert budget_approved.approval_stage == FINANCE_APPROVAL_STAGE
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "budget_approval"
|
||||
and flag.get("event_type") == "expense_claim_budget_approval"
|
||||
and flag.get("next_approval_stage") == FINANCE_APPROVAL_STAGE
|
||||
for flag in budget_approved.risk_flags_json
|
||||
)
|
||||
299
server/tests/test_expense_claim_platform_risk_stage.py
Normal file
299
server/tests/test_expense_claim_platform_risk_stage.py
Normal file
@@ -0,0 +1,299 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
||||
from app.db.base import Base
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY
|
||||
|
||||
|
||||
def build_session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
return session_factory()
|
||||
|
||||
|
||||
def _build_rule_payload(
|
||||
*,
|
||||
rule_code: str,
|
||||
name: str,
|
||||
business_stage: str,
|
||||
message: str,
|
||||
expense_category: str = "travel",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": "2.0",
|
||||
"rule_code": rule_code,
|
||||
"name": name,
|
||||
"evaluator": "template_rule",
|
||||
"enabled": True,
|
||||
"applies_to": {
|
||||
"domains": [AgentAssetDomain.EXPENSE.value],
|
||||
"business_stages": [business_stage],
|
||||
"expense_categories": [expense_category],
|
||||
},
|
||||
"template_key": COMPOSITE_RULE_TEMPLATE_KEY,
|
||||
"params": {
|
||||
"template_key": COMPOSITE_RULE_TEMPLATE_KEY,
|
||||
"field_keys": ["claim.reason"],
|
||||
"conditions": [
|
||||
{
|
||||
"id": "missing_exception_reason",
|
||||
"operator": "not_contains_any",
|
||||
"fields": ["claim.reason"],
|
||||
"keywords": ["专项审批"],
|
||||
}
|
||||
],
|
||||
"hit_logic": {"all": ["missing_exception_reason"]},
|
||||
"message_template": message,
|
||||
},
|
||||
"outcomes": {"fail": {"severity": "high", "action": "manual_review"}},
|
||||
}
|
||||
|
||||
|
||||
def _add_active_rule_asset(
|
||||
db: Session,
|
||||
manager: AgentAssetRuleLibraryManager,
|
||||
*,
|
||||
rule_code: str,
|
||||
business_stage: str,
|
||||
message: str,
|
||||
expense_category: str = "travel",
|
||||
) -> None:
|
||||
file_name = f"{rule_code}.json"
|
||||
payload = _build_rule_payload(
|
||||
rule_code=rule_code,
|
||||
name=message,
|
||||
business_stage=business_stage,
|
||||
message=message,
|
||||
expense_category=expense_category,
|
||||
)
|
||||
manager.write_rule_library_json(
|
||||
library=RISK_RULES_LIBRARY,
|
||||
file_name=file_name,
|
||||
payload=payload,
|
||||
)
|
||||
db.add(
|
||||
AgentAsset(
|
||||
asset_type=AgentAssetType.RULE.value,
|
||||
code=rule_code,
|
||||
name=message,
|
||||
description="",
|
||||
domain=AgentAssetDomain.EXPENSE.value,
|
||||
scenario_json=["差旅费"],
|
||||
owner="pytest",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
published_version="v1.0.0",
|
||||
config_json={
|
||||
"detail_mode": "json_risk",
|
||||
"rule_library": RISK_RULES_LIBRARY,
|
||||
"rule_document": {"file_name": file_name},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _build_claim(*, claim_no: str, expense_type: str, status: str = "draft") -> ExpenseClaim:
|
||||
return ExpenseClaim(
|
||||
claim_no=claim_no,
|
||||
employee_name="张三",
|
||||
department_name="研发部",
|
||||
project_code=None,
|
||||
expense_type=expense_type,
|
||||
reason="去上海处理客户现场问题",
|
||||
location="上海",
|
||||
amount=Decimal("1200.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 30, tzinfo=UTC),
|
||||
submitted_at=None,
|
||||
status=status,
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
|
||||
|
||||
def _patch_rule_manager(monkeypatch, manager: AgentAssetRuleLibraryManager) -> None:
|
||||
from app.services import expense_claim_platform_risk
|
||||
|
||||
monkeypatch.setattr(
|
||||
expense_claim_platform_risk,
|
||||
"AgentAssetRuleLibraryManager",
|
||||
lambda: manager,
|
||||
)
|
||||
|
||||
|
||||
def test_platform_risk_rules_are_filtered_by_business_stage_and_category(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
with build_session() as db:
|
||||
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||
_patch_rule_manager(monkeypatch, manager)
|
||||
_add_active_rule_asset(
|
||||
db,
|
||||
manager,
|
||||
rule_code="application.stage.rule",
|
||||
business_stage="expense_application",
|
||||
message="申请环节规则命中",
|
||||
)
|
||||
_add_active_rule_asset(
|
||||
db,
|
||||
manager,
|
||||
rule_code="reimbursement.stage.rule",
|
||||
business_stage="reimbursement",
|
||||
message="报账环节规则命中",
|
||||
)
|
||||
_add_active_rule_asset(
|
||||
db,
|
||||
manager,
|
||||
rule_code="office.application.rule",
|
||||
business_stage="expense_application",
|
||||
message="办公申请规则不应命中差旅",
|
||||
expense_category="office",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
application_claim = _build_claim(
|
||||
claim_no="APP-TEST-001",
|
||||
expense_type="travel_application",
|
||||
)
|
||||
reimbursement_claim = _build_claim(
|
||||
claim_no="CLM-TEST-001",
|
||||
expense_type="travel",
|
||||
)
|
||||
|
||||
application_review = service.evaluate_platform_risk_rules(
|
||||
application_claim,
|
||||
business_stage="expense_application",
|
||||
)
|
||||
reimbursement_review = service.evaluate_platform_risk_rules(
|
||||
reimbursement_claim,
|
||||
business_stage="reimbursement",
|
||||
)
|
||||
|
||||
assert [flag["rule_code"] for flag in application_review["flags"]] == [
|
||||
"application.stage.rule"
|
||||
]
|
||||
assert application_review["flags"][0]["message"] == "申请环节规则命中"
|
||||
assert application_review["flags"][0]["business_stage"] == "expense_application"
|
||||
assert application_review["flags"][0]["visibility_scope"] == "leader"
|
||||
assert application_review["flags"][0]["actionability"] == "review_decision"
|
||||
assert [flag["rule_code"] for flag in reimbursement_review["flags"]] == [
|
||||
"reimbursement.stage.rule"
|
||||
]
|
||||
assert reimbursement_review["flags"][0]["message"] == "报账环节规则命中"
|
||||
assert reimbursement_review["flags"][0]["business_stage"] == "reimbursement"
|
||||
assert reimbursement_review["flags"][0]["visibility_scope"] == "submitter"
|
||||
assert reimbursement_review["flags"][0]["actionability"] == "fixable_by_submitter"
|
||||
|
||||
|
||||
def test_expense_application_pre_review_runs_stage_rules(tmp_path, monkeypatch) -> None:
|
||||
with build_session() as db:
|
||||
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||
_patch_rule_manager(monkeypatch, manager)
|
||||
_add_active_rule_asset(
|
||||
db,
|
||||
manager,
|
||||
rule_code="application.pre.review.rule",
|
||||
business_stage="expense_application",
|
||||
message="申请预审规则命中",
|
||||
)
|
||||
claim = _build_claim(claim_no="APP-TEST-002", expense_type="travel_application")
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
current_user = CurrentUserContext(
|
||||
username="张三",
|
||||
name="张三",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
department_name="研发部",
|
||||
)
|
||||
reviewed = ExpenseClaimService(db).pre_review_claim(claim.id, current_user)
|
||||
|
||||
assert reviewed is not None
|
||||
rule_flags = [
|
||||
flag
|
||||
for flag in reviewed.risk_flags_json
|
||||
if isinstance(flag, dict)
|
||||
and flag.get("rule_code") == "application.pre.review.rule"
|
||||
]
|
||||
assert len(rule_flags) == 1
|
||||
assert rule_flags[0]["message"] == "申请预审规则命中"
|
||||
assert rule_flags[0]["business_stage"] == "expense_application"
|
||||
assert rule_flags[0]["risk_domain"] == "policy"
|
||||
assert rule_flags[0]["visibility_scope"] == "leader"
|
||||
ai_pre_review = [
|
||||
flag
|
||||
for flag in reviewed.risk_flags_json
|
||||
if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
|
||||
][0]
|
||||
assert ai_pre_review["passed"] is False
|
||||
assert ai_pre_review["blocking_risk_count"] == 1
|
||||
assert ai_pre_review["business_stage"] == "expense_application"
|
||||
|
||||
|
||||
def test_reimbursement_item_sync_persists_rule_center_risk_preview(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
with build_session() as db:
|
||||
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||
_patch_rule_manager(monkeypatch, manager)
|
||||
_add_active_rule_asset(
|
||||
db,
|
||||
manager,
|
||||
rule_code="reimbursement.preview.rule",
|
||||
business_stage="reimbursement",
|
||||
message="报销风险预判命中",
|
||||
)
|
||||
claim = _build_claim(claim_no="RE-TEST-001", expense_type="travel")
|
||||
claim.invoice_count = 1
|
||||
claim.items = [
|
||||
ExpenseClaimItem(
|
||||
item_date=date(2026, 5, 30),
|
||||
item_type="travel",
|
||||
item_reason="客户现场支持",
|
||||
item_location="上海",
|
||||
item_amount=Decimal("1200.00"),
|
||||
invoice_id="ticket.pdf",
|
||||
)
|
||||
]
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
service._sync_claim_from_items(claim)
|
||||
|
||||
rule_flags = [
|
||||
flag
|
||||
for flag in claim.risk_flags_json
|
||||
if isinstance(flag, dict)
|
||||
and flag.get("rule_code") == "reimbursement.preview.rule"
|
||||
]
|
||||
assert len(rule_flags) == 1
|
||||
assert rule_flags[0]["message"] == "报销风险预判命中"
|
||||
assert rule_flags[0]["severity"] == "high"
|
||||
assert rule_flags[0]["business_stage"] == "reimbursement"
|
||||
assert rule_flags[0]["visibility_scope"] == "submitter"
|
||||
assert rule_flags[0]["actionability"] == "fixable_by_submitter"
|
||||
@@ -24,6 +24,11 @@ from app.services.agent_conversations import AgentConversationService
|
||||
from app.services.budget import BudgetService
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPROVAL_DONE_STAGE,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
)
|
||||
from app.services.ontology import SemanticOntologyService
|
||||
from app.services.ocr import OcrService
|
||||
|
||||
@@ -120,6 +125,16 @@ def _seed_budget_monitor_role(db: Session) -> Role:
|
||||
return role
|
||||
|
||||
|
||||
def _seed_executive_role(db: Session) -> Role:
|
||||
role = db.query(Role).filter(Role.role_code == "executive").one_or_none()
|
||||
if role is not None:
|
||||
return role
|
||||
role = Role(role_code="executive", name="Senior finance")
|
||||
db.add(role)
|
||||
db.flush()
|
||||
return role
|
||||
|
||||
|
||||
def test_validate_claim_for_submission_allows_office_claim_without_location() -> None:
|
||||
service = ExpenseClaimService.__new__(ExpenseClaimService)
|
||||
claim = build_claim(expense_type="office", location="待补充")
|
||||
@@ -270,6 +285,63 @@ def test_save_draft_persists_user_changed_expense_category() -> None:
|
||||
assert claim.items[0].item_type == "office"
|
||||
|
||||
|
||||
def test_upsert_draft_from_ontology_persists_linked_application_context() -> None:
|
||||
user_id = "linked-application-context@example.com"
|
||||
message = "业务发生时间:2026-05-20,去北京支撑国网部署,火车票354元,申请差旅费报销"
|
||||
|
||||
with build_session() as db:
|
||||
employee = Employee(
|
||||
employee_no="E5103",
|
||||
name="关联员工",
|
||||
email=user_id,
|
||||
)
|
||||
db.add(employee)
|
||||
db.commit()
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=message,
|
||||
user_id=user_id,
|
||||
)
|
||||
)
|
||||
|
||||
result = ExpenseClaimService(db).upsert_draft_from_ontology(
|
||||
run_id=ontology.run_id,
|
||||
user_id=user_id,
|
||||
message=message,
|
||||
ontology=ontology,
|
||||
context_json={
|
||||
"name": "关联员工",
|
||||
"user_input_text": message,
|
||||
"review_action": "save_draft",
|
||||
"review_form_values": {
|
||||
"expense_type": "差旅费",
|
||||
"amount": "354元",
|
||||
"application_claim_id": "application-linked-1",
|
||||
"application_claim_no": "AP-202605-001",
|
||||
"application_reason": "支撑国网仿生产环境部署",
|
||||
"application_location": "北京",
|
||||
"application_amount": "3000",
|
||||
},
|
||||
"expense_scene_selection": {
|
||||
"expense_type": "travel",
|
||||
"application_claim_id": "application-linked-1",
|
||||
"application_claim_no": "AP-202605-001",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
claim = db.get(ExpenseClaim, result["claim_id"])
|
||||
assert claim is not None
|
||||
link_flag = next(
|
||||
flag
|
||||
for flag in claim.risk_flags_json
|
||||
if isinstance(flag, dict) and flag.get("source") == "application_link"
|
||||
)
|
||||
assert link_flag["application_claim_no"] == "AP-202605-001"
|
||||
assert link_flag["application_claim_id"] == "application-linked-1"
|
||||
assert link_flag["application_detail"]["application_reason"] == "支撑国网仿生产环境部署"
|
||||
|
||||
|
||||
def test_unsaved_conversation_expires_after_retention_but_saved_conversation_stays() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentConversationService(db)
|
||||
@@ -1446,6 +1518,98 @@ def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_p
|
||||
assert not any("用途字段" in point for point in uploaded_meta["analysis"]["points"])
|
||||
|
||||
|
||||
def test_upload_attachment_response_includes_refreshed_rule_center_risk_flags(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-1",
|
||||
name="张三",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
def fake_recognize(
|
||||
self,
|
||||
files: list[tuple[str, bytes, str | None]],
|
||||
) -> OcrRecognizeBatchRead:
|
||||
return OcrRecognizeBatchRead(
|
||||
total_file_count=1,
|
||||
success_count=1,
|
||||
documents=[
|
||||
OcrRecognizeDocumentRead(
|
||||
filename="train-ticket.png",
|
||||
media_type="image/png",
|
||||
text="中国铁路电子客票 武汉-上海 2026-02-20 票价354元",
|
||||
summary="铁路电子客票,武汉至上海,票价 354 元。",
|
||||
avg_score=0.98,
|
||||
line_count=1,
|
||||
page_count=1,
|
||||
document_type="train_ticket",
|
||||
document_type_label="火车/高铁票",
|
||||
scene_code="travel",
|
||||
scene_label="差旅费",
|
||||
document_fields=[
|
||||
{"key": "route", "label": "行程", "value": "武汉-上海"},
|
||||
{"key": "trip_date", "label": "行程日期", "value": "2026-02-20"},
|
||||
{"key": "fare", "label": "票价", "value": "354元"},
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def fake_evaluate_platform_risk_rules(self, claim, **kwargs):
|
||||
assert kwargs.get("business_stage") == "reimbursement"
|
||||
return {
|
||||
"flags": [
|
||||
{
|
||||
"source": "submission_review",
|
||||
"hit_source": "rule_center",
|
||||
"rule_type": "risk",
|
||||
"rule_code": "risk.test.upload_preview",
|
||||
"severity": "high",
|
||||
"message": "测试规则命中",
|
||||
"business_stage": "reimbursement",
|
||||
"risk_domain": "invoice",
|
||||
"visibility_scope": "submitter",
|
||||
"actionability": "fixable_by_submitter",
|
||||
}
|
||||
],
|
||||
"blocking_reasons": ["测试规则命中"],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
|
||||
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
||||
monkeypatch.setattr(
|
||||
ExpenseClaimService,
|
||||
"evaluate_platform_risk_rules",
|
||||
fake_evaluate_platform_risk_rules,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
claim = build_claim(expense_type="travel", location="北京")
|
||||
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="train-ticket.png",
|
||||
content=b"fake-image-bytes",
|
||||
media_type="image/png",
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
assert payload is not None
|
||||
assert any(
|
||||
flag.get("rule_code") == "risk.test.upload_preview"
|
||||
for flag in payload["claim_risk_flags"]
|
||||
)
|
||||
db.refresh(claim)
|
||||
assert payload["claim_risk_flags"] == claim.risk_flags_json
|
||||
|
||||
|
||||
def test_upload_hotel_attachment_audits_date_like_amount(monkeypatch, tmp_path) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-1",
|
||||
@@ -1962,6 +2126,56 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
|
||||
assert submitted.submitted_at is not None
|
||||
|
||||
|
||||
def test_pre_review_claim_records_ai_result_without_submitting() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-pre-review@example.com",
|
||||
name="张三",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="E7050",
|
||||
name="李经理",
|
||||
email="manager-pre-review@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E7051",
|
||||
name="张三",
|
||||
email="emp-pre-review@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
claim = build_claim(expense_type="transport", location="上海")
|
||||
claim.employee = employee
|
||||
claim.employee_id = employee.id
|
||||
claim.items[0].invoice_id = "taxi-ticket.png"
|
||||
claim.risk_flags_json = [
|
||||
{
|
||||
"source": "manual_risk",
|
||||
"severity": "high",
|
||||
"label": "票据风险",
|
||||
"message": "票据金额与行程不匹配。",
|
||||
}
|
||||
]
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
|
||||
reviewed = ExpenseClaimService(db).pre_review_claim(claim.id, current_user)
|
||||
|
||||
assert reviewed is not None
|
||||
assert reviewed.status == "draft"
|
||||
assert reviewed.approval_stage == "AI预审"
|
||||
assert reviewed.submitted_at is None
|
||||
pre_review_flag = next(
|
||||
flag
|
||||
for flag in reviewed.risk_flags_json
|
||||
if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
|
||||
)
|
||||
assert pre_review_flag["status"] == "failed"
|
||||
assert pre_review_flag["next_action"] == "risk_explanation_required"
|
||||
|
||||
|
||||
def test_submit_claim_allows_returned_claim_to_be_resubmitted() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-submit@example.com",
|
||||
@@ -3163,7 +3377,7 @@ def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "submission_review",
|
||||
"source": "platform_risk",
|
||||
"severity": "medium",
|
||||
"message": "旧 AI 预审提示不应保留到申请单提交结果。",
|
||||
}
|
||||
@@ -3423,6 +3637,12 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
|
||||
"transport_mode": "高铁",
|
||||
"amount": "12000.00",
|
||||
},
|
||||
},
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "申请风险复核",
|
||||
"message": "申请金额和行程安排需要预算管理者二次确认。",
|
||||
}
|
||||
],
|
||||
)
|
||||
@@ -3510,6 +3730,273 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
|
||||
)
|
||||
|
||||
|
||||
def test_application_routes_to_department_p8_executive_with_approver_name() -> None:
|
||||
manager_user = CurrentUserContext(
|
||||
username="manager-executive-route@example.com",
|
||||
name="Manager",
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
)
|
||||
budget_user = CurrentUserContext(
|
||||
username="p8-executive-route@example.com",
|
||||
name="P8 Executive",
|
||||
role_codes=["executive"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
executive_role = _seed_executive_role(db)
|
||||
department = OrganizationUnit(
|
||||
unit_code="DELIVERY-EXECUTIVE-ROUTE",
|
||||
name="Engineering",
|
||||
unit_type="department",
|
||||
)
|
||||
manager = Employee(
|
||||
employee_no="E-EXEC-ROUTE-MGR",
|
||||
name="Manager",
|
||||
email="manager-executive-route@example.com",
|
||||
organization_unit=department,
|
||||
)
|
||||
budget_manager = Employee(
|
||||
employee_no="E-EXEC-ROUTE-P8",
|
||||
name="P8 Executive",
|
||||
email="p8-executive-route@example.com",
|
||||
grade="P8",
|
||||
organization_unit=department,
|
||||
roles=[executive_role],
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E-EXEC-ROUTE-APP",
|
||||
name="Applicant",
|
||||
email="applicant-executive-route@example.com",
|
||||
manager=manager,
|
||||
organization_unit=department,
|
||||
)
|
||||
db.add_all([department, manager, budget_manager, employee])
|
||||
db.flush()
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260531-EXEC-ROUTE",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel_application",
|
||||
reason="Production deployment support",
|
||||
location="Beijing",
|
||||
amount=Decimal("12000.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 31, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 31, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "Route risk",
|
||||
"message": "Application requires budget confirmation.",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
routed = ExpenseClaimService(db).approve_claim(
|
||||
claim_id,
|
||||
manager_user,
|
||||
opinion="Approved by direct manager.",
|
||||
)
|
||||
|
||||
assert routed is not None
|
||||
assert routed.status == "submitted"
|
||||
assert routed.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
|
||||
assert getattr(routed, "budget_approver_name", "") == "P8 Executive"
|
||||
assert getattr(routed, "budget_approver_grade", "") == "P8"
|
||||
assert getattr(routed, "budget_approver_role_code", "") == "executive"
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "manual_approval"
|
||||
and flag.get("next_approval_stage") == BUDGET_MANAGER_APPROVAL_STAGE
|
||||
and flag.get("next_approver_name") == "P8 Executive"
|
||||
and flag.get("next_approver_grade") == "P8"
|
||||
and flag.get("next_approver_role_code") == "executive"
|
||||
for flag in routed.risk_flags_json
|
||||
)
|
||||
|
||||
approved = ExpenseClaimService(db).approve_claim(
|
||||
claim_id,
|
||||
budget_user,
|
||||
opinion="Budget confirmed.",
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == APPROVAL_DONE_STAGE
|
||||
|
||||
|
||||
def test_direct_manager_cannot_route_application_to_missing_budget_approver() -> None:
|
||||
manager_user = CurrentUserContext(
|
||||
username="manager-missing-budget@example.com",
|
||||
name="Manager",
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
department = OrganizationUnit(
|
||||
unit_code="DELIVERY-MISSING-BUDGET",
|
||||
name="Engineering",
|
||||
unit_type="department",
|
||||
)
|
||||
manager = Employee(
|
||||
employee_no="E-MISSING-BUDGET-MGR",
|
||||
name="Manager",
|
||||
email="manager-missing-budget@example.com",
|
||||
organization_unit=department,
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E-MISSING-BUDGET-APP",
|
||||
name="Applicant",
|
||||
email="applicant-missing-budget@example.com",
|
||||
manager=manager,
|
||||
organization_unit=department,
|
||||
)
|
||||
db.add_all([department, manager, employee])
|
||||
db.flush()
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260531-MISSING-BUDGET",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel_application",
|
||||
reason="Production deployment support",
|
||||
location="Beijing",
|
||||
amount=Decimal("12000.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 31, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 31, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "Route risk",
|
||||
"message": "Application requires budget confirmation.",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
with pytest.raises(ValueError, match="未找到同部门 P8 预算审批人"):
|
||||
ExpenseClaimService(db).approve_claim(
|
||||
claim_id,
|
||||
manager_user,
|
||||
opinion="Approved by direct manager.",
|
||||
)
|
||||
|
||||
db.refresh(claim)
|
||||
assert claim.status == "submitted"
|
||||
assert claim.approval_stage == DIRECT_MANAGER_APPROVAL_STAGE
|
||||
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
|
||||
|
||||
|
||||
def test_direct_manager_p8_executive_completes_application_without_duplicate_budget_approval() -> None:
|
||||
manager_user = CurrentUserContext(
|
||||
username="manager-executive-merged@example.com",
|
||||
name="P8 Manager",
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
executive_role = _seed_executive_role(db)
|
||||
department = OrganizationUnit(
|
||||
unit_code="DELIVERY-EXECUTIVE-MERGED",
|
||||
name="Engineering",
|
||||
unit_type="department",
|
||||
)
|
||||
manager = Employee(
|
||||
employee_no="E-EXEC-MERGED-MGR",
|
||||
name="P8 Manager",
|
||||
email="manager-executive-merged@example.com",
|
||||
grade="P8",
|
||||
organization_unit=department,
|
||||
roles=[executive_role],
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E-EXEC-MERGED-APP",
|
||||
name="Applicant",
|
||||
email="applicant-executive-merged@example.com",
|
||||
manager=manager,
|
||||
organization_unit=department,
|
||||
)
|
||||
db.add_all([department, manager, employee])
|
||||
db.flush()
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260531-EXEC-MERGED",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel_application",
|
||||
reason="Production deployment support",
|
||||
location="Beijing",
|
||||
amount=Decimal("12000.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 31, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 31, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "Route risk",
|
||||
"message": "Application requires budget confirmation.",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
approved = ExpenseClaimService(db).approve_claim(
|
||||
claim_id,
|
||||
manager_user,
|
||||
opinion="Approved by direct manager and budget owner.",
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == APPROVAL_DONE_STAGE
|
||||
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1
|
||||
assert not any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("next_approval_stage") == BUDGET_MANAGER_APPROVAL_STAGE
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
assert any(
|
||||
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("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
|
||||
)
|
||||
|
||||
|
||||
def test_direct_manager_budget_monitor_completes_application_claim_without_duplicate_budget_approval() -> None:
|
||||
manager_user = CurrentUserContext(
|
||||
username="manager-budget-monitor-application@example.com",
|
||||
@@ -3559,7 +4046,14 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
|
||||
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "申请风险复核",
|
||||
"message": "申请金额和行程安排需要预算管理者二次确认。",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
@@ -3590,7 +4084,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
|
||||
and flag.get("next_status") == "approved"
|
||||
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_monitor"
|
||||
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
|
||||
@@ -3775,7 +4269,14 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
|
||||
submitted_at=None,
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[],
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "platform_risk",
|
||||
"severity": "high",
|
||||
"label": "申请风险复核",
|
||||
"message": "申请金额和行程安排需要预算管理者二次确认。",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
@@ -3806,6 +4307,23 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
|
||||
for flag in generated_draft.risk_flags_json
|
||||
)
|
||||
|
||||
deleted = service.delete_claim(
|
||||
generated_draft.id,
|
||||
CurrentUserContext(
|
||||
username="browser-session-user",
|
||||
name="",
|
||||
role_codes=["user"],
|
||||
is_admin=False,
|
||||
employee_no="E-BUDGET-APP",
|
||||
),
|
||||
)
|
||||
db.refresh(reservation)
|
||||
|
||||
assert deleted is not None
|
||||
assert db.get(ExpenseClaim, generated_draft.id) is None
|
||||
assert reservation.source_status == "released"
|
||||
assert reservation.released_amount == Decimal("12000.00")
|
||||
|
||||
|
||||
def test_direct_manager_approval_defaults_blank_opinion_to_agree() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
@@ -4554,7 +5072,7 @@ def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applica
|
||||
is_admin=False,
|
||||
)
|
||||
p8_without_budget_role = CurrentUserContext(
|
||||
username="budget-p8-list@example.com",
|
||||
username="p8-without-budget-list@example.com",
|
||||
name="budget manager",
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
@@ -4580,6 +5098,13 @@ def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applica
|
||||
organization_unit=delivery_department,
|
||||
roles=[budget_role],
|
||||
)
|
||||
p8_without_budget_employee = Employee(
|
||||
employee_no="E-P8-NO-BUDGET-LIST",
|
||||
name="P8 No Budget Role",
|
||||
email="p8-without-budget-list@example.com",
|
||||
grade="P8",
|
||||
organization_unit=delivery_department,
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E-BUDGET-LIST-OWNER",
|
||||
name="张三",
|
||||
@@ -4592,7 +5117,14 @@ def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applica
|
||||
email="budget-list-market@example.com",
|
||||
organization_unit=market_department,
|
||||
)
|
||||
db.add_all([delivery_department, market_department, budget_manager, employee, market_employee])
|
||||
db.add_all([
|
||||
delivery_department,
|
||||
market_department,
|
||||
budget_manager,
|
||||
p8_without_budget_employee,
|
||||
employee,
|
||||
market_employee,
|
||||
])
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
@@ -4660,5 +5192,8 @@ def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applica
|
||||
claims = ExpenseClaimService(db).list_approval_claims(current_user)
|
||||
|
||||
assert [claim.claim_no for claim in claims] == ["APP-BUDGET-LIST-201"]
|
||||
assert getattr(claims[0], "budget_approver_name", "") == "赵预算"
|
||||
assert getattr(claims[0], "budget_approver_grade", "") == "P8"
|
||||
assert getattr(claims[0], "budget_approver_role_code", "") == "budget_monitor"
|
||||
claims_without_budget_role = ExpenseClaimService(db).list_approval_claims(p8_without_budget_role)
|
||||
assert [claim.claim_no for claim in claims_without_budget_role] == []
|
||||
|
||||
106
server/tests/test_hermes_risk_clue_collector.py
Normal file
106
server/tests/test_hermes_risk_clue_collector.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.hermes_risk_clue_collector import HermesRiskClueCollectorService
|
||||
from app.services.risk_observations import RiskObservationService
|
||||
|
||||
|
||||
def build_session_factory() -> sessionmaker[Session]:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
def test_risk_clue_collector_outputs_review_packet_without_rule_writes() -> None:
|
||||
forbidden_rule_execution = "执行" + "规则"
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
claim = ExpenseClaim(
|
||||
id="claim-risk-clue-1",
|
||||
claim_no="RE-20260531090000-ABCDEFGH",
|
||||
employee_name="张三",
|
||||
department_name="销售部",
|
||||
expense_type="travel",
|
||||
reason="客户现场支持",
|
||||
location="上海",
|
||||
amount=Decimal("9800.00"),
|
||||
currency="CNY",
|
||||
invoice_count=2,
|
||||
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 31, 9, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="财务审批",
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "rule_center",
|
||||
"rule_code": "risk.travel.large_without_preapproval",
|
||||
"label": "大额差旅缺少事前申请",
|
||||
"message": "报销金额较高,未找到对应事前申请。",
|
||||
"severity": "high",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.flush()
|
||||
|
||||
RiskObservationService(db).upsert_observation(
|
||||
{
|
||||
"observation_key": "risk:claim-risk-clue-1:large_without_preapproval",
|
||||
"subject_type": "expense_claim",
|
||||
"subject_key": "claim:claim-risk-clue-1",
|
||||
"subject_label": claim.claim_no,
|
||||
"claim_id": claim.id,
|
||||
"claim_no": claim.claim_no,
|
||||
"risk_type": "preapproval_absent",
|
||||
"risk_signal": "preapproval_absent",
|
||||
"title": "大额差旅缺少事前申请",
|
||||
"description": "报销金额较高,暂未匹配到事前申请,需要人工复核。",
|
||||
"risk_score": 86,
|
||||
"risk_level": "high",
|
||||
"confidence_score": 0.82,
|
||||
"source": "rule_center",
|
||||
"contribution_scores": {"S_rule": 86},
|
||||
"evidence": [
|
||||
{
|
||||
"source": "rule_center",
|
||||
"title": "规则命中",
|
||||
"detail": "金额 9800 元,缺少事前申请。",
|
||||
}
|
||||
],
|
||||
"policy_refs": ["risk.travel.large_without_preapproval"],
|
||||
}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
packet = HermesRiskClueCollectorService(db).collect_risk_clues(run_id="run-risk-clue")
|
||||
|
||||
assert packet["task_type"] == "risk_clue_collect"
|
||||
assert packet["writes_rules"] is False
|
||||
assert packet["human_review_required"] is True
|
||||
assert "主流程由外层智能体执行" in packet["role_boundary"]
|
||||
assert forbidden_rule_execution not in packet["role_boundary"]
|
||||
assert packet["fact_count"] == 1
|
||||
assert packet["rule_hit_count"] >= 1
|
||||
assert packet["risk_clue_count"] >= 1
|
||||
assert packet["facts"][0]["claim_kind"] == "reimbursement"
|
||||
assert packet["risk_clues"][0]["status"] == "human_review_required"
|
||||
assert packet["risk_clues"][0]["observation_key"]
|
||||
assert packet["risk_clues"][0]["feedback_status"] == "unreviewed"
|
||||
assert packet["risk_clues"][0]["next_action"]
|
||||
assert "recent" in packet["feedback_summary"]
|
||||
assert packet["risk_clues"][0]["not_final_conclusion"] is True
|
||||
serialized = str(packet)
|
||||
assert "auto_publish" not in serialized
|
||||
assert "candidate_risk_rules" not in serialized
|
||||
@@ -58,6 +58,13 @@ def skip_agent_foundation_bootstrap(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
},
|
||||
"生成 9 条快照",
|
||||
),
|
||||
(
|
||||
"risk_clue_collect",
|
||||
"task.hermes.risk_rule_discovery",
|
||||
"app.services.hermes_risk_clue_collector.HermesRiskClueCollectorService.collect_risk_clues",
|
||||
{"fact_count": 4, "rule_hit_count": 3, "risk_clue_count": 2},
|
||||
"输出 2 条待复核线索",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_schedule_digital_employee_task_runs_real_service(
|
||||
@@ -708,7 +715,7 @@ def test_orchestrator_application_session_does_not_use_reimbursement_scene_promp
|
||||
assert result.get("review_payload") is None
|
||||
|
||||
|
||||
def test_orchestrator_application_session_guides_transport_amount_and_submit(
|
||||
def test_orchestrator_application_session_guides_transport_estimate_and_submit(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
@@ -749,15 +756,6 @@ def test_orchestrator_application_session_guides_transport_amount_and_submit(
|
||||
)
|
||||
)
|
||||
third = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-flow@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="预计总费用:12000元",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
fourth = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-flow@example.com",
|
||||
@@ -768,29 +766,27 @@ def test_orchestrator_application_session_guides_transport_amount_and_submit(
|
||||
)
|
||||
|
||||
assert first.status == "blocked"
|
||||
assert "当前还需要补充:出行方式、用户预估费用" in first.result["answer"]
|
||||
assert "当前还需要补充:出行方式" in first.result["answer"]
|
||||
assert [item["label"] for item in first.result["suggested_actions"]] == ["一次性补充申请信息"]
|
||||
assert first.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "出行方式:\n用户预估费用:"
|
||||
assert first.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "出行方式:"
|
||||
|
||||
assert "当前还需要补充:用户预估费用" in second.result["answer"]
|
||||
assert [item["label"] for item in second.result["suggested_actions"]] == ["一次性补充申请信息"]
|
||||
assert second.result["suggested_actions"][0]["action_type"] == "prefill_composer"
|
||||
assert second.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "用户预估费用:"
|
||||
assert "这是费用申请核对结果" in second.result["answer"]
|
||||
assert "| 事由 | 支持上海国网服务器部署 |" in second.result["answer"]
|
||||
assert "| 系统预估费用 |" in second.result["answer"]
|
||||
assert "按 2026-05-25 参考票价" in second.result["answer"]
|
||||
assert "2,330元" in second.result["answer"]
|
||||
assert "请核对上述信息无误" in second.result["answer"]
|
||||
assert "[确认](#application-submit)" in second.result["answer"]
|
||||
assert second.status == "blocked"
|
||||
assert second.result["requires_confirmation"] is True
|
||||
assert second.result["suggested_actions"] == []
|
||||
|
||||
assert "这是模拟的费用申请结果" in third.result["answer"]
|
||||
assert "| 事由 | 支持上海国网服务器部署 |" in third.result["answer"]
|
||||
assert "请核对上述信息无误" in third.result["answer"]
|
||||
assert "[确认](#application-submit)" in third.result["answer"]
|
||||
assert third.status == "blocked"
|
||||
assert third.result["requires_confirmation"] is True
|
||||
assert third.status == "succeeded"
|
||||
assert third.result["clarification_required"] is False
|
||||
assert third.result["missing_slots"] == []
|
||||
assert "申请单据已生成,并已进入审批流程" in third.result["answer"]
|
||||
assert "系统已推送给 陈硕 审核,当前节点:陈硕审核中" in third.result["answer"]
|
||||
assert third.result["suggested_actions"] == []
|
||||
|
||||
assert fourth.status == "succeeded"
|
||||
assert fourth.result["clarification_required"] is False
|
||||
assert fourth.result["missing_slots"] == []
|
||||
assert "申请单据已生成,并已进入审批流程" in fourth.result["answer"]
|
||||
assert "系统已推送给 陈硕 审核,当前节点:陈硕审核中" in fourth.result["answer"]
|
||||
assert fourth.result["suggested_actions"] == []
|
||||
application_claims = [
|
||||
claim
|
||||
for claim in db.query(ExpenseClaim).all()
|
||||
@@ -799,7 +795,7 @@ def test_orchestrator_application_session_guides_transport_amount_and_submit(
|
||||
assert len(application_claims) == 1
|
||||
assert application_claims[0].status == "submitted"
|
||||
assert application_claims[0].approval_stage == "直属领导审批"
|
||||
assert fourth.result["draft_payload"]["claim_no"] == application_claims[0].claim_no
|
||||
assert third.result["draft_payload"]["claim_no"] == application_claims[0].claim_no
|
||||
|
||||
|
||||
def test_orchestrator_application_submit_bypasses_generic_operation_block(
|
||||
@@ -833,21 +829,12 @@ def test_orchestrator_application_submit_bypasses_generic_operation_block(
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-approval-required@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="飞机",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
preview = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-approval-required@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="预计总费用:12000元",
|
||||
message="飞机",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.db.base import Base
|
||||
from app.main import create_app
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.role import Role
|
||||
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
@@ -367,11 +368,32 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review()
|
||||
def test_approve_application_endpoint_routes_direct_manager_review_to_budget_review() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
department = OrganizationUnit(
|
||||
id="dept-1",
|
||||
unit_code="DELIVERY-API",
|
||||
name="交付部",
|
||||
unit_type="department",
|
||||
)
|
||||
budget_role = Role(
|
||||
id="role-budget-application-approve-1",
|
||||
role_code="budget_monitor",
|
||||
name="预算监控员",
|
||||
)
|
||||
manager = Employee(
|
||||
id="mgr-application-approve-1",
|
||||
employee_no="E21002",
|
||||
name="李经理",
|
||||
email="manager-application-approve-api@example.com",
|
||||
organization_unit=department,
|
||||
)
|
||||
budget_manager = Employee(
|
||||
id="budget-application-approve-1",
|
||||
employee_no="E31002",
|
||||
name="赵预算",
|
||||
email="budget-application-approve-api@example.com",
|
||||
grade="P8",
|
||||
organization_unit=department,
|
||||
roles=[budget_role],
|
||||
)
|
||||
employee = Employee(
|
||||
id="emp-application-approve-1",
|
||||
@@ -379,6 +401,7 @@ def test_approve_application_endpoint_routes_direct_manager_review_to_budget_rev
|
||||
name="张三",
|
||||
email="zhangsan-application-approve-api@example.com",
|
||||
manager=manager,
|
||||
organization_unit=department,
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
id="claim-application-approve-1",
|
||||
@@ -398,9 +421,16 @@ def test_approve_application_endpoint_routes_direct_manager_review_to_budget_rev
|
||||
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "申请风险复核",
|
||||
"message": "申请金额和行程安排需要预算管理者二次确认。",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add_all([manager, employee, claim])
|
||||
db.add_all([department, budget_role, manager, budget_manager, employee, claim])
|
||||
db.commit()
|
||||
|
||||
response = client.post(
|
||||
@@ -424,6 +454,7 @@ def test_approve_application_endpoint_routes_direct_manager_review_to_budget_rev
|
||||
and item["operator"] == "李经理"
|
||||
and item["next_status"] == "submitted"
|
||||
and item["next_approval_stage"] == "预算管理者审批"
|
||||
and item["next_approver_name"] == "赵预算"
|
||||
for item in payload["risk_flags_json"]
|
||||
)
|
||||
|
||||
@@ -555,3 +586,25 @@ def test_claim_item_delete_removes_item_and_attachment(monkeypatch, tmp_path) ->
|
||||
headers=headers,
|
||||
)
|
||||
assert deleted_meta_response.status_code == 404
|
||||
|
||||
|
||||
def test_claim_delete_allows_draft_owner_by_employee_id_without_employee_no_header(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
||||
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
claim, _ = seed_claim(db)
|
||||
claim_id = claim.id
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1/reimbursements/claims/{claim_id}",
|
||||
headers={"x-auth-username": "emp-1", "x-auth-name": "Browser Session User"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["claim_id"] == claim_id
|
||||
assert payload["status"] == "deleted"
|
||||
|
||||
with session_factory() as db:
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
|
||||
@@ -66,7 +66,9 @@ def test_risk_observation_service_upserts_and_summarizes_dashboard() -> None:
|
||||
assert refreshed.canonical_subject_key == "claim:c1"
|
||||
assert dashboard.total_observations == 2
|
||||
assert dashboard.high_or_above_count == 2
|
||||
assert dashboard.risk_clue_count == 1
|
||||
assert dashboard.confirmed_count == 1
|
||||
assert dashboard.feedback_sample_count == 1
|
||||
assert dashboard.total_amount == 2400.0
|
||||
assert dashboard.level_distribution["high"] == 2
|
||||
assert dashboard.signal_distribution["duplicate_invoice"] == 1
|
||||
@@ -149,6 +151,8 @@ def test_risk_observation_endpoints_return_list_detail_dashboard_and_feedback()
|
||||
assert detail_response.json()["risk_signal"] == "duplicate_invoice"
|
||||
assert dashboard_response.status_code == 200
|
||||
assert dashboard_response.json()["total_observations"] == 1
|
||||
assert dashboard_response.json()["risk_clue_count"] == 1
|
||||
assert dashboard_response.json()["feedback_sample_count"] == 0
|
||||
assert "top_departments" in dashboard_response.json()
|
||||
assert feedback_response.status_code == 200
|
||||
assert feedback_response.json()["feedback_type"] == "false_positive"
|
||||
|
||||
@@ -119,6 +119,53 @@ def test_duplicate_invoice_example_reports_duplicate_evidence() -> None:
|
||||
assert condition["duplicates"] == ["inv-dup-001"]
|
||||
|
||||
|
||||
def test_date_rule_uses_application_month_before_ticket_item_date() -> None:
|
||||
claim = _claim()
|
||||
claim.trip_start_date = None
|
||||
claim.trip_end_date = None
|
||||
claim.occurred_at = datetime(2026, 2, 20, tzinfo=UTC)
|
||||
claim.items[0].item_date = date(2026, 2, 20)
|
||||
claim.risk_flags_json = [
|
||||
{
|
||||
"source": "application_handoff",
|
||||
"application_detail": {
|
||||
"application_time": "6月",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
manifest = {
|
||||
"template_key": COMPOSITE_RULE_TEMPLATE_KEY,
|
||||
"params": {
|
||||
"template_key": COMPOSITE_RULE_TEMPLATE_KEY,
|
||||
"conditions": [
|
||||
{
|
||||
"id": "ticket_date_outside_trip",
|
||||
"operator": "date_outside_range",
|
||||
"date_fields": ["item.item_date", "attachment.issue_date"],
|
||||
"range_start_fields": ["claim.trip_start_date"],
|
||||
"range_end_fields": ["claim.trip_end_date"],
|
||||
"tolerance_days": 0,
|
||||
}
|
||||
],
|
||||
"hit_logic": "ticket_date_outside_trip",
|
||||
"condition_summary": "ticket date outside trip window",
|
||||
},
|
||||
}
|
||||
|
||||
result = RiskRuleTemplateExecutor().evaluate(
|
||||
manifest,
|
||||
claim=claim,
|
||||
contexts=[{"document_info": {"issue_date": "2026-02-20"}}],
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
condition = result["evidence"]["conditions"][0]
|
||||
assert condition["range_start"] == "2026-06-01"
|
||||
assert condition["range_end"] == "2026-06-30"
|
||||
assert condition["outside_dates"] == ["2026-02-20"]
|
||||
|
||||
|
||||
def _claim(*, amount: Decimal = Decimal("1000.00")) -> ExpenseClaim:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="TEST-RISK-RULE-DSL",
|
||||
|
||||
@@ -114,6 +114,16 @@ def test_simulation_returns_execution_trace_for_ticket_city_mismatch(tmp_path) -
|
||||
assert simulation.ready is True
|
||||
assert simulation.hit is True
|
||||
assert simulation.normalized_fields["claim.location"] == "北京"
|
||||
assert simulation.ocr_raw_fields[0]["attachment_name"] == "train-ticket.pdf"
|
||||
assert simulation.ocr_raw_fields[0]["label"] == "行程路线"
|
||||
assert any(
|
||||
field["key"] == "attachment.route_cities"
|
||||
for field in simulation.hermes_normalized_fields
|
||||
)
|
||||
assert any(
|
||||
field["key"] == "attachment.route_cities" and field["required"] is True
|
||||
for field in simulation.executor_input_fields
|
||||
)
|
||||
assert simulation.trace["matched"] is True
|
||||
assert "hit" in simulation.trace["path_node_ids"]
|
||||
assert simulation.trace["steps"]
|
||||
|
||||
156
server/tests/test_risk_rule_feedback.py
Normal file
156
server/tests/test_risk_rule_feedback.py
Normal file
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.core.agent_enums import AgentAssetDomain
|
||||
from app.db.base import Base
|
||||
from app.main import create_app
|
||||
from app.models.agent_asset import AgentAsset, AgentAssetRuleFeedback
|
||||
from app.schemas.agent_asset import (
|
||||
AgentAssetRiskRuleFeedbackCreate,
|
||||
AgentAssetRiskRuleGenerateRequest,
|
||||
)
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
from app.services.risk_rule_generation import RiskRuleGenerationService
|
||||
|
||||
|
||||
class NullRuntimeChatService:
|
||||
def complete(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def build_session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False)()
|
||||
|
||||
|
||||
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
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
|
||||
return TestClient(app), session_factory
|
||||
|
||||
|
||||
def test_risk_rule_feedback_records_misjudgement_without_modifying_rule(tmp_path) -> None:
|
||||
with build_session() as db:
|
||||
asset_id = _create_rule(db, tmp_path)
|
||||
asset = db.get(AgentAsset, asset_id)
|
||||
assert asset is not None
|
||||
before_config = dict(asset.config_json or {})
|
||||
before_status = asset.status
|
||||
|
||||
feedback = AgentAssetService(db).create_risk_rule_feedback(
|
||||
asset_id,
|
||||
AgentAssetRiskRuleFeedbackCreate(
|
||||
feedback_type="false_positive",
|
||||
subject_type="expense_claim",
|
||||
subject_key="CLAIM-001",
|
||||
subject_label="差旅报销 CLAIM-001",
|
||||
actual_result={"hit": True, "severity": "high"},
|
||||
expected_result={"hit": False},
|
||||
comment="票据城市实际与行程一致,当前规则误判。",
|
||||
payload={"source": "expense_review"},
|
||||
),
|
||||
actor="employee",
|
||||
)
|
||||
|
||||
stored = db.scalar(
|
||||
select(AgentAssetRuleFeedback).where(
|
||||
AgentAssetRuleFeedback.feedback_id == feedback.feedback_id
|
||||
)
|
||||
)
|
||||
assert stored is not None
|
||||
assert feedback.feedback_type == "false_positive"
|
||||
assert feedback.version == asset.working_version
|
||||
assert stored.actual_result_json["hit"] is True
|
||||
db.refresh(asset)
|
||||
assert asset.status == before_status
|
||||
assert asset.config_json == before_config
|
||||
|
||||
|
||||
def test_risk_rule_feedback_endpoint_allows_ordinary_user_and_manager_list(tmp_path) -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
asset_id = _create_rule(db, tmp_path)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/agent-assets/{asset_id}/risk-rules/feedback",
|
||||
headers=_user_headers(),
|
||||
json={
|
||||
"feedback_type": "false_negative",
|
||||
"subject_type": "expense_claim",
|
||||
"subject_key": "CLAIM-002",
|
||||
"actual_result": {"hit": False},
|
||||
"expected_result": {"hit": True, "severity": "medium"},
|
||||
"comment": "这张票据应命中风险但没有命中。",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["created_by"] == "employee"
|
||||
assert response.json()["status"] == "open"
|
||||
|
||||
list_response = client.get(
|
||||
f"/api/v1/agent-assets/{asset_id}/risk-rules/feedback",
|
||||
headers=_manager_headers(),
|
||||
)
|
||||
assert list_response.status_code == 200
|
||||
assert list_response.json()[0]["feedback_type"] == "false_negative"
|
||||
|
||||
|
||||
def _create_rule(db: Session, tmp_path) -> str:
|
||||
return RiskRuleGenerationService(
|
||||
db,
|
||||
rule_library_manager=AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules"),
|
||||
runtime_chat_service=NullRuntimeChatService(),
|
||||
).generate_rule_asset(
|
||||
AgentAssetRiskRuleGenerateRequest(
|
||||
business_domain=AgentAssetDomain.EXPENSE,
|
||||
expense_category="travel",
|
||||
rule_title="差旅票据城市规则",
|
||||
natural_language="差旅票据城市与申报目的地不一致时,提示补充说明。",
|
||||
),
|
||||
actor="pytest",
|
||||
)
|
||||
|
||||
|
||||
def _user_headers() -> dict[str, str]:
|
||||
return {
|
||||
"x-auth-username": "employee",
|
||||
"x-auth-name": "employee",
|
||||
"x-auth-role-codes": "user",
|
||||
}
|
||||
|
||||
|
||||
def _manager_headers() -> dict[str, str]:
|
||||
return {
|
||||
"x-auth-username": "manager",
|
||||
"x-auth-name": "manager",
|
||||
"x-auth-role-codes": "manager",
|
||||
}
|
||||
@@ -230,6 +230,41 @@ def test_generate_expense_application_risk_rule_marks_business_stage(tmp_path) -
|
||||
assert payload["params"]["business_stage_label"] == "费用申请"
|
||||
|
||||
|
||||
def test_generate_risk_rule_asset_supports_all_expense_category(tmp_path) -> None:
|
||||
with build_session() as db:
|
||||
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||
service = RiskRuleGenerationService(
|
||||
db,
|
||||
rule_library_manager=manager,
|
||||
runtime_chat_service=NullRuntimeChatService(),
|
||||
)
|
||||
|
||||
asset_id = service.generate_rule_asset(
|
||||
AgentAssetRiskRuleGenerateRequest(
|
||||
business_domain=AgentAssetDomain.EXPENSE,
|
||||
business_stage="expense_application",
|
||||
expense_category="all",
|
||||
rule_title="预算可用余额不足",
|
||||
natural_language="费用申请时,如果申请金额超过预算可用余额,则提示预算风险并要求补充说明。",
|
||||
),
|
||||
actor="pytest",
|
||||
)
|
||||
|
||||
asset = db.get(AgentAsset, asset_id)
|
||||
assert asset is not None
|
||||
assert asset.config_json["expense_category"] == "all"
|
||||
assert asset.config_json["expense_category_label"] == "全部"
|
||||
assert asset.scenario_json == ["全部"]
|
||||
|
||||
payload = manager.read_rule_library_json(
|
||||
library=RISK_RULES_LIBRARY,
|
||||
file_name=asset.config_json["rule_document"]["file_name"],
|
||||
)
|
||||
assert payload["applies_to"]["expense_categories"] == ["all"]
|
||||
assert payload["metadata"]["expense_category"] == "all"
|
||||
assert payload["metadata"]["expense_category_label"] == "全部"
|
||||
|
||||
|
||||
def test_risk_score_model_keeps_explicit_low_control_rules_low() -> None:
|
||||
field_keys = ["attachment.invoice_no", "attachment.goods_name", "claim.reason"]
|
||||
result = calculate_risk_rule_score(
|
||||
@@ -476,6 +511,44 @@ def test_platform_risk_applies_to_chinese_expense_type_labels() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_platform_risk_all_expense_scope_matches_any_budget_category() -> None:
|
||||
class PlatformRiskProbe(ExpenseClaimPlatformRiskMixin):
|
||||
pass
|
||||
|
||||
claim = ExpenseClaim(
|
||||
claim_no="TEST-COMMUNICATION-RISK",
|
||||
employee_name="测试员工",
|
||||
department_name="市场部",
|
||||
expense_type="通信费",
|
||||
reason="客户支持电话费",
|
||||
amount=Decimal("300.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime.now(UTC),
|
||||
status="draft",
|
||||
)
|
||||
manifest = {
|
||||
"applies_to": {
|
||||
"domains": ["expense"],
|
||||
"expense_types": ["all"],
|
||||
}
|
||||
}
|
||||
|
||||
assert PlatformRiskProbe()._risk_manifest_applies_to_claim(
|
||||
manifest,
|
||||
claim=claim,
|
||||
contexts=[],
|
||||
)
|
||||
|
||||
manifest["applies_to"] = {"domains": ["expense"], "expense_categories": ["全部"]}
|
||||
|
||||
assert PlatformRiskProbe()._risk_manifest_applies_to_claim(
|
||||
manifest,
|
||||
claim=claim,
|
||||
contexts=[],
|
||||
)
|
||||
|
||||
|
||||
def test_risk_rule_flow_diagram_uses_risk_level_palette() -> None:
|
||||
renderer = RiskRuleFlowDiagramRenderer()
|
||||
|
||||
|
||||
54
server/tests/test_risk_rule_generation_failure.py
Normal file
54
server/tests/test_risk_rule_generation_failure.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus
|
||||
from app.db.base import Base
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.risk_rule_generation_jobs import RiskRuleGenerationJobService
|
||||
|
||||
|
||||
class FailingRuntimeChatService:
|
||||
def complete(self, *args, **kwargs) -> str:
|
||||
raise RuntimeError("Hermes semantic plan failed")
|
||||
|
||||
|
||||
def build_session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False)()
|
||||
|
||||
|
||||
def test_background_generation_failure_keeps_error_detail_and_last_operation(tmp_path) -> None:
|
||||
with build_session() as db:
|
||||
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||
service = RiskRuleGenerationJobService(
|
||||
db,
|
||||
rule_library_manager=manager,
|
||||
runtime_chat_service=FailingRuntimeChatService(),
|
||||
)
|
||||
body = AgentAssetRiskRuleGenerateRequest(
|
||||
business_domain=AgentAssetDomain.EXPENSE,
|
||||
expense_category="travel",
|
||||
rule_title="差旅异常规则",
|
||||
natural_language="差旅报销票据城市与申报目的地不一致时提示风险。",
|
||||
)
|
||||
asset_id = service.enqueue_rule_asset_generation(body, actor="pytest")
|
||||
|
||||
service.complete_rule_asset_generation(asset_id, body, actor="pytest")
|
||||
|
||||
asset = db.get(AgentAsset, asset_id)
|
||||
assert asset is not None
|
||||
assert asset.status == AgentAssetStatus.FAILED.value
|
||||
assert asset.config_json["generation_status"] == AgentAssetStatus.FAILED.value
|
||||
assert asset.config_json["generation_error"] == "Hermes semantic plan failed"
|
||||
assert asset.config_json["last_operation"]["action"] == "generation_failed"
|
||||
assert asset.config_json["last_operation"]["actor"] == "pytest"
|
||||
@@ -14,6 +14,8 @@ from app.main import create_app
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.agent_asset_risk_rule_regeneration import AgentAssetRiskRuleRegenerationService
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
from app.services.risk_rule_generation import RiskRuleGenerationService
|
||||
|
||||
|
||||
@@ -111,6 +113,95 @@ def test_create_risk_rule_revision_endpoint_keeps_active_version(tmp_path) -> No
|
||||
assert payload["config_json"]["last_operation"]["action"] == "create_revision"
|
||||
|
||||
|
||||
def test_regenerate_risk_rule_endpoint_returns_updated_detail(tmp_path, monkeypatch) -> None:
|
||||
client, session_factory = build_client()
|
||||
asset_id = _create_rule(session_factory, tmp_path)
|
||||
|
||||
def fake_regenerate(self, target_asset_id, body, *, actor, request_id=None):
|
||||
del body, request_id
|
||||
asset = self.db.get(AgentAsset, target_asset_id)
|
||||
assert asset is not None
|
||||
config = dict(asset.config_json or {})
|
||||
config["generation_status"] = "completed"
|
||||
config["last_operation"] = {"action": "regenerate", "actor": actor, "at": "2026-05-30T00:00:00+00:00"}
|
||||
asset.config_json = config
|
||||
self.db.add(asset)
|
||||
self.db.flush()
|
||||
return asset
|
||||
|
||||
monkeypatch.setattr(AgentAssetRiskRuleRegenerationService, "regenerate", fake_regenerate)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/agent-assets/{asset_id}/risk-rules/regenerate",
|
||||
headers=_finance_headers(),
|
||||
json={"natural_language": "差旅票据城市与申报目的地不一致时要求补充说明。"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["config_json"]["generation_status"] == "completed"
|
||||
assert payload["config_json"]["last_operation"]["action"] == "regenerate"
|
||||
|
||||
|
||||
def test_risk_rule_admin_only_actions_block_non_admin_users(tmp_path) -> None:
|
||||
client, session_factory = build_client()
|
||||
asset_id = _create_rule(session_factory, tmp_path)
|
||||
|
||||
generate_response = client.post(
|
||||
"/api/v1/agent-assets/risk-rules/generate",
|
||||
headers=_finance_headers(),
|
||||
json={
|
||||
"business_domain": "expense",
|
||||
"expense_category": "travel",
|
||||
"rule_title": "普通财务新建规则",
|
||||
"natural_language": "差旅票据城市与申报目的地不一致时提示风险。",
|
||||
},
|
||||
)
|
||||
assert generate_response.status_code == 403
|
||||
|
||||
simulate_response = client.post(
|
||||
f"/api/v1/agent-assets/{asset_id}/risk-rule-tests/simulate",
|
||||
headers=_finance_headers(),
|
||||
json={"message": "测试一张差旅票据。"},
|
||||
)
|
||||
assert simulate_response.status_code == 403
|
||||
|
||||
delete_response = client.delete(
|
||||
f"/api/v1/agent-assets/{asset_id}",
|
||||
headers=_manager_headers(),
|
||||
)
|
||||
assert delete_response.status_code == 403
|
||||
|
||||
|
||||
def test_manager_can_toggle_risk_rule_enabled_endpoint(tmp_path, monkeypatch) -> None:
|
||||
client, session_factory = build_client()
|
||||
asset_id = _create_rule(session_factory, tmp_path)
|
||||
|
||||
def fake_toggle(self, target_asset_id, *, enabled, actor, request_id=None):
|
||||
del request_id
|
||||
asset = self.db.get(AgentAsset, target_asset_id)
|
||||
assert asset is not None
|
||||
config = dict(asset.config_json or {})
|
||||
config["enabled"] = bool(enabled)
|
||||
config["last_operation"] = {"action": "offline", "actor": actor}
|
||||
asset.config_json = config
|
||||
self.db.add(asset)
|
||||
self.db.flush()
|
||||
return asset
|
||||
|
||||
monkeypatch.setattr(AgentAssetService, "set_risk_rule_enabled", fake_toggle)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/agent-assets/{asset_id}/risk-rule-enabled",
|
||||
headers=_manager_headers(),
|
||||
json={"enabled": False},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["config_json"]["enabled"] is False
|
||||
assert response.json()["config_json"]["last_operation"]["actor"] == "manager"
|
||||
|
||||
|
||||
def _create_rule(session_factory: sessionmaker[Session], tmp_path) -> str:
|
||||
with session_factory() as db:
|
||||
return RiskRuleGenerationService(
|
||||
@@ -147,3 +238,12 @@ def _finance_headers() -> dict[str, str]:
|
||||
"x-auth-role-codes": "finance",
|
||||
"x-actor": "finance",
|
||||
}
|
||||
|
||||
|
||||
def _manager_headers() -> dict[str, str]:
|
||||
return {
|
||||
"x-auth-username": "manager",
|
||||
"x-auth-name": "manager",
|
||||
"x-auth-role-codes": "manager",
|
||||
"x-actor": "manager",
|
||||
}
|
||||
|
||||
@@ -7,13 +7,16 @@ from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus
|
||||
from app.db.base import Base
|
||||
from app.models.agent_asset import AgentAsset, AgentAssetVersion
|
||||
from app.models.agent_asset import AgentAsset, AgentAssetTestRun, AgentAssetVersion
|
||||
from app.schemas.agent_asset import (
|
||||
AgentAssetRiskRuleDraftUpdate,
|
||||
AgentAssetRiskRuleGenerateRequest,
|
||||
AgentAssetRiskRuleRegenerateRequest,
|
||||
AgentAssetRiskRuleRevisionCreate,
|
||||
)
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.agent_asset_risk_rule_regeneration import AgentAssetRiskRuleRegenerationService
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
from app.services.risk_rule_generation import RiskRuleGenerationService
|
||||
from app.services.agent_asset_risk_rule_revision import AgentAssetRiskRuleRevisionService
|
||||
|
||||
@@ -107,10 +110,173 @@ def test_create_revision_draft_for_published_rule_does_not_overwrite_active_vers
|
||||
assert db.query(AgentAssetVersion).filter_by(asset_id=asset_id, version="v0.1.1").one()
|
||||
|
||||
|
||||
def _create_rule(db: Session, tmp_path) -> str:
|
||||
def test_regenerate_unpublished_draft_updates_dsl_and_score(tmp_path) -> None:
|
||||
with build_session() as db:
|
||||
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||
asset_id = _create_rule(db, tmp_path, manager=manager)
|
||||
updated = AgentAssetRiskRuleRegenerationService(
|
||||
db,
|
||||
rule_library_manager=manager,
|
||||
runtime_chat_service=NullRuntimeChatService(),
|
||||
).regenerate(
|
||||
asset_id,
|
||||
AgentAssetRiskRuleRegenerateRequest(
|
||||
rule_title="差旅城市一致性复核",
|
||||
natural_language="差旅报销票据城市与申报目的地不一致时,要求补充说明。",
|
||||
requires_attachment=True,
|
||||
),
|
||||
actor="finance",
|
||||
)
|
||||
|
||||
assert updated.status == AgentAssetStatus.DRAFT.value
|
||||
assert updated.config_json["generation_status"] == "completed"
|
||||
assert updated.config_json["risk_score"] is not None
|
||||
assert updated.config_json["last_operation"]["action"] == "regenerate"
|
||||
payload = manager.read_rule_library_json(
|
||||
library="risk-rules",
|
||||
file_name=updated.config_json["rule_document"]["file_name"],
|
||||
)
|
||||
assert payload["name"] == "差旅城市一致性复核"
|
||||
assert payload["flow_diagram_svg"]
|
||||
assert payload["metadata"]["risk_score"] == updated.config_json["risk_score"]
|
||||
|
||||
|
||||
def test_regenerate_revision_draft_keeps_active_document_unchanged(tmp_path) -> None:
|
||||
with build_session() as db:
|
||||
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||
asset_id = _create_rule(db, tmp_path, manager=manager)
|
||||
asset = db.get(AgentAsset, asset_id)
|
||||
assert asset is not None
|
||||
active_document = asset.config_json["rule_document"]
|
||||
active_payload_before = manager.read_rule_library_json(
|
||||
library="risk-rules",
|
||||
file_name=active_document["file_name"],
|
||||
)
|
||||
asset.status = AgentAssetStatus.ACTIVE.value
|
||||
asset.published_version = "v0.1.0"
|
||||
asset.current_version = "v0.1.0"
|
||||
asset.working_version = "v0.1.0"
|
||||
db.add(asset)
|
||||
db.flush()
|
||||
|
||||
AgentAssetRiskRuleRevisionService(db).create_revision_draft(
|
||||
asset_id,
|
||||
AgentAssetRiskRuleRevisionCreate(
|
||||
rule_title="票据城市一致性复核",
|
||||
natural_language="票据城市与申报目的地不一致时,要求补充说明。",
|
||||
requires_attachment=True,
|
||||
change_reason="补充城市一致性判断。",
|
||||
),
|
||||
actor="manager",
|
||||
)
|
||||
updated = AgentAssetRiskRuleRegenerationService(
|
||||
db,
|
||||
rule_library_manager=manager,
|
||||
runtime_chat_service=NullRuntimeChatService(),
|
||||
).regenerate(
|
||||
asset_id,
|
||||
AgentAssetRiskRuleRegenerateRequest(),
|
||||
actor="manager",
|
||||
)
|
||||
|
||||
revision = updated.config_json["revision_draft"]
|
||||
assert updated.status == AgentAssetStatus.ACTIVE.value
|
||||
assert updated.published_version == "v0.1.0"
|
||||
assert updated.config_json["rule_document"] == active_document
|
||||
assert revision["generation_status"] == "completed"
|
||||
assert revision["risk_score"] is not None
|
||||
assert revision["rule_document"]["file_name"] != active_document["file_name"]
|
||||
active_payload_after = manager.read_rule_library_json(
|
||||
library="risk-rules",
|
||||
file_name=active_document["file_name"],
|
||||
)
|
||||
assert active_payload_after == active_payload_before
|
||||
revision_payload = manager.read_rule_library_json(
|
||||
library="risk-rules",
|
||||
file_name=revision["rule_document"]["file_name"],
|
||||
)
|
||||
assert revision_payload["rule_code"] == updated.code
|
||||
assert revision_payload["enabled"] is False
|
||||
|
||||
detail_service = AgentAssetService(db)
|
||||
detail_service.rule_library_manager = manager
|
||||
displayed = detail_service.read_rule_json(asset_id)
|
||||
assert displayed.file_name == revision["rule_document"]["file_name"]
|
||||
assert displayed.payload["rule_code"] == updated.code
|
||||
|
||||
|
||||
def test_publish_regenerated_revision_replaces_online_document(tmp_path) -> None:
|
||||
with build_session() as db:
|
||||
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||
asset_id = _create_rule(db, tmp_path, manager=manager)
|
||||
asset = db.get(AgentAsset, asset_id)
|
||||
assert asset is not None
|
||||
old_document = asset.config_json["rule_document"]
|
||||
asset.status = AgentAssetStatus.ACTIVE.value
|
||||
asset.published_version = "v0.1.0"
|
||||
asset.current_version = "v0.1.0"
|
||||
asset.working_version = "v0.1.0"
|
||||
db.add(asset)
|
||||
db.flush()
|
||||
AgentAssetRiskRuleRevisionService(db).create_revision_draft(
|
||||
asset_id,
|
||||
AgentAssetRiskRuleRevisionCreate(
|
||||
rule_title="差旅票据城市复核",
|
||||
natural_language="票据城市与申报目的地不一致时,要求补充说明。",
|
||||
requires_attachment=True,
|
||||
change_reason="补充城市一致性判断。",
|
||||
),
|
||||
actor="manager",
|
||||
)
|
||||
regenerated = AgentAssetRiskRuleRegenerationService(
|
||||
db,
|
||||
rule_library_manager=manager,
|
||||
runtime_chat_service=NullRuntimeChatService(),
|
||||
).regenerate(asset_id, AgentAssetRiskRuleRegenerateRequest(), actor="manager")
|
||||
revision = regenerated.config_json["revision_draft"]
|
||||
db.add(
|
||||
AgentAssetTestRun(
|
||||
asset_id=asset_id,
|
||||
version="v0.1.1",
|
||||
test_type="report",
|
||||
status="passed",
|
||||
passed=True,
|
||||
summary="测试报告已确认。",
|
||||
input_json={},
|
||||
result_json={},
|
||||
created_by="manager",
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
|
||||
service = AgentAssetService(db)
|
||||
service.rule_library_manager = manager
|
||||
published = service.publish_risk_rule(asset_id, actor="manager")
|
||||
|
||||
assert published.status == AgentAssetStatus.ACTIVE.value
|
||||
assert published.current_version == "v0.1.1"
|
||||
assert published.published_version == "v0.1.1"
|
||||
assert "revision_draft" not in published.config_json
|
||||
assert published.config_json["rule_document"] == revision["rule_document"]
|
||||
assert published.config_json["revision_history"][0]["previous_rule_document"] == old_document
|
||||
assert published.config_json["last_operation"]["action"] == "publish_revision"
|
||||
manifest = manager.read_rule_library_json(
|
||||
library="risk-rules",
|
||||
file_name=published.config_json["rule_document"]["file_name"],
|
||||
)
|
||||
assert manifest["enabled"] is True
|
||||
assert manifest["rule_code"] == published.code
|
||||
|
||||
|
||||
def _create_rule(
|
||||
db: Session,
|
||||
tmp_path,
|
||||
*,
|
||||
manager: AgentAssetRuleLibraryManager | None = None,
|
||||
) -> str:
|
||||
return RiskRuleGenerationService(
|
||||
db,
|
||||
rule_library_manager=AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules"),
|
||||
rule_library_manager=manager or AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules"),
|
||||
runtime_chat_service=NullRuntimeChatService(),
|
||||
).generate_rule_asset(
|
||||
AgentAssetRiskRuleGenerateRequest(
|
||||
|
||||
65
server/tests/test_risk_rule_template_catalog.py
Normal file
65
server/tests/test_risk_rule_template_catalog.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
from app.services.risk_rule_dsl_validator import validate_risk_rule_draft
|
||||
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY
|
||||
from app.services.risk_rule_generation_ontology import FIELD_ONTOLOGY
|
||||
from app.services.risk_rule_template_catalog import (
|
||||
list_risk_rule_template_groups,
|
||||
list_risk_rule_templates,
|
||||
)
|
||||
|
||||
|
||||
def test_risk_rule_template_catalog_groups_and_dsl_examples() -> None:
|
||||
groups = list_risk_rule_template_groups()
|
||||
templates = list_risk_rule_templates()
|
||||
|
||||
assert [group["group"] for group in groups] == [
|
||||
"budget",
|
||||
"invoice",
|
||||
"travel",
|
||||
"entertainment",
|
||||
"procurement_ap",
|
||||
"corporate_card",
|
||||
"general",
|
||||
]
|
||||
assert len(templates) >= 8
|
||||
|
||||
for group in groups:
|
||||
assert group["templates"], group["group"]
|
||||
|
||||
for template in templates:
|
||||
assert template["title"]
|
||||
assert template["natural_language"]
|
||||
assert isinstance(template["requires_attachment"], bool)
|
||||
assert template["fields"]
|
||||
assert all("[" in field["display"] and "]" in field["display"] for field in template["fields"])
|
||||
assert template["dsl_example"]["template_key"] == COMPOSITE_RULE_TEMPLATE_KEY
|
||||
|
||||
normalized = validate_risk_rule_draft(
|
||||
template["dsl_example"]["params"],
|
||||
fields=list(FIELD_ONTOLOGY),
|
||||
natural_language=template["natural_language"],
|
||||
)
|
||||
assert normalized["template_key"] == COMPOSITE_RULE_TEMPLATE_KEY
|
||||
assert normalized["dsl_validation"]["status"] == "passed"
|
||||
assert normalized["conditions"]
|
||||
|
||||
|
||||
def test_risk_rule_template_endpoint_requires_login_and_returns_groups() -> None:
|
||||
client = TestClient(create_app())
|
||||
|
||||
unauthorized = client.get("/api/v1/agent-assets/risk-rules/templates")
|
||||
assert unauthorized.status_code == 401
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/agent-assets/risk-rules/templates",
|
||||
headers={"x-auth-username": "finance", "x-auth-role-codes": "finance"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload[0]["group"] == "budget"
|
||||
assert payload[0]["templates"][0]["dsl_example"]["params"]["conditions"]
|
||||
@@ -211,11 +211,11 @@ def test_user_agent_application_context_uses_application_language() -> None:
|
||||
assert "| 字段 | 内容 |" in response.answer
|
||||
assert "| 发生时间 | 2026-05-25 至 2026-05-27 |" in response.answer
|
||||
assert "支持上海国网服务器部署" in response.answer
|
||||
assert "当前还需要补充:出行方式、用户预估费用" in response.answer
|
||||
assert "当前还需要补充:出行方式" in response.answer
|
||||
assert "请先在下面选择报销场景" not in response.answer
|
||||
assert response.review_payload is None
|
||||
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:\n用户预估费用:"
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:"
|
||||
|
||||
|
||||
def test_user_agent_application_infers_natural_reason_and_expands_single_date() -> None:
|
||||
@@ -228,7 +228,7 @@ def test_user_agent_application_infers_natural_reason_and_expands_single_date()
|
||||
assert "| 地点 | 上海市 |" in response.answer
|
||||
assert "| 事由 | 支撑上海国网服务器部署 |" in response.answer
|
||||
assert "当前还需要先补充:申请事由" not in response.answer
|
||||
assert "当前还需要补充:出行方式、用户预估费用" in response.answer
|
||||
assert "当前还需要补充:出行方式" in response.answer
|
||||
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||
|
||||
|
||||
@@ -292,13 +292,13 @@ def test_user_agent_application_uses_selected_time_and_natural_language_fields()
|
||||
assert "| 发生时间 | 2026-05-25 |" in response.answer
|
||||
assert "| 地点 | 上海市 |" in response.answer
|
||||
assert "| 事由 | 支撑国网服务器上线部署 |" in response.answer
|
||||
assert "当前还需要补充:出行方式、用户预估费用" in response.answer
|
||||
assert "当前还需要补充:出行方式" in response.answer
|
||||
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||
assert response.suggested_actions[0].action_type == "prefill_composer"
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:\n用户预估费用:"
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:"
|
||||
|
||||
|
||||
def test_user_agent_application_asks_amount_after_transport_choice() -> None:
|
||||
def test_user_agent_application_builds_system_estimate_after_transport_choice() -> None:
|
||||
session_factory = build_session_factory()
|
||||
initial_message = (
|
||||
"发生时间:2026-05-25\n"
|
||||
@@ -313,11 +313,16 @@ def test_user_agent_application_asks_amount_after_transport_choice() -> None:
|
||||
history=[{"role": "user", "content": initial_message}],
|
||||
)
|
||||
|
||||
assert "这是费用申请核对结果" in response.answer
|
||||
assert "| 出行方式 | 飞机 |" in response.answer
|
||||
assert "当前还需要补充:用户预估费用" in response.answer
|
||||
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||
assert response.suggested_actions[0].action_type == "prefill_composer"
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "用户预估费用:"
|
||||
assert "| 系统预估费用 |" in response.answer
|
||||
assert "交通" in response.answer
|
||||
assert "参考票价" in response.answer
|
||||
assert "按 2026-05-25 参考票价" in response.answer
|
||||
assert "2,330元" in response.answer
|
||||
assert "查询耗时" in response.answer
|
||||
assert response.requires_confirmation is True
|
||||
assert response.suggested_actions == []
|
||||
|
||||
|
||||
def test_user_agent_application_missing_base_actions_prefill_composer() -> None:
|
||||
@@ -328,10 +333,10 @@ def test_user_agent_application_missing_base_actions_prefill_composer() -> None:
|
||||
"地点:上海\n事由:支撑国网服务器部署\n天数:3天",
|
||||
)
|
||||
|
||||
assert "当前还需要补充:出行方式、用户预估费用" in response.answer
|
||||
assert "当前还需要补充:出行方式" in response.answer
|
||||
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||
assert response.suggested_actions[0].action_type == "prefill_composer"
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:\n用户预估费用:"
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:"
|
||||
|
||||
|
||||
def test_user_agent_application_precomputes_time_from_today_and_days() -> None:
|
||||
@@ -346,7 +351,7 @@ def test_user_agent_application_precomputes_time_from_today_and_days() -> None:
|
||||
},
|
||||
)
|
||||
|
||||
assert "这是模拟的费用申请结果" in response.answer
|
||||
assert "这是费用申请核对结果" in response.answer
|
||||
assert "| 发生时间 | 2026-05-29 至 2026-05-31 |" in response.answer
|
||||
assert response.requires_confirmation is True
|
||||
|
||||
@@ -363,17 +368,27 @@ def test_user_agent_application_builds_preview_when_amount_is_ready() -> None:
|
||||
response = build_application_user_agent_response(
|
||||
db,
|
||||
"预计总费用:12000元",
|
||||
context_overrides={
|
||||
"name": "张三",
|
||||
"department_name": "交付部",
|
||||
"position": "实施经理",
|
||||
"manager_name": "李文静",
|
||||
},
|
||||
history=[
|
||||
{"role": "user", "content": initial_message},
|
||||
{"role": "user", "content": "飞机"},
|
||||
],
|
||||
)
|
||||
|
||||
assert "这是模拟的费用申请结果" in response.answer
|
||||
assert "这是费用申请核对结果" in response.answer
|
||||
assert "| 字段 | 内容 |" in response.answer
|
||||
assert "| 姓名 | 张三 |" in response.answer
|
||||
assert "| 部门 | 交付部 |" in response.answer
|
||||
assert "| 岗位 | 实施经理 |" in response.answer
|
||||
assert "| 直属领导 | 李文静 |" in response.answer
|
||||
assert "| 事由 | 支持上海国网服务器部署 |" in response.answer
|
||||
assert "| 出行方式 | 飞机 |" in response.answer
|
||||
assert "| 用户预估费用 | 12000元 |" in response.answer
|
||||
assert "| 系统预估费用 | 12000元 |" in response.answer
|
||||
assert "请核对上述信息无误" in response.answer
|
||||
assert "[确认](#application-submit)" in response.answer
|
||||
assert response.requires_confirmation is True
|
||||
@@ -389,7 +404,7 @@ def test_user_agent_application_submit_enters_leader_review() -> None:
|
||||
"天数:3天"
|
||||
)
|
||||
preview_answer = (
|
||||
"这是模拟的费用申请结果,请核对:\n"
|
||||
"这是费用申请核对结果,请核对:\n"
|
||||
"| 字段 | 内容 |\n"
|
||||
"| --- | --- |\n"
|
||||
"| 申请类型 | 差旅费用申请 |\n"
|
||||
@@ -398,7 +413,7 @@ def test_user_agent_application_submit_enters_leader_review() -> None:
|
||||
"| 事由 | 支持上海国网服务器部署 |\n"
|
||||
"| 天数 | 3天 |\n"
|
||||
"| 出行方式 | 飞机 |\n"
|
||||
"| 用户预估费用 | 12000元 |\n\n"
|
||||
"| 系统预估费用 | 12000元 |\n\n"
|
||||
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。"
|
||||
)
|
||||
with session_factory() as db:
|
||||
|
||||
Reference in New Issue
Block a user