feat: 增强风险规则生成引擎与预算中心页面
后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块, 优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强 报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图 组件,重构审计页面和风险规则测试对话框交互,完善文档中心 和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
94
server/tests/test_document_numbering.py
Normal file
94
server/tests/test_document_numbering.py
Normal 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")
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
361
server/tests/test_risk_rule_composite_generation.py
Normal file
361
server/tests/test_risk_rule_composite_generation.py
Normal 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
|
||||
@@ -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出现B∪C之外城市时命中。"
|
||||
),
|
||||
"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中出现B∪C之外城市则命中。",
|
||||
},
|
||||
"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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user