Files
X-Financial/server/src/app/services/expense_claim_approval_routing.py
2026-06-09 08:32:00 +00:00

256 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from decimal import Decimal, InvalidOperation
from typing import Any
from sqlalchemy import or_, select
from app.models.financial_record import ExpenseClaim
from app.services.budget import BudgetService
from app.services.expense_claim_constants import AI_REVIEW_LOOKBACK_DAYS
from app.services.expense_claim_risk_stage import (
risk_business_stage_for_claim,
risk_flag_business_stage,
with_risk_business_stage,
)
class ExpenseClaimApprovalRoutingMixin:
_APPLICATION_BUDGET_REVIEW_USAGE_THRESHOLD = Decimal("90.00")
_BUDGET_REVIEW_RATINGS = {"block"}
_BUDGET_REVIEW_RISK_LEVELS = {"high", "critical"}
_ROUTE_RISK_SEVERITIES = {"medium", "high", "critical", "danger"}
_ROUTE_RISK_SOURCES = {
"attachment_analysis",
"budget",
"budget_control",
"manual_return",
"platform_risk",
"platform_risk_rule",
"policy_review",
"risk_rule",
"scene_policy",
"submission_review",
"travel_policy",
}
_ROUTE_IGNORED_SOURCES = {
"application_detail",
"application_handoff",
"approval_routing",
"budget_approval",
"finance_approval",
"manual_approval",
"payment",
}
_ROUTE_RISK_EVENT_TYPES = {
"budget_frozen",
"budget_insufficient",
"budget_missing",
"platform_risk_rule_hit",
"risk_rule_hit",
}
_BUDGET_ROUTE_EVENT_TYPES = {
"budget_frozen",
"budget_insufficient",
"budget_missing",
}
def _build_approval_route_decision(
self,
claim: ExpenseClaim,
*,
is_application_claim: bool,
) -> dict[str, Any]:
business_stage = risk_business_stage_for_claim(is_application_claim=is_application_claim)
budget_result = BudgetService(self.db).analyze_claim_budget(claim)
budget_reasons = (
self._collect_application_budget_route_reasons(budget_result)
if is_application_claim
else self._collect_budget_route_reasons(budget_result)
)
current_risk_reasons = self._collect_current_route_risk_reasons(
claim.risk_flags_json,
business_stage=business_stage,
)
historical_risk_count = self._count_recent_substantive_risky_claims(claim)
historical_risk_reasons = (
[f"申请人近 {AI_REVIEW_LOOKBACK_DAYS} 天存在 {historical_risk_count} 笔实质风险记录"]
if historical_risk_count > 0
else []
)
reasons = self._dedupe_reasons(
budget_reasons
if is_application_claim
else [*budget_reasons, *current_risk_reasons, *historical_risk_reasons]
)
requires_budget_review = bool(reasons)
route = (
"budget_manager"
if requires_budget_review
else "approval_done"
if is_application_claim
else "finance"
)
label = "需要预算管理者复核" if requires_budget_review else "跳过预算管理者复核"
if is_application_claim:
message = (
"系统根据预算占用阈值判断,该申请单达到 90% 预算复核线,需要预算管理者二次确认。"
if requires_budget_review
else "系统根据预算占用阈值判断,该申请单未达到 90% 预算复核线,可跳过预算管理者复核。"
)
else:
message = (
"系统根据预算、当前风险和历史风险判断,该单据需要预算管理者二次确认。"
if requires_budget_review
else "系统根据预算、当前风险和历史风险判断,该单据可跳过预算管理者复核。"
)
return with_risk_business_stage(
{
"source": "approval_routing",
"event_type": (
"expense_application_route_decision"
if is_application_claim
else "expense_claim_route_decision"
),
"severity": "medium" if requires_budget_review else "info",
"label": label,
"message": message,
"requires_budget_review": requires_budget_review,
"route": route,
"reasons": reasons,
"budget_result": self._compact_budget_result(budget_result),
"current_risk_count": len(current_risk_reasons),
"historical_risk_count": historical_risk_count,
"created_at": datetime.now(UTC).isoformat(),
},
business_stage,
)
def _collect_budget_route_reasons(self, budget_result: dict[str, Any]) -> list[str]:
rating = str(budget_result.get("rating") or "").strip().lower()
risk_level = str(budget_result.get("risk_level") or "").strip().lower()
metrics = budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {}
context = (
budget_result.get("budget_context")
if isinstance(budget_result.get("budget_context"), dict)
else {}
)
reasons: list[str] = []
if context.get("budget_applicable") is True and context.get("matched") is False:
reasons.append("未匹配到可用预算池")
if rating in self._BUDGET_REVIEW_RATINGS:
summary = str(budget_result.get("summary") or "").strip()
reasons.append(summary or f"预算测算评级为 {rating}")
if risk_level in self._BUDGET_REVIEW_RISK_LEVELS:
reasons.append(f"预算风险等级为 {risk_level}")
over_budget_amount = self._decimal(metrics.get("over_budget_amount"))
if over_budget_amount > Decimal("0.00"):
reasons.append(f"预计超预算 {over_budget_amount}")
return self._dedupe_reasons(reasons)
def _collect_application_budget_route_reasons(self, budget_result: dict[str, Any]) -> list[str]:
metrics = budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {}
over_budget_amount = self._decimal(metrics.get("over_budget_amount"))
if over_budget_amount > Decimal("0.00"):
return [f"预计超预算 {over_budget_amount}"]
after_usage_rate = self._decimal(metrics.get("after_usage_rate"))
claim_amount_ratio = self._decimal(metrics.get("claim_amount_ratio"))
budget_usage_rate = max(after_usage_rate, claim_amount_ratio)
if budget_usage_rate >= self._APPLICATION_BUDGET_REVIEW_USAGE_THRESHOLD:
return [f"审批后预算占用达到 {budget_usage_rate}%,触发 90% 预算复核线"]
return []
def _collect_current_route_risk_reasons(
self,
risk_flags: list[Any] | None,
*,
business_stage: str,
) -> list[str]:
reasons: list[str] = []
for flag in list(risk_flags or []):
if not isinstance(flag, dict):
continue
flag_stage = risk_flag_business_stage(flag)
if flag_stage and flag_stage != business_stage:
continue
if not self._is_substantive_route_risk_flag(flag):
continue
label = str(flag.get("label") or flag.get("event_type") or "风险标记").strip()
message = str(flag.get("message") or "").strip()
reasons.append(f"{label}{message}" if message else label)
return self._dedupe_reasons(reasons)
def _count_recent_substantive_risky_claims(self, claim: ExpenseClaim) -> int:
filters = []
if claim.employee_id:
filters.append(ExpenseClaim.employee_id == claim.employee_id)
elif claim.employee_name:
filters.append(ExpenseClaim.employee_name == claim.employee_name)
if not filters:
return 0
since = datetime.now(UTC) - timedelta(days=AI_REVIEW_LOOKBACK_DAYS)
stmt = (
select(ExpenseClaim)
.where(or_(*filters))
.where(ExpenseClaim.id != claim.id)
.where(ExpenseClaim.occurred_at >= since)
)
return sum(
1
for item in self.db.scalars(stmt).all()
if any(
self._is_substantive_route_risk_flag(flag)
for flag in list(item.risk_flags_json or [])
if isinstance(flag, dict)
)
)
def _is_substantive_route_risk_flag(self, flag: dict[str, Any]) -> bool:
source = str(flag.get("source") or "").strip().lower()
if source in self._ROUTE_IGNORED_SOURCES:
return False
event_type = str(flag.get("event_type") or "").strip().lower()
severity = str(flag.get("severity") or "").strip().lower()
if source in {"budget", "budget_control"}:
return event_type in self._BUDGET_ROUTE_EVENT_TYPES or severity in {"high", "critical", "danger"}
if event_type in self._ROUTE_RISK_EVENT_TYPES:
return True
if severity in self._ROUTE_RISK_SEVERITIES:
return source in self._ROUTE_RISK_SOURCES or bool(source)
return source in self._ROUTE_RISK_SOURCES and bool(flag.get("triggered"))
@staticmethod
def _compact_budget_result(budget_result: dict[str, Any]) -> dict[str, Any]:
return {
"score": budget_result.get("score"),
"rating": budget_result.get("rating"),
"risk_level": budget_result.get("risk_level"),
"summary": budget_result.get("summary"),
"metrics": budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {},
}
@staticmethod
def _dedupe_reasons(reasons: list[str]) -> list[str]:
deduped: list[str] = []
seen: set[str] = set()
for reason in reasons:
text = str(reason or "").strip()
if not text or text in seen:
continue
seen.add(text)
deduped.append(text)
return deduped
@staticmethod
def _decimal(value: Any) -> Decimal:
try:
return Decimal(str(value if value is not None else "0")).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return Decimal("0.00")