后端新增预算费控服务和报销单审批流模块,引入申请人费用画像 算法,优化知识库 RAG 运行时和同步逻辑,完善报销单工作流常 量和明细同步,更新差旅报销规则电子表格,前端新增预算分析 组件和数字员工模型,完善审批对话框和洞察面板交互,优化侧 边栏和顶栏样式,补充单元测试。
195 lines
8.7 KiB
Python
195 lines
8.7 KiB
Python
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'))}"
|
|
|