feat: 新增预算费控模型与报销审批流引擎

后端新增预算费控服务和报销单审批流模块,引入申请人费用画像
算法,优化知识库 RAG 运行时和同步逻辑,完善报销单工作流常
量和明细同步,更新差旅报销规则电子表格,前端新增预算分析
组件和数字员工模型,完善审批对话框和洞察面板交互,优化侧
边栏和顶栏样式,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-27 17:31:27 +08:00
parent cbb98f4469
commit d4d5d40569
75 changed files with 5393 additions and 686 deletions

View 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'))}"