Files
X-Financial/server/tests/test_expense_claim_approval_routing.py

381 lines
13 KiB
Python
Raw Normal View History

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 (
APPLICATION_LINK_STATUS_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 == APPLICATION_LINK_STATUS_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") == APPLICATION_LINK_STATUS_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 == APPLICATION_LINK_STATUS_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 == APPLICATION_LINK_STATUS_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
)