feat: 重构报销单AI预审流程并添加平台风险规则引擎
- 将AI验审改为AI预审,高风险不再拦截而是随单流转给审批人复核 - 新增平台风险规则评估引擎,支持事由过短、票据异常、重复发票等多种评估器 - 用户上下文增加部门信息(department_name),认证流程同步关联组织架构 - 规则scenario_json改为中文标签(差旅/费用科目),统一场景分类 - 新增orchestrator审核流程测试用例 - 前端更新审计视图、差旅报销等相关页面
This commit is contained in:
@@ -30,7 +30,9 @@ from app.schemas.agent_asset import (
|
||||
AgentAssetVersionCreate,
|
||||
)
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||
FINANCE_RULES_LIBRARY,
|
||||
)
|
||||
@@ -145,6 +147,26 @@ def test_agent_asset_service_seeds_all_foundation_asset_types() -> None:
|
||||
assert len(service.list_assets(asset_type=AgentAssetType.TASK.value)) >= 3
|
||||
|
||||
|
||||
def test_finance_rules_use_risk_rule_scenario_categories() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentAssetService(db)
|
||||
|
||||
rules = service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||||
travel_rule = next(item for item in rules if item.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
|
||||
communication_rule = next(
|
||||
item for item in rules if item.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE
|
||||
)
|
||||
travel_config = travel_rule.config_json or {}
|
||||
communication_config = communication_rule.config_json or {}
|
||||
|
||||
assert travel_rule.scenario_json == ["差旅"]
|
||||
assert travel_config["scenario_category"] == "差旅"
|
||||
assert travel_config["ai_review_category"] == "差旅"
|
||||
assert communication_rule.scenario_json == ["费用科目"]
|
||||
assert communication_config["scenario_category"] == "费用科目"
|
||||
assert communication_config["ai_review_category"] == "费用科目"
|
||||
|
||||
|
||||
def test_agent_asset_service_can_activate_rule_after_review() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentAssetService(db)
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.api.deps import CurrentUserContext
|
||||
from app.db.base import Base
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.schemas.ontology import OntologyParseRequest
|
||||
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
|
||||
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate
|
||||
@@ -76,6 +77,16 @@ def test_validate_claim_for_submission_allows_office_claim_without_location() ->
|
||||
assert not any("缺少地点" in item for item in issues)
|
||||
|
||||
|
||||
def test_validate_claim_for_submission_allows_transport_claim_without_location() -> None:
|
||||
service = ExpenseClaimService.__new__(ExpenseClaimService)
|
||||
claim = build_claim(expense_type="transport", location="待补充")
|
||||
|
||||
issues = service._validate_claim_for_submission(claim)
|
||||
|
||||
assert "业务地点未完善" not in issues
|
||||
assert not any("缺少地点" in item for item in issues)
|
||||
|
||||
|
||||
def test_validate_claim_for_submission_still_requires_location_for_travel_claim() -> None:
|
||||
service = ExpenseClaimService.__new__(ExpenseClaimService)
|
||||
claim = build_claim(expense_type="travel", location="待补充")
|
||||
@@ -666,7 +677,54 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
|
||||
assert submitted.submitted_at is not None
|
||||
|
||||
|
||||
def test_submit_claim_blocks_high_risk_attachment_at_ai_review(monkeypatch, tmp_path) -> None:
|
||||
def test_submit_claim_backfills_department_from_current_employee() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-dept@example.com",
|
||||
name="张三",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
department = OrganizationUnit(
|
||||
unit_code="D7200",
|
||||
name="销售部",
|
||||
)
|
||||
manager = Employee(
|
||||
employee_no="E7200",
|
||||
name="李经理",
|
||||
email="manager-dept@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E7201",
|
||||
name="张三",
|
||||
email="emp-dept@example.com",
|
||||
organization_unit=department,
|
||||
manager=manager,
|
||||
)
|
||||
claim = build_claim(expense_type="transport", location="待补充")
|
||||
claim.employee = None
|
||||
claim.employee_id = None
|
||||
claim.employee_name = "张三"
|
||||
claim.department_id = None
|
||||
claim.department_name = "待补充"
|
||||
claim.items[0].item_location = "待补充"
|
||||
db.add_all([department, manager, employee, claim])
|
||||
db.commit()
|
||||
|
||||
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
|
||||
|
||||
assert submitted is not None
|
||||
assert submitted.status == "submitted"
|
||||
assert submitted.department_id == department.id
|
||||
assert submitted.department_name == "销售部"
|
||||
assert submitted.approval_stage == "直属领导审批"
|
||||
|
||||
|
||||
def test_submit_claim_routes_high_risk_attachment_to_approval_with_review_flag(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-risk@example.com",
|
||||
name="张三",
|
||||
@@ -732,16 +790,19 @@ def test_submit_claim_blocks_high_risk_attachment_at_ai_review(monkeypatch, tmp_
|
||||
submitted = service.submit_claim(claim.id, current_user)
|
||||
|
||||
assert submitted is not None
|
||||
assert submitted.status == "supplement"
|
||||
assert submitted.approval_stage == "待补充"
|
||||
assert submitted.submitted_at is None
|
||||
assert submitted.status == "submitted"
|
||||
assert submitted.approval_stage == "直属领导审批"
|
||||
assert submitted.submitted_at is not None
|
||||
assert any(
|
||||
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "submission_review"
|
||||
for flag in list(submitted.risk_flags_json or [])
|
||||
)
|
||||
|
||||
|
||||
def test_submit_claim_blocks_travel_route_mismatch_without_explanation(monkeypatch, tmp_path) -> None:
|
||||
def test_submit_claim_routes_travel_route_mismatch_to_approval_with_review_flag(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-travel@example.com",
|
||||
name="张三",
|
||||
@@ -876,8 +937,8 @@ def test_submit_claim_blocks_travel_route_mismatch_without_explanation(monkeypat
|
||||
submitted = service.submit_claim(claim.id, current_user)
|
||||
|
||||
assert submitted is not None
|
||||
assert submitted.status == "supplement"
|
||||
assert submitted.approval_stage == "待补充"
|
||||
assert submitted.status == "submitted"
|
||||
assert submitted.approval_stage == "直属领导审批"
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "submission_review"
|
||||
@@ -889,7 +950,10 @@ def test_submit_claim_blocks_travel_route_mismatch_without_explanation(monkeypat
|
||||
)
|
||||
|
||||
|
||||
def test_submit_claim_blocks_hotel_amount_over_travel_policy_without_explanation(monkeypatch, tmp_path) -> None:
|
||||
def test_submit_claim_routes_hotel_amount_over_travel_policy_to_approval_with_review_flag(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-hotel@example.com",
|
||||
name="张三",
|
||||
@@ -1024,8 +1088,8 @@ def test_submit_claim_blocks_hotel_amount_over_travel_policy_without_explanation
|
||||
submitted = service.submit_claim(claim.id, current_user)
|
||||
|
||||
assert submitted is not None
|
||||
assert submitted.status == "supplement"
|
||||
assert submitted.approval_stage == "待补充"
|
||||
assert submitted.status == "submitted"
|
||||
assert submitted.approval_stage == "直属领导审批"
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "submission_review"
|
||||
|
||||
@@ -310,7 +310,9 @@ def test_semantic_ontology_service_keeps_travel_amount_follow_up_in_knowledge_qu
|
||||
assert result.clarification_required is False
|
||||
|
||||
|
||||
def test_semantic_ontology_service_rejects_draft_intent_inside_knowledge_session(monkeypatch) -> None:
|
||||
def test_semantic_ontology_service_rejects_draft_intent_inside_knowledge_session(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
service = SemanticOntologyService(db)
|
||||
@@ -341,11 +343,33 @@ def test_semantic_ontology_service_rejects_draft_intent_inside_knowledge_session
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert result.scenario == "knowledge"
|
||||
assert result.intent == "query"
|
||||
assert result.clarification_required is False
|
||||
assert result.clarification_question is None
|
||||
|
||||
assert result.scenario == "knowledge"
|
||||
assert result.intent == "query"
|
||||
assert result.clarification_required is False
|
||||
assert result.clarification_question is None
|
||||
|
||||
|
||||
def test_review_next_step_context_inherits_expense_draft_flow() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
result = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="我已核对右侧识别结果,请进入下一步。",
|
||||
user_id="pytest",
|
||||
context_json={
|
||||
"review_action": "next_step",
|
||||
"draft_claim_id": "claim-1",
|
||||
"attachment_count": 1,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert result.scenario == "expense"
|
||||
assert result.intent == "draft"
|
||||
assert result.permission.level == "draft_write"
|
||||
assert result.clarification_required is False
|
||||
assert result.clarification_question is None
|
||||
|
||||
|
||||
def test_semantic_ontology_service_prefers_expense_for_customer_entertainment_narrative() -> None:
|
||||
|
||||
175
server/tests/test_orchestrator_review_flow.py
Normal file
175
server/tests/test_orchestrator_review_flow.py
Normal file
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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.db.base import Base
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.schemas.orchestrator import OrchestratorRequest
|
||||
from app.services.orchestrator import OrchestratorService
|
||||
|
||||
|
||||
def build_session_factory() -> sessionmaker[Session]:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
def test_review_next_step_run_submits_existing_claim_and_returns_draft_payload(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.services.runtime_chat.RuntimeChatService.complete",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
manager = Employee(
|
||||
employee_no="E9000",
|
||||
name="李经理",
|
||||
email="manager-next@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E9001",
|
||||
name="张三",
|
||||
email="emp-next@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
id="claim-next-step",
|
||||
claim_no="EXP-202605-001",
|
||||
employee=employee,
|
||||
employee_id=employee.id,
|
||||
employee_name="张三",
|
||||
department_name="销售部",
|
||||
expense_type="office",
|
||||
reason="采购办公用品",
|
||||
location="上海",
|
||||
amount=Decimal("128.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 20, 9, 0, tzinfo=UTC),
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
items=[
|
||||
ExpenseClaimItem(
|
||||
item_date=date(2026, 5, 20),
|
||||
item_type="office",
|
||||
item_reason="采购办公用品",
|
||||
item_location="上海",
|
||||
item_amount=Decimal("128.00"),
|
||||
invoice_id="office-invoice.png",
|
||||
)
|
||||
],
|
||||
)
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
|
||||
response = OrchestratorService(db).run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="emp-next@example.com",
|
||||
message="我已核对右侧识别结果,请进入下一步。",
|
||||
context_json={
|
||||
"review_action": "next_step",
|
||||
"draft_claim_id": claim.id,
|
||||
"attachment_count": 1,
|
||||
"name": "张三",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
db.refresh(claim)
|
||||
assert response.status == "succeeded"
|
||||
assert response.requires_confirmation is False
|
||||
assert response.result["draft_payload"]["status"] == "submitted"
|
||||
assert response.result["draft_payload"]["approval_stage"] == "直属领导审批"
|
||||
assert claim.status == "submitted"
|
||||
assert claim.approval_stage == "直属领导审批"
|
||||
assert claim.submitted_at is not None
|
||||
|
||||
|
||||
def test_review_next_step_blocked_returns_reasons_and_removes_next_step_action(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.services.runtime_chat.RuntimeChatService.complete",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
employee = Employee(
|
||||
employee_no="E9011",
|
||||
name="张三",
|
||||
email="emp-blocked@example.com",
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
id="claim-next-step-blocked",
|
||||
claim_no="EXP-202605-002",
|
||||
employee=employee,
|
||||
employee_id=employee.id,
|
||||
employee_name="张三",
|
||||
department_name="待补充",
|
||||
expense_type="office",
|
||||
reason="采购办公用品",
|
||||
location="上海",
|
||||
amount=Decimal("128.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 20, 9, 0, tzinfo=UTC),
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
items=[
|
||||
ExpenseClaimItem(
|
||||
item_date=date(2026, 5, 20),
|
||||
item_type="office",
|
||||
item_reason="采购办公用品",
|
||||
item_location="上海",
|
||||
item_amount=Decimal("128.00"),
|
||||
invoice_id="office-invoice.png",
|
||||
)
|
||||
],
|
||||
)
|
||||
db.add_all([employee, claim])
|
||||
db.commit()
|
||||
|
||||
response = OrchestratorService(db).run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="emp-blocked@example.com",
|
||||
message="我已核对右侧识别结果,请进入下一步。",
|
||||
context_json={
|
||||
"review_action": "next_step",
|
||||
"draft_claim_id": claim.id,
|
||||
"attachment_count": 1,
|
||||
"name": "张三",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
result = response.result
|
||||
review_payload = result["review_payload"]
|
||||
actions = {
|
||||
str(item.get("action_type") or "").strip()
|
||||
for item in review_payload["confirmation_actions"]
|
||||
}
|
||||
|
||||
assert response.status == "succeeded"
|
||||
assert result["draft_payload"]["status"] == "draft"
|
||||
assert "AI预审暂未通过" in result["answer"]
|
||||
assert "所属部门未完善" in result["answer"]
|
||||
assert "next_step" not in actions
|
||||
assert "save_draft" in actions
|
||||
assert any(
|
||||
"所属部门未完善" in str(item.get("content") or "")
|
||||
for item in review_payload["risk_briefs"]
|
||||
)
|
||||
@@ -727,7 +727,7 @@ def test_user_agent_draft_returns_structured_payload() -> None:
|
||||
assert response.answer == response.review_payload.body_message
|
||||
|
||||
|
||||
def test_user_agent_returns_draft_limit_message_when_save_is_blocked() -> None:
|
||||
def test_user_agent_returns_draft_limit_message_when_save_is_blocked() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
@@ -751,13 +751,50 @@ def test_user_agent_returns_draft_limit_message_when_save_is_blocked() -> None:
|
||||
)
|
||||
)
|
||||
|
||||
assert (
|
||||
response.answer
|
||||
== "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。"
|
||||
)
|
||||
|
||||
|
||||
def test_user_agent_builds_review_payload_for_multi_document_expense_flow() -> None:
|
||||
assert (
|
||||
response.answer
|
||||
== "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。"
|
||||
)
|
||||
|
||||
|
||||
def test_user_agent_returns_submitted_draft_payload_for_review_next_step() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
context_json = {
|
||||
"review_action": "next_step",
|
||||
"draft_claim_id": "claim-1",
|
||||
"attachment_count": 1,
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="我已核对右侧识别结果,请进入下一步。",
|
||||
user_id="pytest",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest",
|
||||
message="我已核对右侧识别结果,请进入下一步。",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload={
|
||||
"claim_id": "claim-1",
|
||||
"claim_no": "BX202605200001",
|
||||
"status": "submitted",
|
||||
"approval_stage": "直属领导审批",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.draft_payload is not None
|
||||
assert response.draft_payload.status == "submitted"
|
||||
assert response.draft_payload.confirmation_required is False
|
||||
assert "当前节点为 直属领导审批" in response.answer
|
||||
|
||||
|
||||
def test_user_agent_builds_review_payload_for_multi_document_expense_flow() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
yesterday = (datetime.now(UTC).date() - timedelta(days=1)).isoformat()
|
||||
|
||||
Reference in New Issue
Block a user