feat: 增强风险规则生成引擎与预算中心页面

后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块,
优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强
报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图
组件,重构审计页面和风险规则测试对话框交互,完善文档中心
和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-26 09:15:14 +08:00
parent d0e946cf47
commit 0e861d8fa6
150 changed files with 14953 additions and 4099 deletions

View File

@@ -0,0 +1,94 @@
from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
import pytest
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.document_numbering import (
build_document_number,
generate_unique_expense_claim_no,
is_application_claim_no,
)
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return factory()
def test_build_document_number_uses_kind_prefix_timestamp_and_token() -> None:
timestamp = datetime(2026, 5, 25, 10, 30, 45, tzinfo=UTC)
assert (
build_document_number("application", timestamp=timestamp, token="ABCDEFGH")
== "AP-20260525103045-ABCDEFGH"
)
assert (
build_document_number("reimbursement", timestamp=timestamp, token="ABCDEFGH")
== "RE-20260525103045-ABCDEFGH"
)
assert (
build_document_number("audit", timestamp=timestamp, token="ABCDEFGH")
== "AD-20260525103045-ABCDEFGH"
)
def test_build_document_number_rejects_ambiguous_token_chars() -> None:
timestamp = datetime(2026, 5, 25, 10, 30, 45, tzinfo=UTC)
with pytest.raises(ValueError):
build_document_number("application", timestamp=timestamp, token="ABCDEF10")
def test_generate_unique_expense_claim_no_retries_existing_candidate() -> None:
timestamp = datetime(2026, 5, 25, 10, 30, 45, tzinfo=UTC)
with build_session() as db:
db.add(
ExpenseClaim(
claim_no="RE-20260525103045-ABCDEFGH",
employee_name="张三",
department_name="市场部",
project_code=None,
expense_type="transport",
reason="交通报销",
location="深圳",
amount=Decimal("10.00"),
currency="CNY",
invoice_count=1,
occurred_at=timestamp,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
)
db.commit()
tokens = iter(["ABCDEFGH", "HGFEDCBA"])
assert (
generate_unique_expense_claim_no(
db,
"reimbursement",
timestamp=timestamp,
token_factory=lambda: next(tokens),
)
== "RE-20260525103045-HGFEDCBA"
)
def test_is_application_claim_no_supports_new_and_legacy_prefixes() -> None:
assert is_application_claim_no("AP-20260525103045-ABCDEFGH")
assert is_application_claim_no("APP-20260525-ABC123")
assert not is_application_claim_no("RE-20260525103045-ABCDEFGH")

View File

@@ -15,6 +15,8 @@ from app.models.organization import OrganizationUnit
from app.models.role import Role
from app.schemas.employee import EmployeeUpdate
from app.services.employee import EmployeeService
from app.services.employee_seed import CANONICAL_DEPARTMENT_CODES
from app.services.employee_time import format_history_datetime
def build_session() -> Session:
@@ -49,7 +51,7 @@ def test_employee_directory_seeds_rich_employee_data() -> None:
history_count = db.scalar(select(func.count()).select_from(EmployeeChangeLog))
assert role_count == 6
assert org_count == 10
assert org_count == 7
assert employee_count == 30
assert history_count and history_count >= 30
@@ -194,6 +196,42 @@ def test_employee_meta_includes_organization_options() -> None:
assert meta.organizationOptions
assert all(item.code and item.name for item in meta.organizationOptions)
assert [item.name for item in meta.organizationOptions] == [
"人力资源部",
"市场部",
"总裁办",
"技术部",
"生产部",
"财务部",
]
def test_employee_directory_normalizes_legacy_departments() -> None:
with build_session() as db:
service = EmployeeService(db)
service.list_employees()
legacy_department = OrganizationUnit(
unit_code="RND-CENTER",
name="产品研发中心",
unit_type="department",
)
employee = db.execute(
select(Employee).where(Employee.employee_no == "E11745")
).scalar_one()
employee.organization_unit = legacy_department
db.add(legacy_department)
db.commit()
refreshed = next(
item for item in service.list_employees() if item.employeeNo == "E11745"
)
meta = service.get_employee_meta()
assert refreshed.department == "技术部"
assert refreshed.organization is not None
assert refreshed.organization.code == "TECH-DEPT"
assert "RND-CENTER" not in {item.code for item in meta.organizationOptions}
def test_update_employee_changes_organization() -> None:
@@ -202,7 +240,11 @@ def test_update_employee_changes_organization() -> None:
employee = service.list_employees()[0]
organizations = service.repository.list_organization_units()
current_code = employee.organization.code if employee.organization else None
target = next(unit for unit in organizations if unit.unit_code != current_code)
target = next(
unit
for unit in organizations
if unit.unit_code in CANONICAL_DEPARTMENT_CODES and unit.unit_code != current_code
)
updated = service.update_employee(
employee.id,
@@ -245,7 +287,7 @@ def test_update_employee_changes_manager() -> None:
def test_format_history_datetime_uses_local_timezone_without_seconds() -> None:
value = datetime(2026, 5, 20, 6, 30, 45, tzinfo=UTC)
formatted = EmployeeService._format_history_datetime(value)
formatted = format_history_datetime(value)
assert formatted == "2026年5月20日14时30分"
assert "" not in formatted

View File

@@ -94,7 +94,7 @@ def test_import_employees_updates_existing_employee() -> None:
"上海",
employee.position,
employee.grade,
"FIN-SSC",
"FINANCE-DEPT",
"",
"华东财务组",
"CC-TEST",
@@ -132,7 +132,7 @@ def test_import_employees_creates_new_employee() -> None:
"上海",
"业务专员",
"P3",
"FIN-SSC",
"FINANCE-DEPT",
"E10234",
"华东财务组",
"CC-9001",

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import re
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
@@ -1042,7 +1043,7 @@ def test_upsert_draft_from_ontology_updates_returned_claim_and_preserves_return_
assert manual_returns == [return_flag]
def test_generate_claim_no_uses_max_suffix_instead_of_count() -> None:
def test_generate_claim_no_uses_re_prefix_timestamp_and_random_suffix() -> None:
with build_session() as db:
db.add_all(
[
@@ -1084,7 +1085,10 @@ def test_generate_claim_no_uses_max_suffix_instead_of_count() -> None:
service = ExpenseClaimService(db)
assert service._generate_claim_no(datetime(2026, 5, 14, tzinfo=UTC)) == "EXP-202605-004"
assert re.fullmatch(
r"RE-\d{14}-[A-HJ-NP-Z2-9]{8}",
service._generate_claim_no(datetime(2026, 5, 14, tzinfo=UTC)),
)
def test_upsert_draft_from_ontology_retries_claim_no_conflict() -> None:
@@ -1100,7 +1104,7 @@ def test_upsert_draft_from_ontology_retries_claim_no_conflict() -> None:
db.flush()
db.add(
ExpenseClaim(
claim_no="EXP-202605-004",
claim_no="RE-20260525101010-ABCDEFGH",
employee_name="历史单据",
department_name="财务部",
project_code=None,
@@ -1125,7 +1129,9 @@ def test_upsert_draft_from_ontology_retries_claim_no_conflict() -> None:
)
)
service = ExpenseClaimService(db)
generated_claim_nos = iter(["EXP-202605-004", "EXP-202605-005"])
generated_claim_nos = iter(
["RE-20260525101010-ABCDEFGH", "RE-20260525101010-HGFEDCBA"]
)
service._generate_claim_no = lambda occurred_at: next(generated_claim_nos)
result = service.upsert_draft_from_ontology(
@@ -1141,8 +1147,8 @@ def test_upsert_draft_from_ontology_retries_claim_no_conflict() -> None:
created_claim = db.get(ExpenseClaim, result["claim_id"])
assert created_claim is not None
assert created_claim.claim_no == "EXP-202605-005"
assert result["claim_no"] == "EXP-202605-005"
assert created_claim.claim_no == "RE-20260525101010-HGFEDCBA"
assert result["claim_no"] == "RE-20260525101010-HGFEDCBA"
def test_create_claim_item_adds_blank_draft_row_without_forcing_attachment() -> None:
@@ -2629,17 +2635,53 @@ def test_list_archived_claims_returns_company_archived_records_for_finance() ->
approval_stage="财务审批",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="AP-20260525120000-ABCDEFGH",
employee_name="",
department_name="C部",
project_code="PRJ-C",
expense_type="travel_application",
reason="C 申请",
location="成都",
amount=Decimal("800.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 11, 14, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 15, 0, tzinfo=UTC),
status="approved",
approval_stage="审批完成",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="AP-20260525123000-HGFEDCBA",
employee_name="",
department_name="D部",
project_code="PRJ-D",
expense_type="travel_application",
reason="D 申请",
location="北京",
amount=Decimal("500.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 11, 16, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 17, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
),
]
)
db.commit()
claims = ExpenseClaimService(db).list_archived_claims(current_user)
assert len(claims) == 1
assert claims[0].claim_no == "EXP-ARCH-101"
assert {claim.claim_no for claim in claims} == {
"EXP-ARCH-101",
"AP-20260525120000-ABCDEFGH",
}
def test_list_archived_claims_is_empty_for_regular_employee() -> None:
def test_list_archived_claims_returns_only_own_records_for_regular_employee() -> None:
current_user = CurrentUserContext(
username="zhangsan@example.com",
name="张三",
@@ -2648,30 +2690,49 @@ def test_list_archived_claims_is_empty_for_regular_employee() -> None:
)
with build_session() as db:
db.add(
ExpenseClaim(
claim_no="EXP-ARCH-EMP",
employee_name="张三",
department_name="研发部",
project_code="PRJ-EMP",
expense_type="travel",
reason="本人报销",
location="北京",
amount=Decimal("200.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 10, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 10, 10, 0, tzinfo=UTC),
status="approved",
approval_stage="归档入账",
risk_flags_json=[],
)
db.add_all(
[
ExpenseClaim(
claim_no="EXP-ARCH-EMP",
employee_name="张三",
department_name="研发部",
project_code="PRJ-EMP",
expense_type="travel",
reason="本人报销",
location="北京",
amount=Decimal("200.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 10, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 10, 10, 0, tzinfo=UTC),
status="approved",
approval_stage="归档入账",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="AP-20260525130000-ABCDEFGH",
employee_name="李四",
department_name="研发部",
project_code="PRJ-EMP",
expense_type="travel_application",
reason="他人申请",
location="上海",
amount=Decimal("500.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 10, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 10, 10, 0, tzinfo=UTC),
status="approved",
approval_stage="审批完成",
risk_flags_json=[],
),
]
)
db.commit()
claims = ExpenseClaimService(db).list_archived_claims(current_user)
assert claims == []
assert [claim.claim_no for claim in claims] == ["EXP-ARCH-EMP"]
def test_finance_can_return_but_cannot_delete_submitted_claim() -> None:
@@ -2760,6 +2821,79 @@ def test_executive_can_delete_submitted_claim() -> None:
assert db.get(ExpenseClaim, claim_id) is None
def test_executive_cannot_delete_archived_claim() -> None:
current_user = CurrentUserContext(
username="executive-archive-delete@example.com",
name="高管",
role_codes=["executive"],
is_admin=False,
)
with build_session() as db:
claim = ExpenseClaim(
claim_no="EXP-DEL-ARCHIVE-101",
employee_name="张三",
department_name="市场部",
project_code="PRJ-A",
expense_type="travel",
reason="差旅报销",
location="上海",
amount=Decimal("120.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
status="approved",
approval_stage="归档入账",
risk_flags_json=[],
)
db.add(claim)
db.commit()
claim_id = claim.id
with pytest.raises(ValueError, match="已归档单据不能删除"):
ExpenseClaimService(db).delete_claim(claim_id, current_user)
assert db.get(ExpenseClaim, claim_id) is not None
def test_admin_can_delete_archived_claim() -> None:
current_user = CurrentUserContext(
username="superadmin",
name="系统管理员",
role_codes=["manager"],
is_admin=True,
)
with build_session() as db:
claim = ExpenseClaim(
claim_no="EXP-DEL-ARCHIVE-102",
employee_name="张三",
department_name="市场部",
project_code="PRJ-A",
expense_type="travel",
reason="差旅报销",
location="上海",
amount=Decimal("120.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
status="approved",
approval_stage="归档入账",
risk_flags_json=[],
)
db.add(claim)
db.commit()
claim_id = claim.id
deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)
assert deleted is not None
assert deleted.claim_no == "EXP-DEL-ARCHIVE-102"
assert db.get(ExpenseClaim, claim_id) is None
def test_direct_manager_can_return_subordinate_claim_to_pending_submission() -> None:
current_user = CurrentUserContext(
username="manager-return@example.com",
@@ -2945,7 +3079,7 @@ def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch
)
def test_direct_manager_can_approve_application_claim_to_completed_stage() -> None:
def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() -> None:
current_user = CurrentUserContext(
username="manager-application-approve@example.com",
name="李经理",
@@ -2998,6 +3132,35 @@ def test_direct_manager_can_approve_application_claim_to_completed_stage() -> No
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == "审批完成"
archived_claims = ExpenseClaimService(db).list_archived_claims(
CurrentUserContext(
username="finance-archive@example.com",
name="财务归档员",
role_codes=["finance"],
is_admin=False,
)
)
assert any(claim.claim_no == "APP-20260525-APPROVE" for claim in archived_claims)
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
assert generated_draft.status == "draft"
assert generated_draft.approval_stage == "待提交"
assert generated_draft.expense_type == "travel"
assert generated_draft.employee_id == employee.id
assert generated_draft.employee_name == "张三"
assert generated_draft.department_name == "交付部"
assert generated_draft.reason == "支撑国网服务器上线部署"
assert generated_draft.location == "上海"
assert generated_draft.amount == Decimal("12000.00")
assert generated_draft.invoice_count == 0
assert generated_draft.items == []
assert any(
isinstance(flag, dict)
and flag.get("source") == "application_handoff"
and flag.get("event_type") == "expense_application_to_reimbursement_draft"
and flag.get("application_claim_no") == "APP-20260525-APPROVE"
and flag.get("leader_opinion") == "业务必要,同意申请。"
for flag in generated_draft.risk_flags_json
)
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_approval"
@@ -3006,10 +3169,69 @@ def test_direct_manager_can_approve_application_claim_to_completed_stage() -> No
and flag.get("previous_approval_stage") == "直属领导审批"
and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == "审批完成"
and flag.get("generated_draft_claim_id") == generated_draft.id
and flag.get("generated_draft_claim_no") == generated_draft.claim_no
for flag in approved.risk_flags_json
)
def test_direct_manager_approval_requires_leader_opinion() -> None:
current_user = CurrentUserContext(
username="manager-application-required-opinion@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E8122",
name="李经理",
email="manager-application-required-opinion@example.com",
)
employee = Employee(
employee_no="E8123",
name="张三",
email="zhangsan-application-required-opinion@example.com",
manager=manager,
)
db.add_all([manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="APP-20260525-REQUIRE-OPINION",
employee_id=employee.id,
employee_name="张三",
department_name="交付部",
project_code="PRJ-A",
expense_type="travel_application",
reason="支撑国网服务器上线部署",
location="上海",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
db.add(claim)
db.commit()
claim_id = claim.id
with pytest.raises(ValueError, match="领导审核意见不能为空"):
ExpenseClaimService(db).approve_claim(
claim_id,
current_user,
opinion=" ",
)
db.refresh(claim)
assert claim.status == "submitted"
assert claim.approval_stage == "直属领导审批"
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
def test_finance_can_approve_claim_to_archive_stage() -> None:
current_user = CurrentUserContext(
username="finance-approve@example.com",

View File

@@ -244,10 +244,10 @@ def test_semantic_ontology_service_matches_day3_evaluation_set(
assert result.run_id.startswith("run_")
def test_semantic_ontology_service_extracts_entities_time_and_constraints() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
def test_semantic_ontology_service_extracts_entities_time_and_constraints() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="张三 2026年4月差旅报销金额超过5000元的明细",
user_id="pytest",
@@ -255,9 +255,30 @@ def test_semantic_ontology_service_extracts_entities_time_and_constraints() -> N
)
assert result.scenario == "expense"
assert result.intent == "query"
assert result.time_range.start_date == "2026-04-01"
assert result.time_range.end_date == "2026-04-30"
assert result.intent == "query"
assert result.time_range.start_date == "2026-04-01"
assert result.time_range.end_date == "2026-04-30"
def test_semantic_ontology_service_extracts_new_document_numbers() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="查询 RE-20260525103045-ABCDEFGH 和 AP-20260525113045-HGFEDCBA 的状态",
user_id="pytest",
)
)
claim_codes = {
item.normalized_value
for item in result.entities
if item.type == "expense_claim"
}
assert claim_codes == {
"RE-20260525103045-ABCDEFGH",
"AP-20260525113045-HGFEDCBA",
}
def test_semantic_ontology_service_treats_travel_amount_question_as_knowledge_query() -> None:

View File

@@ -687,14 +687,14 @@ 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"] == "出行方式:\n用户预估费用:"
assert "当前还需要补充:预计金额/预算" in second.result["answer"]
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 second.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "用户预估费用:"
assert "这是模拟的费用申请结果" in third.result["answer"]
assert "| 事由 | 支持上海国网服务器部署 |" in third.result["answer"]
@@ -713,7 +713,7 @@ def test_orchestrator_application_session_guides_transport_amount_and_submit(
application_claims = [
claim
for claim in db.query(ExpenseClaim).all()
if claim.claim_no.startswith("APP-20260525-")
if claim.claim_no.startswith("AP-")
]
assert len(application_claims) == 1
assert application_claims[0].status == "submitted"

View File

@@ -0,0 +1,361 @@
from __future__ import annotations
import json
from datetime import UTC, date, datetime
from decimal import Decimal
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.core.agent_enums import AgentAssetDomain
from app.db.base import Base
from app.models.agent_asset import AgentAsset
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.risk_rule_generation import RiskRuleGenerationService
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
class NullRuntimeChatService:
def complete(self, *args, **kwargs) -> None:
return None
class CompositeRuntimeChatService:
def complete(self, *args, **kwargs) -> str:
return json.dumps(
{
"name": "招待发票说明校验",
"description": "招待报销已取得发票但缺少客户说明时进入复核。",
"template_key": "composite_rule_v1",
"semantic_type": "entertainment_invoice_reason_check",
"field_keys": ["attachment.invoice_no", "claim.reason"],
"condition_summary": "D=发票号码E=报销事由D存在且E未说明客户名称时命中。",
"rule_ir": {
"facts": [
{"id": "D", "label": "发票号码", "fields": ["attachment.invoice_no"]},
{"id": "E", "label": "报销事由", "fields": ["claim.reason"]},
],
"hit_logic": "D AND NOT CONTAINS(E, 客户)",
},
"conditions": [
{
"id": "invoice_present",
"operator": "exists_any",
"fields": ["attachment.invoice_no"],
},
{
"id": "missing_customer_reason",
"operator": "not_contains_any",
"fields": ["claim.reason"],
"keywords": ["客户", "拜访对象"],
},
],
"hit_logic": {"all": ["invoice_present", "missing_customer_reason"]},
"formula": "HIT WHEN EXISTS(invoice_no) AND NOT CONTAINS(reason, 客户|拜访对象)",
"message_template": "招待发票已上传,但事由缺少客户或拜访对象说明。",
"keywords": [],
"exception_keywords": [],
"flow": {
"start": "招待报销提交",
"evidence": "读取发票号码和报销事由",
"decision": "是否有发票且事由缺少客户说明",
"pass": "客户说明完整,继续流转",
"fail": "缺少客户说明,进入复核",
},
},
ensure_ascii=False,
)
class LodgingSemanticRuntimeChatService:
def complete(self, *args, **kwargs) -> str:
return json.dumps(
{
"name": "住宿城市日期一致性校验",
"description": "住宿票据的城市和日期需要能对应本次差旅行程,缺少合理说明时进入复核。",
"template_key": "composite_rule_v1",
"semantic_type": "travel_lodging_city_date_consistency",
"field_keys": [
"attachment.hotel_city",
"attachment.stay_start_date",
"attachment.stay_end_date",
"attachment.issue_date",
"claim.location",
"item.item_location",
"attachment.route_cities",
"claim.trip_start_date",
"claim.trip_end_date",
"item.item_date",
"claim.reason",
"item.item_reason",
],
"condition_summary": (
"D=住宿票据事实A=住宿城市B=本次行程城市范围T=住宿日期或开票日期,"
"R=出差起止日期D存在且[(A不属于B)或(T超出R)]且无合理说明时命中。"
),
"rule_ir": {
"facts": [
{"id": "D", "label": "住宿票据事实", "fields": ["attachment.hotel_city", "attachment.ocr_text"]},
{"id": "A", "label": "住宿城市", "fields": ["attachment.hotel_city"]},
{"id": "B", "label": "本次行程城市范围", "fields": ["claim.location", "item.item_location", "attachment.route_cities"]},
{"id": "T", "label": "住宿日期或开票日期", "fields": ["attachment.stay_start_date", "attachment.stay_end_date", "attachment.issue_date"]},
{"id": "R", "label": "出差起止日期", "fields": ["claim.trip_start_date", "claim.trip_end_date", "item.item_date"]},
{"id": "E", "label": "合理说明", "fields": ["claim.reason", "item.item_reason"]},
],
"hit_logic": "D AND ((A NOT_IN B) OR DATE_OUTSIDE(T,R)) AND NOT CONTAINS(E, exception_keywords)",
},
"conditions": [
{
"id": "lodging_document_present",
"operator": "exists_any",
"fields": ["attachment.hotel_city", "attachment.ocr_text"],
},
{
"id": "lodging_city_outside_trip_scope",
"operator": "not_in_scope",
"left_fields": ["attachment.hotel_city"],
"right_fields": ["claim.location", "item.item_location", "attachment.route_cities"],
},
{
"id": "lodging_date_outside_trip_range",
"operator": "date_outside_range",
"date_fields": ["attachment.stay_start_date", "attachment.stay_end_date", "attachment.issue_date"],
"range_start_fields": ["claim.trip_start_date", "item.item_date"],
"range_end_fields": ["claim.trip_end_date", "item.item_date"],
},
{
"id": "missing_reasonable_exception",
"operator": "not_contains_any",
"fields": ["claim.reason", "item.item_reason"],
"keywords": ["延期", "改签", "临时任务"],
},
],
"hit_logic": {
"all": [
"lodging_document_present",
{"any": ["lodging_city_outside_trip_scope", "lodging_date_outside_trip_range"]},
"missing_reasonable_exception",
]
},
"formula": "D AND ((A NOT_IN B) OR DATE_OUTSIDE(T,R)) AND NOT EXCEPTION(E)",
"message_template": "住宿票据城市或日期无法与本次差旅行程形成一致关系,且未识别到合理说明。",
"keywords": [],
"exception_keywords": ["延期", "改签", "临时任务"],
"flow": {
"start": "差旅住宿报销提交",
"evidence": "读取住宿票据事实、行程范围和合理说明",
"decision": "住宿城市或日期是否脱离本次行程,且是否缺少合理说明",
"pass": "城市和日期均能对应行程,或已有合理说明",
"fail": "城市或日期无法对应行程,进入复核",
},
},
ensure_ascii=False,
)
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 _read_payload(manager: AgentAssetRuleLibraryManager, asset: AgentAsset) -> dict:
return manager.read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=asset.config_json["rule_document"]["file_name"],
)
def test_lodging_city_date_rule_generates_explainable_composite_json(tmp_path) -> None:
text = (
"差旅住宿报销时,先确认已上传住宿发票或酒店水单;再读取报销事由、申报目的地、"
"住宿城市、开票日期和行程城市。若住宿票据显示的城市不在本次差旅行程范围内,"
"或住宿发生时间明显早于出差开始、晚于出差结束,且没有延期、改签、临时任务等说明,"
"则标记为高风险,要求补充住宿原因、行程证明或重新提交票据。"
)
with build_session() as db:
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
service = RiskRuleGenerationService(
db,
rule_library_manager=manager,
runtime_chat_service=LodgingSemanticRuntimeChatService(),
)
asset_id = service.generate_rule_asset(
AgentAssetRiskRuleGenerateRequest(
business_domain=AgentAssetDomain.EXPENSE,
expense_category="hotel",
rule_title="住宿城市日期一致性校验",
risk_level="high",
natural_language=text,
requires_attachment=True,
),
actor="pytest",
)
asset = db.get(AgentAsset, asset_id)
assert asset is not None
payload = _read_payload(manager, asset)
assert payload["template_key"] == "composite_rule_v1"
assert payload["semantic_type"] == "travel_lodging_city_date_consistency"
assert payload["params"]["semantic_type"] == "travel_lodging_city_date_consistency"
assert payload["params"]["keywords"] == []
assert "风险关键词" not in payload["params"]["condition_summary"]
assert "attachment.stay_start_date" in payload["params"]["field_keys"]
assert "claim.trip_start_date" in payload["params"]["field_keys"]
assert payload["params"]["rule_ir"]["facts"]
assert payload["params"]["hit_logic"]["all"][1]["any"] == [
"lodging_city_outside_trip_scope",
"lodging_date_outside_trip_range",
]
def test_composite_lodging_executor_hits_mismatch_and_respects_exception() -> None:
manifest = {
"template_key": "composite_rule_v1",
"params": {
"template_key": "composite_rule_v1",
"semantic_type": "travel_lodging_city_date_consistency",
"condition_summary": "住宿城市或日期不在本次差旅行程范围内且无合理说明时命中。",
"conditions": [
{
"id": "lodging_document_present",
"operator": "exists_any",
"fields": ["attachment.hotel_city", "attachment.ocr_text"],
},
{
"id": "lodging_city_outside_trip_scope",
"operator": "not_in_scope",
"left_fields": ["attachment.hotel_city"],
"right_fields": ["claim.location", "item.item_location", "attachment.route_cities"],
},
{
"id": "lodging_date_outside_trip_range",
"operator": "date_outside_range",
"date_fields": ["attachment.stay_start_date", "attachment.stay_end_date"],
"range_start_fields": ["claim.trip_start_date", "item.item_date"],
"range_end_fields": ["claim.trip_end_date", "item.item_date"],
},
{
"id": "missing_reasonable_exception",
"operator": "not_contains_any",
"fields": ["claim.reason", "item.item_reason"],
"keywords": ["延期", "改签", "临时任务"],
},
],
"hit_logic": {
"all": [
"lodging_document_present",
{"any": ["lodging_city_outside_trip_scope", "lodging_date_outside_trip_range"]},
"missing_reasonable_exception",
]
},
},
"outcomes": {"fail": {"severity": "high"}},
}
claim = _build_claim(reason="去上海出差住宿", location="上海")
contexts = [
{
"document_info": {
"hotel_city": "北京",
"stay_start_date": "2026-05-11",
"stay_end_date": "2026-05-12",
"fields": [
{"key": "hotel_city", "label": "住宿城市", "value": "北京"},
{"key": "stay_start_date", "label": "入住日期", "value": "2026-05-11"},
{"key": "stay_end_date", "label": "离店日期", "value": "2026-05-12"},
],
},
"ocr_text": "北京酒店住宿发票",
}
]
result = RiskRuleTemplateExecutor().evaluate(manifest, claim=claim, contexts=contexts)
assert result is not None
assert result["evidence"]["condition_results"]["lodging_city_outside_trip_scope"] is True
claim_with_exception = _build_claim(reason="去上海出差住宿,因临时任务改签至北京", location="上海")
assert RiskRuleTemplateExecutor().evaluate(
manifest, claim=claim_with_exception, contexts=contexts
) is None
def test_model_generated_composite_rule_is_preserved_for_other_categories(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=CompositeRuntimeChatService(),
)
asset_id = service.generate_rule_asset(
AgentAssetRiskRuleGenerateRequest(
business_domain=AgentAssetDomain.EXPENSE,
expense_category="meal",
rule_title="招待发票客户说明校验",
risk_level="medium",
natural_language="招待报销时,如果已经上传发票但报销事由没有客户或拜访对象说明,则提示中风险。",
),
actor="pytest",
)
asset = db.get(AgentAsset, asset_id)
assert asset is not None
payload = _read_payload(manager, asset)
assert payload["template_key"] == "composite_rule_v1"
assert payload["semantic_type"] == "entertainment_invoice_reason_check"
assert payload["params"]["conditions"][0]["operator"] == "exists_any"
assert payload["params"]["hit_logic"] == {"all": ["invoice_present", "missing_customer_reason"]}
assert payload["params"]["message_template"] == "招待发票已上传,但事由缺少客户或拜访对象说明。"
claim = _build_claim(reason="招待费", location="上海")
result = RiskRuleTemplateExecutor().evaluate(
payload,
claim=claim,
contexts=[{"document_info": {"invoice_no": "INV-20260526001"}}],
)
assert result is not None
assert result["message"] == "招待发票已上传,但事由缺少客户或拜访对象说明。"
claim.reason = "招待客户 ACME 的餐费"
assert RiskRuleTemplateExecutor().evaluate(
payload,
claim=claim,
contexts=[{"document_info": {"invoice_no": "INV-20260526001"}}],
) is None
def _build_claim(*, reason: str, location: str) -> ExpenseClaim:
claim = ExpenseClaim(
claim_no="TEST-COMPOSITE-RISK",
employee_name="测试员工",
department_name="测试部门",
expense_type="差旅费",
reason=reason,
location=location,
amount=Decimal("680.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, tzinfo=UTC),
status="draft",
)
claim.trip_start_date = date(2026, 5, 10)
claim.trip_end_date = date(2026, 5, 12)
claim.items = [
ExpenseClaimItem(
item_date=date(2026, 5, 11),
item_type="住宿费",
item_reason=reason,
item_location=location,
item_amount=Decimal("680.00"),
)
]
return claim

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
import json
from datetime import UTC, datetime
from datetime import UTC, date, datetime
from decimal import Decimal
from sqlalchemy import create_engine
@@ -11,7 +11,8 @@ from sqlalchemy.pool import StaticPool
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentReviewStatus
from app.db.base import Base
from app.models.agent_asset import AgentAsset
from app.models.financial_record import ExpenseClaim
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.schemas.agent_asset import (
AgentAssetReviewCreate,
AgentAssetRiskRuleGenerateRequest,
@@ -23,11 +24,15 @@ from app.schemas.agent_asset import (
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.agent_assets import AgentAssetService
from app.services.agent_foundation_risk_rules import AgentFoundationRiskRuleMixin
from app.services.risk_rule_flow_diagram import (
RiskRuleFlowDiagramRenderer,
RiskRuleFlowDiagramSpec,
)
from app.services.risk_rule_generation import RiskRuleGenerationService
from app.services.risk_rule_generation_jobs import RiskRuleGenerationJobService
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
class NullRuntimeChatService:
@@ -35,6 +40,41 @@ class NullRuntimeChatService:
return None
class TravelRouteSemanticRuntimeChatService:
def complete(self, *args, **kwargs) -> str:
return json.dumps(
{
"name": "差旅票据路线一致性校验",
"description": "交通票或住宿票据城市需要与申报行程形成一致关系。",
"template_key": "field_compare_v1",
"semantic_type": "travel_route_city_consistency",
"field_keys": [
"attachment.route_cities",
"attachment.hotel_city",
"claim.location",
"item.item_location",
"employee.location",
"claim.reason",
"item.item_reason",
],
"condition_summary": (
"A=交通票行程城市住宿发票城市B=申报目的地∪明细发生地点,"
"C=员工常驻地A与B无交集且无合理说明或A出现BC之外城市时命中。"
),
"keywords": [],
"exception_keywords": ["绕行", "跨城办事", "临时改签"],
"flow": {
"start": "差旅报销提交",
"evidence": "读取票据城市、申报地点、明细地点和报销事由",
"decision": "票据城市是否覆盖申报行程,是否出现额外中转城市",
"pass": "票据城市与申报行程一致",
"fail": "票据城市与申报行程不一致,进入复核",
},
},
ensure_ascii=False,
)
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
@@ -58,6 +98,7 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
AgentAssetRiskRuleGenerateRequest(
business_domain=AgentAssetDomain.EXPENSE,
expense_category="travel",
rule_title="差旅住宿城市一致性校验",
risk_level="high",
natural_language="住宿城市必须出现在本次差旅行程城市中,否则提示高风险。",
),
@@ -66,6 +107,7 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
asset = db.get(AgentAsset, asset_id)
assert asset is not None
assert asset.name == "差旅住宿城市一致性校验"
assert asset.status == AgentAssetStatus.DRAFT.value
assert asset.config_json["detail_mode"] == "json_risk"
assert asset.config_json["evaluator"] == "template_rule"
@@ -78,17 +120,23 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
rule_path = tmp_path / "rules" / RISK_RULES_LIBRARY / file_name
payload = json.loads(rule_path.read_text(encoding="utf-8"))
assert payload["rule_code"] == asset.code
assert payload["name"] == "差旅住宿城市一致性校验"
assert payload["applies_to"]["expense_categories"] == ["travel"]
assert payload["risk_category"] == "差旅费"
assert payload["metadata"]["expense_category"] == "travel"
assert payload["metadata"]["rule_title"] == "差旅住宿城市一致性校验"
assert payload["outcomes"]["fail"]["severity"] == "high"
assert payload["template_key"] == "field_compare_v1"
assert payload["metadata"]["natural_language"].startswith("住宿城市")
assert payload["inputs"]["fields"]
assert payload["flow_diagram_svg"].startswith("<svg")
assert 'width="760" height="280"' in payload["flow_diagram_svg"]
assert 'width="860" height="360"' in payload["flow_diagram_svg"]
assert 'data-risk-flow-style="review-node-only"' in payload["flow_diagram_svg"]
assert 'data-risk-flow-detail="logic-v2"' in payload["flow_diagram_svg"]
assert "RULE FLOW" in payload["flow_diagram_svg"]
assert "字段事实" in payload["flow_diagram_svg"]
assert "判断条件" in payload["flow_diagram_svg"]
assert "命中逻辑" in payload["flow_diagram_svg"]
assert "进入复核" in payload["flow_diagram_svg"]
assert "" in payload["flow_diagram_svg"]
assert "" in payload["flow_diagram_svg"]
@@ -99,6 +147,113 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
assert "feDropShadow" not in payload["flow_diagram_svg"]
def test_set_risk_rule_level_updates_manifest_config_and_flow_svg(tmp_path) -> None:
with build_session() as db:
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
generator = RiskRuleGenerationService(
db,
rule_library_manager=manager,
runtime_chat_service=NullRuntimeChatService(),
)
asset_id = generator.generate_rule_asset(
AgentAssetRiskRuleGenerateRequest(
business_domain=AgentAssetDomain.EXPENSE,
expense_category="travel",
rule_title="差旅住宿城市一致性校验",
risk_level="high",
natural_language="住宿城市必须出现在本次差旅行程城市中,否则提示高风险。",
),
actor="pytest",
)
asset_service = AgentAssetService(db)
asset_service.rule_library_manager = manager
updated = asset_service.set_risk_rule_level(
asset_id,
risk_level="low",
actor="pytest",
)
assert updated.config_json["severity"] == "low"
asset = db.get(AgentAsset, asset_id)
assert asset is not None
assert asset.config_json["risk_level_label"] == "低风险"
file_name = asset.config_json["rule_document"]["file_name"]
payload = manager.read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=file_name,
)
assert payload["outcomes"]["fail"]["severity"] == "low"
assert payload["metadata"]["risk_level"] == "low"
assert payload["metadata"]["risk_level_label"] == "低风险"
assert "低风险" in payload["metadata"]["flow"]["fail"]
assert "#2563eb" in payload["flow_diagram_svg"]
assert "#dc2626" not in payload["flow_diagram_svg"]
version = asset_service.repository.get_version(asset_id, asset.working_version)
assert version is not None
assert '"severity": "low"' in version.content
def test_enqueue_risk_rule_generation_creates_visible_generating_asset(tmp_path) -> None:
with build_session() as db:
service = RiskRuleGenerationJobService(
db,
rule_library_manager=AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules"),
runtime_chat_service=NullRuntimeChatService(),
)
request = AgentAssetRiskRuleGenerateRequest(
business_domain=AgentAssetDomain.EXPENSE,
expense_category="travel",
rule_title="差旅城市一致性校验",
risk_level="high",
natural_language="住宿城市必须出现在本次差旅行程城市中,否则提示高风险。",
)
asset_id = service.enqueue_rule_asset_generation(request, actor="pytest")
asset = db.get(AgentAsset, asset_id)
assert asset is not None
assert asset.status == AgentAssetStatus.GENERATING.value
assert asset.owner == "pytest"
assert asset.name == "差旅城市一致性校验"
assert asset.config_json["generation_status"] == "generating"
assert asset.config_json["expense_category_label"] == "差旅费"
assert asset.current_version is None
detail = AgentAssetService(db).get_asset(asset_id)
assert detail is not None
assert detail.status == AgentAssetStatus.GENERATING.value
assert detail.latest_test_summary is None
service.complete_rule_asset_generation(asset_id, request, actor="pytest")
db.refresh(asset)
assert asset.status == AgentAssetStatus.DRAFT.value
assert asset.working_version == "v0.1.0"
assert asset.config_json["generation_status"] == "completed"
assert asset.config_json["expense_category_label"] == "差旅费"
assert asset.scenario_json == ["差旅费"]
def test_platform_risk_sync_skips_natural_language_drafts() -> None:
assert AgentFoundationRiskRuleMixin._is_user_generated_risk_manifest(
{
"rule_code": "risk.expense.travel.generated_20260525123000000000",
"metadata": {
"stability": "generated_draft",
"source_ref": "自然语言风险规则",
},
}
)
assert not AgentFoundationRiskRuleMixin._is_user_generated_risk_manifest(
{
"rule_code": "risk.travel.destination_location_mismatch",
"metadata": {"source_ref": "平台内置风险规则"},
}
)
def test_risk_rule_flow_diagram_uses_risk_level_palette() -> None:
renderer = RiskRuleFlowDiagramRenderer()
@@ -123,10 +278,380 @@ def test_risk_rule_flow_diagram_uses_risk_level_palette() -> None:
assert "#f97316" in render("medium", "中风险")
high_svg = render("high", "高风险")
assert "#dc2626" in high_svg
assert high_svg.count("#dc2626") == 1
assert high_svg.count("#dc2626") >= 1
assert "#10a37f" not in high_svg
def test_risk_rule_simulation_extracts_ticket_route_cities() -> None:
with build_session() as db:
service = AgentAssetService(db)
value = service._find_attachment_field_value(
"attachment.route_cities",
"行程城市",
[
{
"document_fields": [
{"key": "route", "label": "行程路线", "value": "上海虹桥-武汉"}
],
"ocr_text": "G123 上海虹桥 至 武汉 二等座",
"summary": "高铁票 上海虹桥-武汉",
}
],
)
assert value == ["上海", "武汉"]
def test_current_keyword_city_consistency_rule_hits_ticket_city_mismatch() -> None:
manifest = {
"template_key": "keyword_match_v1",
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"attachment.hotel_city",
"claim.location",
"attachment.route_cities",
"item.item_location",
],
"search_fields": [
"attachment.hotel_city",
"claim.location",
"attachment.route_cities",
"item.item_location",
],
"natural_language": "差旅报销时,交通票或住宿票据中的城市必须与申报目的地一致;未说明绕行、跨城或改签原因时标记风险。",
"condition_summary": "检查住宿城市、申报地点、行程城市是否一致",
"keywords": ["绕行", "跨城", "改签", "变更"],
},
"outcomes": {"fail": {"severity": "medium"}},
}
claim = ExpenseClaim(
claim_no="TEST-CURRENT-RISK",
employee_name="测试员工",
department_name="测试部门",
expense_type="差旅费",
reason="去北京出差3天",
location="北京",
amount=Decimal("320.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime.now(UTC),
status="draft",
)
claim.items = [
ExpenseClaimItem(
item_date=date.today(),
item_type="交通费",
item_reason="去北京出差3天",
item_location="北京",
item_amount=Decimal("320.00"),
)
]
result = RiskRuleTemplateExecutor().evaluate(
manifest,
claim=claim,
contexts=[
{
"document_info": {
"route_cities": ["武汉", "上海"],
"fields": [
{"key": "route_cities", "label": "行程城市", "value": ["武汉", "上海"]}
],
},
"ocr_text": "武汉 到 上海",
}
],
)
assert result is not None
assert result["evidence"]["city_consistency"]["attachment_values"] == ["武汉", "上海"]
assert result["evidence"]["city_consistency"]["reference_values"] == ["北京"]
def test_generate_complex_travel_route_rule_uses_formula_not_keyword_match(tmp_path) -> None:
text = (
"差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;"
"再读取申报目的地、明细发生地点、交通票行程城市和住宿发票城市。"
"若交通票或住宿票据中的城市均无法与申报目的地、明细地点形成一致关系,"
"且报销事由中没有说明绕行、跨城办事或临时改签原因,则标记为高风险,"
"要求补充行程说明或退回修改。"
)
with build_session() as db:
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
service = RiskRuleGenerationService(
db,
rule_library_manager=manager,
runtime_chat_service=TravelRouteSemanticRuntimeChatService(),
)
asset_id = service.generate_rule_asset(
AgentAssetRiskRuleGenerateRequest(
business_domain=AgentAssetDomain.EXPENSE,
expense_category="travel",
rule_title="差旅票据路线一致性校验",
risk_level="high",
natural_language=text,
requires_attachment=True,
),
actor="pytest",
)
asset = db.get(AgentAsset, asset_id)
assert asset is not None
payload = manager.read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=asset.config_json["rule_document"]["file_name"],
)
assert payload["template_key"] == "field_compare_v1"
assert payload["semantic_type"] == "travel_route_city_consistency"
assert payload["params"]["semantic_type"] == "travel_route_city_consistency"
assert payload["params"]["keywords"] == []
assert payload["params"]["exception_keywords"][:3] == ["绕行", "跨城办事", "跨城"]
assert "A=交通票行程城市" in payload["params"]["condition_summary"]
assert "风险关键词" not in payload["params"]["condition_summary"]
assert "employee.location" in payload["params"]["field_keys"]
assert "route_anomaly_policy" in payload["params"]
def test_legacy_city_route_keyword_manifest_is_normalized_before_display_and_execution() -> None:
manifest = {
"schema_version": "2.0",
"rule_code": "risk.expense.travel.legacy_city_keyword",
"name": "差旅票据路线一致性校验",
"description": "差旅报销时读取申报目的地、明细发生地点、交通票行程城市和住宿发票城市。",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"risk_category": "差旅费",
"inputs": {
"fields": [
{"key": "attachment.hotel_city", "label": "住宿城市"},
{"key": "claim.location", "label": "申报地点"},
{"key": "attachment.route_cities", "label": "行程城市"},
]
},
"params": {
"template_key": "keyword_match_v1",
"field_keys": [
"attachment.hotel_city",
"claim.location",
"attachment.route_cities",
],
"search_fields": [
"attachment.hotel_city",
"claim.location",
"attachment.route_cities",
],
"keywords": ["绕行", "跨城办事", "临时改签"],
"condition_summary": "检查住宿城市、申报地点、行程城市是否出现规则描述中的风险关键词",
"natural_language": (
"差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;"
"再读取申报目的地、明细发生地点、交通票行程城市和住宿发票城市。"
"若交通票或住宿票据中的城市均无法与申报目的地、明细地点形成一致关系,"
"且报销事由中没有说明绕行、跨城办事或临时改签原因,则标记为高风险。"
),
},
"outcomes": {"fail": {"severity": "high"}},
"metadata": {
"condition_summary": "检查住宿城市、申报地点、行程城市是否出现规则描述中的风险关键词",
"flow": {
"decision": "检查住宿城市、申报地点、行程城市是否出现规则描述中的风险关键词"
},
},
"flow_diagram_svg": (
'<svg data-risk-flow-style="review-node-only">'
"检查住宿城市、申报地点、行程城市是否出现规则描述中的风险关键词"
"</svg>"
),
}
normalized = normalize_risk_rule_manifest(manifest)
assert normalized["template_key"] == "field_compare_v1"
assert normalized["semantic_type"] == "travel_route_city_consistency"
assert normalized["params"]["keywords"] == []
assert "风险关键词" not in normalized["params"]["condition_summary"]
assert "风险关键词" not in normalized["metadata"]["flow"]["decision"]
assert "风险关键词" not in normalized["flow_diagram_svg"]
claim = ExpenseClaim(
claim_no="TEST-LEGACY-NORMALIZER",
employee_name="测试员工",
department_name="测试部门",
expense_type="差旅费",
reason="去上海办事",
location="上海",
amount=Decimal("520.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime.now(UTC),
status="draft",
)
claim.employee = Employee(
employee_no="TEST-EMPLOYEE",
name="测试员工",
email="legacy-route-risk@example.com",
location="武汉",
)
claim.items = [
ExpenseClaimItem(
item_date=date.today(),
item_type="交通费",
item_reason="去上海办事",
item_location="上海",
item_amount=Decimal("520.00"),
)
]
result = RiskRuleTemplateExecutor().evaluate(
normalized,
claim=claim,
contexts=[{"document_info": {"route_cities": ["上海", "北京", "武汉"]}}],
)
assert result is not None
assert result["evidence"]["city_consistency"]["unexpected_route_cities"] == ["北京"]
def test_travel_route_rule_hits_unexpected_intermediate_city_even_when_returning_home() -> None:
manifest = {
"template_key": "field_compare_v1",
"params": {
"template_key": "field_compare_v1",
"semantic_type": "travel_route_city_consistency",
"field_keys": [
"attachment.route_cities",
"claim.location",
"item.item_location",
"employee.location",
"claim.reason",
],
"attachment_city_fields": ["attachment.route_cities"],
"reference_city_fields": ["claim.location", "item.item_location"],
"home_city_fields": ["employee.location"],
"exception_fields": ["claim.reason"],
"exception_keywords": ["绕行", "跨城办事", "临时改签"],
"condition_summary": "A=票据路线城市B=申报城市C=员工常驻地A中出现BC之外城市则命中。",
},
"outcomes": {"fail": {"severity": "high"}},
}
claim = ExpenseClaim(
claim_no="TEST-ROUTE-ANOMALY",
employee_name="测试员工",
department_name="测试部门",
expense_type="差旅费",
reason="去上海办事",
location="上海",
amount=Decimal("520.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime.now(UTC),
status="draft",
)
claim.employee = Employee(
employee_no="TEST-EMPLOYEE",
name="测试员工",
email="route-risk@example.com",
location="武汉",
)
claim.items = [
ExpenseClaimItem(
item_date=date.today(),
item_type="交通费",
item_reason="去上海办事",
item_location="上海",
item_amount=Decimal("520.00"),
)
]
result = RiskRuleTemplateExecutor().evaluate(
manifest,
claim=claim,
contexts=[
{
"document_info": {
"route_cities": ["上海", "北京", "武汉"],
"fields": [
{"key": "route_cities", "label": "行程城市", "value": ["上海", "北京", "武汉"]}
],
},
"ocr_text": "上海 到 北京 到 武汉",
}
],
)
assert result is not None
evidence = result["evidence"]["city_consistency"]
assert evidence["reference_values"] == ["上海"]
assert evidence["home_values"] == ["武汉"]
assert evidence["unexpected_route_cities"] == ["北京"]
def test_simulation_uses_current_rule_manifest_for_ticket_city_mismatch(tmp_path) -> None:
with build_session() as db:
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
generator = RiskRuleGenerationService(
db,
rule_library_manager=manager,
runtime_chat_service=NullRuntimeChatService(),
)
asset_id = generator.generate_rule_asset(
AgentAssetRiskRuleGenerateRequest(
business_domain=AgentAssetDomain.EXPENSE,
expense_category="travel",
rule_title="当前差旅票据城市一致性规则",
risk_level="medium",
natural_language="差旅报销时,交通票或住宿票据中的城市必须与申报目的地一致;未说明绕行、跨城或改签原因时标记风险。",
requires_attachment=True,
),
actor="pytest",
)
service = AgentAssetService(db)
service.rule_library_manager = manager
asset = db.get(AgentAsset, asset_id)
assert asset is not None
manifest = manager.read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=asset.config_json["rule_document"]["file_name"],
)
manifest["template_key"] = "keyword_match_v1"
manifest["params"]["template_key"] = "keyword_match_v1"
manifest["params"]["keywords"] = ["绕行", "跨城", "改签", "变更"]
manifest["params"]["search_fields"] = [
"attachment.hotel_city",
"claim.location",
"attachment.route_cities",
"item.item_location",
]
manifest["params"]["field_keys"] = manifest["params"]["search_fields"]
manager.write_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=asset.config_json["rule_document"]["file_name"],
payload=manifest,
)
simulation = service.simulate_risk_rule_message(
asset_id,
AgentAssetRiskRuleSimulationRequest(
message="去北京出差3天",
attachments=[
{
"name": "train-ticket.pdf",
"content_type": "application/pdf",
"ocr_text": "武汉 到 上海",
"summary": "高铁票 武汉-上海",
"document_fields": [
{"key": "route", "label": "行程路线", "value": "武汉-上海"}
],
}
],
),
)
assert simulation.ready is True
assert simulation.hit is True
assert simulation.field_values["claim.location"] == "北京"
assert simulation.field_values["attachment.route_cities"] == ["武汉", "上海"]
def test_risk_rule_requires_test_report_before_review_and_publish(tmp_path) -> None:
with build_session() as db:
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import re
from datetime import UTC, datetime, timedelta
from decimal import Decimal
@@ -208,11 +209,11 @@ def test_user_agent_application_context_uses_application_language() -> None:
assert "| 字段 | 内容 |" in response.answer
assert "| 发生时间 | 2026-05-25 至 2026-05-28 |" 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"] == "出行方式:\n用户预估费用:"
def test_user_agent_application_infers_natural_reason_and_expands_single_date() -> None:
@@ -222,13 +223,38 @@ def test_user_agent_application_infers_natural_reason_and_expands_single_date()
response = build_application_user_agent_response(db, message)
assert "| 发生时间 | 2026-05-25 至 2026-05-28 |" in response.answer
assert "| 地点 | 上海 |" in response.answer
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] == ["一次性补充申请信息"]
def test_user_agent_application_normalizes_location_to_region_city() -> None:
session_factory = build_session_factory()
yili_message = (
"发生时间2026-05-25\n"
"地点:伊犁\n"
"事由:支撑新疆电力仿生产部署\n"
"天数3天"
)
beijing_message = (
"发生时间2026-05-25\n"
"地点:北京\n"
"事由:支撑总部系统部署\n"
"天数3天"
)
with session_factory() as db:
yili_response = build_application_user_agent_response(db, yili_message)
beijing_response = build_application_user_agent_response(db, beijing_message)
assert "| 发生时间 | 2026-05-25 至 2026-05-28 |" in yili_response.answer
assert "| 地点 | 新疆,伊犁 |" in yili_response.answer
assert "| 事由 | 支撑新疆电力仿生产部署 |" in yili_response.answer
assert "伊犁出差" not in yili_response.answer
assert "| 地点 | 北京市 |" in beijing_response.answer
def test_user_agent_application_uses_selected_time_and_natural_language_fields() -> None:
session_factory = build_session_factory()
message = "出差上海,支撑国网服务器上线部署"
@@ -262,12 +288,12 @@ 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 "当前还需要补充:出行方式、用户预估费用" 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"] == "出行方式:\n用户预估费用:"
def test_user_agent_application_asks_amount_after_transport_choice() -> None:
@@ -286,10 +312,10 @@ def test_user_agent_application_asks_amount_after_transport_choice() -> None:
)
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 response.suggested_actions[0].payload["prompt_prefill"] == "用户预估费用:"
def test_user_agent_application_missing_base_actions_prefill_composer() -> None:
@@ -300,10 +326,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出行方式:\n预计总费用:"
assert response.suggested_actions[0].payload["prompt_prefill"] == "申请时间段:\n出行方式:\n用户预估费用:"
def test_user_agent_application_builds_preview_when_amount_is_ready() -> None:
@@ -328,7 +354,7 @@ def test_user_agent_application_builds_preview_when_amount_is_ready() -> None:
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
@@ -349,11 +375,11 @@ def test_user_agent_application_submit_enters_leader_review() -> None:
"| --- | --- |\n"
"| 申请类型 | 差旅费用申请 |\n"
"| 发生时间 | 2026-05-25 |\n"
"| 地点 | 上海 |\n"
"| 地点 | 上海 |\n"
"| 事由 | 支持上海国网服务器部署 |\n"
"| 天数 | 3天 |\n"
"| 出行方式 | 飞机 |\n"
"| 预计总费用 | 12000元 |\n\n"
"| 用户预估费用 | 12000元 |\n\n"
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。"
)
with session_factory() as db:
@@ -372,9 +398,9 @@ def test_user_agent_application_submit_enters_leader_review() -> None:
assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in response.answer
assert "当前状态:陈硕审核中" in response.answer
assert "预算占用参考" in response.answer
assert "APP-20260525-" in response.answer
assert re.search(r"AP-\d{14}-[A-HJ-NP-Z2-9]{8}", response.answer)
assert response.suggested_actions == []
claim = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("APP-20260525-%")).one()
claim = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).one()
assert claim.status == "submitted"
assert claim.approval_stage == "直属领导审批"
assert claim.expense_type == "travel_application"