256 lines
10 KiB
Python
256 lines
10 KiB
Python
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")
|