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 )