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