feat: 新增预算费控模型与报销审批流引擎
后端新增预算费控服务和报销单审批流模块,引入申请人费用画像 算法,优化知识库 RAG 运行时和同步逻辑,完善报销单工作流常 量和明细同步,更新差旅报销规则电子表格,前端新增预算分析 组件和数字员工模型,完善审批对话框和洞察面板交互,优化侧 边栏和顶栏样式,补充单元测试。
This commit is contained in:
194
server/src/app/services/budget_expense_control.py
Normal file
194
server/src/app/services/budget_expense_control.py
Normal file
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
|
||||
|
||||
class BudgetExpenseControlModel:
|
||||
"""预算费用管控模型:用预算容量、单据影响、规则动作和信息完整度给出建议。"""
|
||||
|
||||
def assess(self, budget_context: dict[str, Any], claim: ExpenseClaim | None = None) -> dict[str, Any]:
|
||||
context = dict(budget_context or {})
|
||||
amount = self._money(context.get("claim_amount"))
|
||||
if not context.get("budget_applicable", True):
|
||||
return self._build_result(
|
||||
context=context,
|
||||
score=72,
|
||||
rating="reference",
|
||||
risk_level="low",
|
||||
summary="该费用类型暂未纳入预算强管控,本次仅作为申请合理性参考。",
|
||||
basis=["当前费用科目未启用预算控制。"],
|
||||
suggestions=["预算管理者可结合项目必要性和历史同类费用进行人工判断。"],
|
||||
)
|
||||
if not context.get("matched"):
|
||||
return self._build_result(
|
||||
context=context,
|
||||
score=38,
|
||||
rating="block",
|
||||
risk_level="high",
|
||||
summary="未匹配到可用预算池,建议先完成预算编制或调整预算维度后再审批。",
|
||||
basis=["系统按部门、成本中心、项目和费用科目未找到匹配预算额度。"],
|
||||
suggestions=["请预算编制者补充对应预算池,或核对申请部门、项目和费用类型是否填写正确。"],
|
||||
)
|
||||
|
||||
total = self._money(context.get("total_amount"))
|
||||
reserved = self._money(context.get("reserved_amount"))
|
||||
consumed = self._money(context.get("consumed_amount"))
|
||||
current_reserved = self._money(context.get("current_reserved_amount"))
|
||||
warning_threshold = self._money(context.get("warning_threshold") or "80")
|
||||
used_before = max(reserved + consumed - current_reserved, Decimal("0.00"))
|
||||
available_before = max(total - used_before, Decimal("0.00"))
|
||||
after_used = used_before + amount
|
||||
claim_ratio = self._percent(amount, total)
|
||||
after_usage_rate = self._percent(after_used, total)
|
||||
over_budget_amount = max(amount - available_before, Decimal("0.00"))
|
||||
|
||||
score = 100
|
||||
basis = [
|
||||
f"预算池 {context.get('budget_no') or '未命名'} 总额度 {self._fmt(total)} 元。",
|
||||
f"本次申请金额 {self._fmt(amount)} 元,占预算 {self._fmt(claim_ratio)}%。",
|
||||
f"审批后预算使用率预计 {self._fmt(after_usage_rate)}%,预警线 {self._fmt(warning_threshold)}%。",
|
||||
]
|
||||
suggestions: list[str] = []
|
||||
|
||||
if over_budget_amount > Decimal("0.00"):
|
||||
score -= 55
|
||||
basis.append(f"按当前预算余额测算,本次申请将超出预算 {self._fmt(over_budget_amount)} 元。")
|
||||
suggestions.append("建议先追加或调剂预算,再允许申请继续流转。")
|
||||
elif after_usage_rate >= Decimal("100.00"):
|
||||
score -= 38
|
||||
basis.append("审批后预算使用率将达到或超过 100%。")
|
||||
suggestions.append("建议预算管理者复核剩余额度,并确认是否需要预算调剂。")
|
||||
elif after_usage_rate >= warning_threshold:
|
||||
score -= 20
|
||||
basis.append("审批后预算使用率将触达预算预警线。")
|
||||
suggestions.append("建议关注后续同类费用,必要时提前调整预算节奏。")
|
||||
elif after_usage_rate >= Decimal("70.00"):
|
||||
score -= 8
|
||||
basis.append("审批后预算使用率较高,但尚未触达预警线。")
|
||||
|
||||
if claim_ratio >= Decimal("50.00"):
|
||||
score -= 20
|
||||
basis.append("单笔申请占预算比例超过 50%,对预算池影响较大。")
|
||||
suggestions.append("建议补充业务必要性、交付范围和费用拆分依据。")
|
||||
elif claim_ratio >= Decimal("30.00"):
|
||||
score -= 12
|
||||
basis.append("单笔申请占预算比例超过 30%,需要关注预算节奏。")
|
||||
elif claim_ratio >= Decimal("15.00"):
|
||||
score -= 5
|
||||
basis.append("单笔申请占预算比例超过 15%,属于中等预算影响。")
|
||||
|
||||
missing_fields = self._collect_context_gaps(claim)
|
||||
if missing_fields:
|
||||
score -= min(12, len(missing_fields) * 4)
|
||||
basis.append(f"申请信息仍缺少:{'、'.join(missing_fields)}。")
|
||||
suggestions.append("建议申请人补齐业务背景,便于预算管理者判断费用必要性。")
|
||||
|
||||
control_action = str(context.get("control_action") or "").strip().lower()
|
||||
if control_action == "block" and over_budget_amount > Decimal("0.00"):
|
||||
suggestions.append("该预算池为硬控制口径,超预算时不建议直接通过。")
|
||||
|
||||
score = max(0, min(100, int(round(score))))
|
||||
rating, risk_level = self._rate(score, over_budget_amount)
|
||||
if not suggestions:
|
||||
suggestions.append("预算额度与本次费用影响基本匹配,可以结合业务必要性继续审批。")
|
||||
|
||||
return self._build_result(
|
||||
context={
|
||||
**context,
|
||||
"claim_amount_ratio": str(claim_ratio),
|
||||
"after_usage_rate": str(after_usage_rate),
|
||||
"available_before_amount": str(available_before),
|
||||
"over_budget_amount": str(over_budget_amount),
|
||||
},
|
||||
score=score,
|
||||
rating=rating,
|
||||
risk_level=risk_level,
|
||||
summary=self._summary(rating),
|
||||
basis=basis,
|
||||
suggestions=suggestions,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _collect_context_gaps(claim: ExpenseClaim | None) -> list[str]:
|
||||
if claim is None:
|
||||
return []
|
||||
gaps = []
|
||||
if not str(claim.reason or "").strip():
|
||||
gaps.append("申请事由")
|
||||
if not str(claim.location or "").strip():
|
||||
gaps.append("地点")
|
||||
if not str(claim.project_code or "").strip():
|
||||
gaps.append("项目编号")
|
||||
return gaps
|
||||
|
||||
@staticmethod
|
||||
def _rate(score: int, over_budget_amount: Decimal) -> tuple[str, str]:
|
||||
if over_budget_amount > Decimal("0.00") or score < 50:
|
||||
return "block", "high"
|
||||
if score < 70:
|
||||
return "review", "medium"
|
||||
if score < 85:
|
||||
return "caution", "medium"
|
||||
return "recommended", "low"
|
||||
|
||||
@staticmethod
|
||||
def _summary(rating: str) -> str:
|
||||
summaries = {
|
||||
"recommended": "预算容量充足,单据费用与当前预算节奏基本匹配。",
|
||||
"caution": "预算整体可承接,但本次费用对预算池已有一定影响。",
|
||||
"review": "预算影响偏高,建议预算管理者结合业务必要性复核后再通过。",
|
||||
"block": "预算风险较高,不建议在未补充依据或调整预算前直接通过。",
|
||||
}
|
||||
return summaries.get(rating, "已完成预算费用合理性测算。")
|
||||
|
||||
@staticmethod
|
||||
def _build_result(
|
||||
*,
|
||||
context: dict[str, Any],
|
||||
score: int,
|
||||
rating: str,
|
||||
risk_level: str,
|
||||
summary: str,
|
||||
basis: list[str],
|
||||
suggestions: list[str],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"budget_context": context,
|
||||
"score": score,
|
||||
"rating": rating,
|
||||
"risk_level": risk_level,
|
||||
"summary": summary,
|
||||
"basis": basis,
|
||||
"suggestions": suggestions,
|
||||
"metrics": {
|
||||
"claim_amount": context.get("claim_amount"),
|
||||
"total_amount": context.get("total_amount"),
|
||||
"claim_amount_ratio": context.get("claim_amount_ratio", "0.00"),
|
||||
"usage_rate": context.get("usage_rate", "0.00"),
|
||||
"after_usage_rate": context.get("after_usage_rate", context.get("usage_rate", "0.00")),
|
||||
"available_amount": context.get("available_amount"),
|
||||
"available_before_amount": context.get("available_before_amount"),
|
||||
"over_budget_amount": context.get("over_budget_amount", "0.00"),
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _money(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")
|
||||
|
||||
@staticmethod
|
||||
def _percent(numerator: Decimal, denominator: Decimal) -> Decimal:
|
||||
if denominator <= Decimal("0.00"):
|
||||
return Decimal("0.00")
|
||||
return ((numerator / denominator) * Decimal("100")).quantize(Decimal("0.01"))
|
||||
|
||||
@staticmethod
|
||||
def _fmt(value: Decimal) -> str:
|
||||
return f"{value.quantize(Decimal('0.01'))}"
|
||||
|
||||
Reference in New Issue
Block a user