381 lines
13 KiB
Python
381 lines
13 KiB
Python
|
|
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
|
||
|
|
)
|