feat(expenses): add authoritative pre-review workflow
This commit is contained in:
@@ -16,6 +16,7 @@ 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_errors import ExpenseClaimPreReviewBlockedError
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPLICATION_LINK_STATUS_STAGE,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
@@ -311,7 +312,7 @@ def test_application_routes_to_budget_manager_when_usage_reaches_90_percent() ->
|
||||
)
|
||||
|
||||
|
||||
def test_application_stage_risk_under_90_percent_does_not_route_to_budget_manager() -> None:
|
||||
def test_high_risk_application_under_90_percent_routes_to_budget_manager() -> None:
|
||||
with build_session() as db:
|
||||
department, manager, _budget_manager, employee = _seed_people(db, suffix="RISK-APP")
|
||||
_seed_budget_allocation(
|
||||
@@ -362,16 +363,169 @@ def test_application_stage_risk_under_90_percent_does_not_route_to_budget_manage
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
|
||||
assert approved.status == "submitted"
|
||||
assert approved.approval_stage == BUDGET_MANAGER_APPROVAL_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["route"] == "approval_done"
|
||||
assert route_flag["requires_budget_review"] is True
|
||||
assert route_flag["route"] == "budget_manager"
|
||||
assert route_flag["current_risk_count"] == 1
|
||||
assert any("申请信息风险" in reason for reason in route_flag["reasons"])
|
||||
|
||||
|
||||
def test_fixable_high_risk_application_is_blocked_before_direct_manager() -> None:
|
||||
with build_session() as db:
|
||||
department, _manager, _budget_manager, employee = _seed_people(
|
||||
db,
|
||||
suffix="FIXABLE-RISK-APP",
|
||||
)
|
||||
_seed_budget_allocation(
|
||||
db,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
amount=Decimal("10000.00"),
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260530-FIXABLE-RISK",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
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),
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "manual_risk",
|
||||
"severity": "high",
|
||||
"actionability": "fixable_by_submitter",
|
||||
"label": "申请事由不完整",
|
||||
"message": "请先补充客户与项目说明。",
|
||||
"business_stage": "expense_application",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
current_user = CurrentUserContext(
|
||||
username=employee.email,
|
||||
name=employee.name,
|
||||
employee_id=employee.id,
|
||||
role_codes=["user"],
|
||||
is_admin=False,
|
||||
tenant_id="default",
|
||||
)
|
||||
service = ExpenseClaimService(db)
|
||||
|
||||
reviewed = service.pre_review_claim(claim.id, current_user)
|
||||
assert reviewed is not None
|
||||
pre_review_flag = next(
|
||||
flag
|
||||
for flag in reviewed.risk_flags_json
|
||||
if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
|
||||
)
|
||||
assert pre_review_flag["decision"] == "needs_fix"
|
||||
|
||||
with pytest.raises(ExpenseClaimPreReviewBlockedError):
|
||||
service.submit_claim(
|
||||
claim.id,
|
||||
current_user,
|
||||
pre_review_id=pre_review_flag["review_id"],
|
||||
pre_review_input_fingerprint=pre_review_flag["input_fingerprint"],
|
||||
)
|
||||
|
||||
assert db.get(ExpenseClaim, claim.id).status == "draft"
|
||||
|
||||
|
||||
def test_review_decision_high_risk_application_flows_from_pre_review_to_p8() -> None:
|
||||
with build_session() as db:
|
||||
department, manager, _budget_manager, employee = _seed_people(
|
||||
db,
|
||||
suffix="REVIEW-RISK-APP",
|
||||
)
|
||||
_seed_budget_allocation(
|
||||
db,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
amount=Decimal("10000.00"),
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260530-REVIEW-RISK",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
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),
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "manual_risk",
|
||||
"severity": "high",
|
||||
"actionability": "review_decision",
|
||||
"label": "特殊项目风险",
|
||||
"message": "该项目需预算管理者确认。",
|
||||
"business_stage": "expense_application",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
current_user = CurrentUserContext(
|
||||
username=employee.email,
|
||||
name=employee.name,
|
||||
employee_id=employee.id,
|
||||
role_codes=["user"],
|
||||
is_admin=False,
|
||||
tenant_id="default",
|
||||
)
|
||||
service = ExpenseClaimService(db)
|
||||
|
||||
reviewed = service.pre_review_claim(claim.id, current_user)
|
||||
assert reviewed is not None
|
||||
pre_review_flag = next(
|
||||
flag
|
||||
for flag in reviewed.risk_flags_json
|
||||
if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
|
||||
)
|
||||
assert pre_review_flag["decision"] == "ready_with_review"
|
||||
submitted = service.submit_claim(
|
||||
claim.id,
|
||||
current_user,
|
||||
pre_review_id=pre_review_flag["review_id"],
|
||||
pre_review_input_fingerprint=pre_review_flag["input_fingerprint"],
|
||||
)
|
||||
assert submitted is not None
|
||||
assert submitted.approval_stage == DIRECT_MANAGER_APPROVAL_STAGE
|
||||
|
||||
routed = service.approve_claim(
|
||||
claim.id,
|
||||
CurrentUserContext(
|
||||
username=manager.email,
|
||||
name=manager.name,
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
tenant_id="default",
|
||||
),
|
||||
opinion="业务必要,同意申请。",
|
||||
)
|
||||
assert routed is not None
|
||||
assert routed.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
|
||||
|
||||
|
||||
def test_application_route_ignores_reimbursement_stage_current_risks() -> None:
|
||||
@@ -520,6 +674,65 @@ def test_risky_reimbursement_routes_to_budget_then_finance() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_resolved_high_risk_application_does_not_route_to_budget_manager() -> None:
|
||||
with build_session() as db:
|
||||
department, manager, _budget_manager, employee = _seed_people(
|
||||
db,
|
||||
suffix="RESOLVED-RISK-APP",
|
||||
)
|
||||
_seed_budget_allocation(
|
||||
db,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
amount=Decimal("10000.00"),
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260530-RESOLVED-RISK",
|
||||
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": "expense_application",
|
||||
"resolution_status": "resolved",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
routed = ExpenseClaimService(db).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 == "approved"
|
||||
assert routed.approval_stage == APPLICATION_LINK_STATUS_STAGE
|
||||
|
||||
|
||||
def test_budget_manager_blank_opinion_defaults_to_agree_when_budget_under_warning() -> None:
|
||||
with build_session() as db:
|
||||
department, _manager, budget_manager, employee = _seed_people(db, suffix="BUDGET-NORMAL")
|
||||
|
||||
Reference in New Issue
Block a user