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")