feat: 新增预算后端服务与差旅风险规则库

后端新增预算模型、端点和服务模块,支持预算 CRUD 和余额
查询,清理旧生成规则文件并替换为按严重等级分类的差旅风
险规则库,优化认证权限和报销单访问策略,新增财务规则目
录和演示数据构建脚本,前端预算中心增加对话框交互,完善
审计页面运行时模型和元数据展示,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-26 17:29:35 +08:00
parent e1e515ecae
commit e7bef0883d
85 changed files with 6443 additions and 1497 deletions

View File

@@ -10,9 +10,11 @@ from app.models.agent_asset import AgentAsset
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.budget import BudgetService
from app.services.expense_rule_runtime import (
RuntimeTravelPolicy,
)
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
@@ -29,6 +31,16 @@ class ExpenseClaimPlatformRiskMixin:
return {"flags": [], "blocking_reasons": []}
contexts = self._build_claim_attachment_contexts(claim)
contexts.append(
{
"index": len(contexts) + 1,
"item": None,
"document_info": {},
"ocr_text": "",
"ocr_summary": "",
"budget_context": BudgetService(self.db).build_claim_budget_context(claim),
}
)
flags: list[dict[str, Any]] = []
blocking_reasons: list[str] = []
@@ -163,24 +175,18 @@ class ExpenseClaimPlatformRiskMixin:
if min_attachments and int(claim.invoice_count or 0) < min_attachments and not contexts:
return False
expense_types = {
str(claim.expense_type or "").strip().lower(),
*{
str(item.item_type or "").strip().lower()
for item in list(claim.items or [])
if str(item.item_type or "").strip()
},
}
expense_types = self._normalize_expense_type_values(
str(claim.expense_type or ""),
*[str(item.item_type or "") for item in list(claim.items or [])],
)
domains = {
str(value or "").strip().lower()
for value in list(applies_to.get("domains") or [])
if str(value or "").strip()
}
configured_expense_types = {
str(value or "").strip().lower()
for value in list(applies_to.get("expense_types") or [])
if str(value or "").strip()
}
configured_expense_types = self._normalize_expense_type_values(
*[str(value or "") for value in list(applies_to.get("expense_types") or [])]
)
if configured_expense_types and not (expense_types & configured_expense_types):
return False
@@ -193,6 +199,19 @@ class ExpenseClaimPlatformRiskMixin:
return True
@staticmethod
def _normalize_expense_type_values(*values: str) -> set[str]:
normalized: set[str] = set()
for value in values:
raw = str(value or "").strip()
if not raw:
continue
normalized.add(raw.lower())
resolved = resolve_expense_type_code_from_text(raw)
if resolved:
normalized.add(resolved)
return normalized
def _risk_domains_match_claim(
self,
domains: set[str],
@@ -213,6 +232,9 @@ class ExpenseClaimPlatformRiskMixin:
}
)
if "expense" in domains:
return True
if "travel" in domains:
if expense_types & {"travel", "hotel", "transport"}:
return True