feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造
- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制 - 引入费用审批动态路由、平台风险分级、预审与风险阶段管理 - 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板 - 新增 Hermes 风险线索收集器、Agent 链路追踪中心 - 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估 - 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
380
server/tests/test_expense_claim_approval_routing.py
Normal file
380
server/tests/test_expense_claim_approval_routing.py
Normal file
@@ -0,0 +1,380 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.db.base import Base
|
||||
from app.models.budget import BudgetAllocation
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.role import Role
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPROVAL_DONE_STAGE,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
FINANCE_APPROVAL_STAGE,
|
||||
)
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
|
||||
|
||||
def build_session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
return session_factory()
|
||||
|
||||
|
||||
def _seed_budget_monitor_role(db: Session) -> Role:
|
||||
role = Role(role_code="budget_monitor", name="预算管理员")
|
||||
db.add(role)
|
||||
db.flush()
|
||||
return role
|
||||
|
||||
|
||||
def _seed_budget_allocation(
|
||||
db: Session,
|
||||
*,
|
||||
department_id: str,
|
||||
department_name: str,
|
||||
amount: Decimal = Decimal("50000.00"),
|
||||
) -> None:
|
||||
db.add(
|
||||
BudgetAllocation(
|
||||
budget_no=f"BUD-ROUTE-{uuid.uuid4().hex[:8]}",
|
||||
fiscal_year=2026,
|
||||
period_type="quarter",
|
||||
period_key="2026Q2",
|
||||
department_id=department_id,
|
||||
department_name=department_name,
|
||||
cost_center=None,
|
||||
project_code=None,
|
||||
subject_code="travel",
|
||||
subject_name="差旅",
|
||||
original_amount=amount,
|
||||
adjusted_amount=Decimal("0.00"),
|
||||
status="active",
|
||||
warning_threshold=Decimal("80.00"),
|
||||
control_action="block",
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
|
||||
|
||||
def _seed_people(db: Session, *, suffix: str) -> tuple[OrganizationUnit, Employee, Employee, Employee]:
|
||||
budget_role = _seed_budget_monitor_role(db)
|
||||
department = OrganizationUnit(
|
||||
unit_code=f"ROUTE-{suffix}",
|
||||
name=f"动态路由部{suffix}",
|
||||
unit_type="department",
|
||||
)
|
||||
manager = Employee(
|
||||
employee_no=f"M-{suffix}",
|
||||
name=f"直属领导{suffix}",
|
||||
email=f"manager-{suffix}@example.com",
|
||||
organization_unit=department,
|
||||
)
|
||||
budget_manager = Employee(
|
||||
employee_no=f"B-{suffix}",
|
||||
name=f"预算管理员{suffix}",
|
||||
email=f"budget-{suffix}@example.com",
|
||||
grade="P8",
|
||||
organization_unit=department,
|
||||
roles=[budget_role],
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no=f"E-{suffix}",
|
||||
name=f"申请人{suffix}",
|
||||
email=f"employee-{suffix}@example.com",
|
||||
manager=manager,
|
||||
organization_unit=department,
|
||||
)
|
||||
db.add_all([department, manager, budget_manager, employee])
|
||||
db.flush()
|
||||
return department, manager, budget_manager, employee
|
||||
|
||||
|
||||
def test_low_risk_application_skips_budget_manager_and_generates_draft() -> None:
|
||||
with build_session() as db:
|
||||
department, manager, _budget_manager, employee = _seed_people(db, suffix="LOW-APP")
|
||||
_seed_budget_allocation(
|
||||
db,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260530-LOW-ROUTE",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
project_code=None,
|
||||
expense_type="travel_application",
|
||||
reason="客户现场沟通",
|
||||
location="上海",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
approved = ExpenseClaimService(db).approve_claim(
|
||||
claim.id,
|
||||
CurrentUserContext(
|
||||
username=manager.email,
|
||||
name=manager.name,
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
),
|
||||
opinion="业务必要,同意申请",
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == APPROVAL_DONE_STAGE
|
||||
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "approval_routing"
|
||||
and flag.get("requires_budget_review") is False
|
||||
and flag.get("route") == "approval_done"
|
||||
and flag.get("business_stage") == "expense_application"
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "manual_approval"
|
||||
and flag.get("next_approval_stage") == APPROVAL_DONE_STAGE
|
||||
and flag.get("route_decision", {}).get("requires_budget_review") is False
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_budget_warning_application_still_skips_budget_manager_when_not_over_budget() -> None:
|
||||
with build_session() as db:
|
||||
department, manager, _budget_manager, employee = _seed_people(db, suffix="WARN-APP")
|
||||
_seed_budget_allocation(
|
||||
db,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
amount=Decimal("10000.00"),
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260530-WARN-ROUTE",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
project_code=None,
|
||||
expense_type="travel_application",
|
||||
reason="客户现场支持",
|
||||
location="上海",
|
||||
amount=Decimal("8500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "budget_control",
|
||||
"event_type": "budget_warning",
|
||||
"severity": "medium",
|
||||
"label": "预算接近预警线",
|
||||
"message": "预算仍可承接,但审批后使用率将接近预警线。",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
approved = ExpenseClaimService(db).approve_claim(
|
||||
claim.id,
|
||||
CurrentUserContext(
|
||||
username=manager.email,
|
||||
name=manager.name,
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
),
|
||||
opinion="业务必要,同意申请。",
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == APPROVAL_DONE_STAGE
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "approval_routing"
|
||||
and flag.get("requires_budget_review") is False
|
||||
and flag.get("route") == "approval_done"
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
assert not any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "manual_approval"
|
||||
and flag.get("next_approval_stage") == BUDGET_MANAGER_APPROVAL_STAGE
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_application_route_ignores_reimbursement_stage_current_risks() -> None:
|
||||
with build_session() as db:
|
||||
department, manager, _budget_manager, employee = _seed_people(db, suffix="MIXED-STAGE")
|
||||
_seed_budget_allocation(
|
||||
db,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260530-MIXED-STAGE",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
project_code=None,
|
||||
expense_type="travel_application",
|
||||
reason="客户现场沟通",
|
||||
location="上海",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "报销票据风险",
|
||||
"message": "报销票据城市与行程城市不一致。",
|
||||
"business_stage": "reimbursement",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
approved = ExpenseClaimService(db).approve_claim(
|
||||
claim.id,
|
||||
CurrentUserContext(
|
||||
username=manager.email,
|
||||
name=manager.name,
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
),
|
||||
opinion="业务必要,同意申请。",
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == APPROVAL_DONE_STAGE
|
||||
route_flag = [
|
||||
flag
|
||||
for flag in approved.risk_flags_json
|
||||
if isinstance(flag, dict) and flag.get("source") == "approval_routing"
|
||||
][0]
|
||||
assert route_flag["requires_budget_review"] is False
|
||||
assert route_flag["current_risk_count"] == 0
|
||||
assert route_flag["business_stage"] == "expense_application"
|
||||
|
||||
|
||||
def test_risky_reimbursement_routes_to_budget_then_finance() -> None:
|
||||
with build_session() as db:
|
||||
department, manager, budget_manager, employee = _seed_people(db, suffix="RISK-CLAIM")
|
||||
_seed_budget_allocation(
|
||||
db,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
claim_no="RE-20260530-RISK-ROUTE",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
project_code=None,
|
||||
expense_type="travel",
|
||||
reason="客户现场沟通",
|
||||
location="上海",
|
||||
amount=Decimal("600.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "行程城市异常",
|
||||
"message": "票据城市与申报目的地不一致",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
service = ExpenseClaimService(db)
|
||||
|
||||
routed = service.approve_claim(
|
||||
claim.id,
|
||||
CurrentUserContext(
|
||||
username=manager.email,
|
||||
name=manager.name,
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
),
|
||||
opinion="业务属实,同意报账",
|
||||
)
|
||||
|
||||
assert routed is not None
|
||||
assert routed.status == "submitted"
|
||||
assert routed.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "approval_routing"
|
||||
and flag.get("requires_budget_review") is True
|
||||
and flag.get("route") == "budget_manager"
|
||||
and any("行程城市异常" in item for item in flag.get("reasons", []))
|
||||
for flag in routed.risk_flags_json
|
||||
)
|
||||
|
||||
budget_approved = service.approve_claim(
|
||||
claim.id,
|
||||
CurrentUserContext(
|
||||
username=budget_manager.email,
|
||||
name=budget_manager.name,
|
||||
role_codes=["budget_monitor"],
|
||||
is_admin=False,
|
||||
),
|
||||
opinion="预算影响已复核,同意进入财务审批",
|
||||
)
|
||||
|
||||
assert budget_approved is not None
|
||||
assert budget_approved.status == "submitted"
|
||||
assert budget_approved.approval_stage == FINANCE_APPROVAL_STAGE
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "budget_approval"
|
||||
and flag.get("event_type") == "expense_claim_budget_approval"
|
||||
and flag.get("next_approval_stage") == FINANCE_APPROVAL_STAGE
|
||||
for flag in budget_approved.risk_flags_json
|
||||
)
|
||||
Reference in New Issue
Block a user