feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造

- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制
- 引入费用审批动态路由、平台风险分级、预审与风险阶段管理
- 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板
- 新增 Hermes 风险线索收集器、Agent 链路追踪中心
- 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估
- 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-01 17:07:14 +08:00
parent 7989f3a159
commit 92444e7eae
285 changed files with 25075 additions and 2986 deletions

View File

@@ -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:

View 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"

View File

@@ -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()

View 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 == []

View 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"

View File

@@ -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:

View 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
)

View 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"

View File

@@ -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] == []

View 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

View File

@@ -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,
)
)

View File

@@ -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

View File

@@ -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"

View File

@@ -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",

View File

@@ -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"]

View 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",
}

View File

@@ -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()

View 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"

View File

@@ -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",
}

View File

@@ -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(

View 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"]

View File

@@ -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: