Files
X-Financial/server/scripts/build_expense_control_demo_risk_rules.py

705 lines
24 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Build expense-control demo risk rule JSON manifests."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
SERVER_DIR = Path(__file__).resolve().parents[1]
RISK_RULE_DIR = SERVER_DIR / "rules" / "risk-rules"
BUDGET_EXPENSE_TYPES = (
"travel",
"hotel",
"transport",
"meal",
"meeting",
"marketing",
"office",
"training",
"software",
"communication",
"welfare",
)
FIELD_LABELS = {
"claim.amount": ("报销金额", "number", "claim"),
"claim.expense_type": ("费用类型", "enum", "claim"),
"claim.department_name": ("部门", "text", "claim"),
"claim.reason": ("事由", "text", "claim"),
"item.item_reason": ("明细说明", "text", "item"),
"application.id": ("申请单", "text", "application"),
"application.status": ("申请状态", "enum", "application"),
"application.approved_amount": ("申请审批金额", "number", "application"),
"application.expense_type": ("申请费用类型", "enum", "application"),
"application.department_name": ("申请部门", "text", "application"),
"application.start_date": ("申请开始日期", "date", "application"),
"application.end_date": ("申请结束日期", "date", "application"),
"budget.line_id": ("预算行", "text", "budget"),
"budget.available_amount": ("预算可用金额", "number", "budget"),
"budget.used_rate": ("预算使用率", "number", "budget"),
"budget.status": ("预算状态", "enum", "budget"),
"budget.department_name": ("预算部门", "text", "budget"),
"budget.quarter": ("预算季度", "text", "budget"),
"budget.project_code": ("预算项目", "text", "budget"),
"material.attachment_count": ("附件数量", "number", "material"),
"material.contract_uploaded": ("合同已上传", "boolean", "material"),
"material.acceptance_uploaded": ("验收材料已上传", "boolean", "material"),
"material.plan_uploaded": ("方案已上传", "boolean", "material"),
"material.attendee_list_uploaded": ("参与人清单已上传", "boolean", "material"),
"material.invoice_uploaded": ("发票已上传", "boolean", "material"),
}
BASE_FIELDS = (
"claim.amount",
"claim.expense_type",
"claim.department_name",
"claim.reason",
"item.item_reason",
)
BUDGET_FIELDS = BASE_FIELDS + (
"budget.line_id",
"budget.available_amount",
"budget.used_rate",
"budget.status",
"budget.department_name",
"budget.quarter",
"budget.project_code",
)
APPLICATION_FIELDS = BASE_FIELDS + (
"application.id",
"application.status",
"application.approved_amount",
"application.expense_type",
"application.department_name",
)
MATERIAL_FIELDS = BASE_FIELDS + (
"material.attachment_count",
"material.contract_uploaded",
"material.acceptance_uploaded",
"material.plan_uploaded",
"material.attendee_list_uploaded",
"material.invoice_uploaded",
)
@dataclass(frozen=True, slots=True)
class DemoRiskRule:
code: str
name: str
description: str
category: str
ontology_signal: str
finance_rule_code: str
finance_rule_sheet: str
business_stage: tuple[str, ...]
expense_types: tuple[str, ...]
condition_summary: str
keywords: tuple[str, ...]
severity: str
action: str
risk_score: int
risk_level: str
budget_required: bool = True
requires_attachment: bool = False
field_keys: tuple[str, ...] = field(default_factory=lambda: BASE_FIELDS)
def _budget_rule(
code: str,
name: str,
description: str,
condition_summary: str,
keywords: tuple[str, ...],
severity: str,
action: str,
risk_score: int,
) -> DemoRiskRule:
return DemoRiskRule(
code=code,
name=name,
description=description,
category="预算管控",
ontology_signal="budget_over_limit",
finance_rule_code="budget.execution.policy",
finance_rule_sheet="预算执行规则",
business_stage=("expense_application", "reimbursement", "budget_execution"),
expense_types=BUDGET_EXPENSE_TYPES,
condition_summary=condition_summary,
keywords=keywords,
severity=severity,
action=action,
risk_score=risk_score,
risk_level="high" if risk_score >= 80 else "medium",
field_keys=BUDGET_FIELDS,
)
def _rule_payload(rule: DemoRiskRule) -> dict[str, object]:
now = datetime.now(UTC).isoformat()
fields = [
{
"key": key,
"label": FIELD_LABELS[key][0],
"type": FIELD_LABELS[key][1],
"source": FIELD_LABELS[key][2],
}
for key in rule.field_keys
]
return {
"schema_version": "2.0",
"rule_code": rule.code,
"name": rule.name,
"description": rule.description,
"enabled": True,
"requires_attachment": rule.requires_attachment,
"risk_dimension": "expense_control_demo",
"risk_category": rule.category,
"ontology_signal": rule.ontology_signal,
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": rule.finance_rule_code,
"finance_rule_sheet": rule.finance_rule_sheet,
"business_stage": list(rule.business_stage),
"expense_types": list(rule.expense_types),
"budget_required": rule.budget_required,
"applies_to": {
"domains": ["expense"],
"expense_types": list(rule.expense_types),
"business_stages": list(rule.business_stage),
},
"inputs": {"fields": fields},
"params": {
"template_key": "keyword_match_v1",
"field_keys": list(rule.field_keys),
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type",
],
"keywords": list(rule.keywords),
"condition_summary": rule.condition_summary,
"finance_rule_code": rule.finance_rule_code,
"finance_rule_sheet": rule.finance_rule_sheet,
"business_stage": list(rule.business_stage),
"expense_types": list(rule.expense_types),
"budget_required": rule.budget_required,
},
"outcomes": {
"pass": {"severity": "none", "action": "continue"},
"fail": {
"severity": rule.severity,
"action": rule.action,
"risk_score": rule.risk_score,
},
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": now,
"created_by": "system",
"risk_score": rule.risk_score,
"risk_level": rule.risk_level,
"rule_title": rule.name,
"finance_rule_code": rule.finance_rule_code,
"finance_rule_sheet": rule.finance_rule_sheet,
"business_stage": list(rule.business_stage),
"expense_types": list(rule.expense_types),
"budget_required": rule.budget_required,
},
"severity": rule.severity,
"risk_score": rule.risk_score,
"risk_level": rule.risk_level,
}
RULES: tuple[DemoRiskRule, ...] = (
_budget_rule(
"risk.budget.available_balance_insufficient",
"预算可用余额不足",
"提交后预算余额为负,或当前可用预算不足以覆盖本次申请/报销金额。",
"预算可用金额小于本次金额时触发。",
("预算不足", "可用余额不足", "超预算"),
"high",
"manual_review",
88,
),
_budget_rule(
"risk.budget.usage_warning_80",
"预算使用率达到 80% 预警",
"报销或申请通过后,部门/项目/费用类型预算使用率达到 80% 以上。",
"预算使用率大于等于 80% 且低于 100% 时触发。",
("预算预警", "80%", "使用率过高"),
"medium",
"warning",
70,
),
_budget_rule(
"risk.budget.usage_over_100",
"预算使用率超过 100% 管控",
"报销或申请通过后,预算使用率超过 100%,需要阻断或升级审批。",
"预算使用率超过 100% 时触发。",
("预算超支", "超过100%", "禁止提交"),
"critical",
"block_submit",
96,
),
_budget_rule(
"risk.budget.frozen_or_closed_used",
"使用冻结或关闭预算",
"单据引用了已冻结、已关闭或已作废的预算行。",
"预算状态不是启用时触发。",
("冻结预算", "关闭预算", "预算作废"),
"high",
"block_submit",
90,
),
_budget_rule(
"risk.budget.missing_budget_line",
"缺少预算口径",
"需要预算管控的费用未关联年度、季度、部门、项目或费用类型预算。",
"费用类型要求预算管控但预算行为空时触发。",
("无预算", "预算口径缺失", "未关联预算"),
"high",
"manual_review",
82,
),
_budget_rule(
"risk.budget.cross_department_without_authorization",
"跨部门预算未授权",
"报销部门与预算归属部门不一致,且没有跨部门预算授权。",
"单据部门与预算部门不一致且无授权说明时触发。",
("跨部门预算", "部门不一致", "未授权"),
"high",
"manual_review",
86,
),
_budget_rule(
"risk.budget.cross_quarter_without_explanation",
"跨季度预算未说明",
"单据发生期间与预算季度不一致,且缺少跨季度使用说明。",
"发生季度与预算季度不一致且未说明时触发。",
("跨季度预算", "季度不一致", "未说明"),
"medium",
"manual_review",
76,
),
_budget_rule(
"risk.budget.project_department_mismatch",
"项目预算与部门不匹配",
"单据引用的项目预算不属于当前部门或当前成本中心。",
"项目预算归属与报销部门不一致时触发。",
("项目预算", "成本中心不匹配", "部门不匹配"),
"high",
"manual_review",
84,
),
_budget_rule(
"risk.budget.duplicate_reserve",
"重复占用预算",
"同一申请、项目或合同已占用预算,本次单据再次占用同一预算口径。",
"相同业务标识存在未释放预算占用时触发。",
("重复占用", "预算锁定", "重复申请"),
"medium",
"manual_review",
74,
),
_budget_rule(
"risk.budget.consume_without_release",
"预算占用未释放",
"申请取消、退回或驳回后,预算占用未释放导致后续可用预算失真。",
"申请非有效状态但仍存在预算占用时触发。",
("占用未释放", "退回未释放", "预算释放"),
"medium",
"manual_review",
72,
),
DemoRiskRule(
"risk.application.large_expense_without_preapproval",
"大额费用未事前申请",
"达到财务制度中大额标准的费用,未找到有效事前申请即进入报销。",
"申请前置",
"application_required",
"finance.preapproval.policy",
"费用申请前置规则",
("reimbursement",),
BUDGET_EXPENSE_TYPES,
"金额达到大额阈值且缺少已通过申请单时触发。",
("大额费用", "未申请", "先申请后报销"),
"high",
"manual_review",
86,
"high",
field_keys=APPLICATION_FIELDS,
),
DemoRiskRule(
"risk.application.marketing_without_campaign",
"市场推广费无活动申请",
"市场活动、投放、展会等推广费用,缺少已审批的活动申请或投放方案。",
"申请前置",
"application_required",
"expense.application.policy",
"费用申请前置规则",
("reimbursement",),
("marketing",),
"市场推广费报销缺少活动申请或方案时触发。",
("市场推广", "活动申请", "投放方案"),
"high",
"manual_review",
84,
"high",
field_keys=APPLICATION_FIELDS + ("material.plan_uploaded",),
),
DemoRiskRule(
"risk.application.training_without_plan",
"培训费无培训计划",
"培训费报销缺少年度培训计划、专项培训申请或审批记录。",
"申请前置",
"application_required",
"expense.application.policy",
"费用申请前置规则",
("reimbursement",),
("training",),
"培训费用没有匹配到培训计划或事前申请时触发。",
("培训计划", "培训申请", "未申请"),
"high",
"manual_review",
83,
"high",
field_keys=APPLICATION_FIELDS + ("material.plan_uploaded",),
),
DemoRiskRule(
"risk.application.meeting_without_application",
"会务费无会议申请",
"会务场地、物料、会务服务等费用缺少会议申请或会议预算审批。",
"申请前置",
"application_required",
"expense.application.policy",
"费用申请前置规则",
("reimbursement",),
("meeting",),
"会务费报销缺少会议申请、会议预算或会议通知时触发。",
("会议申请", "会议预算", "会务费"),
"high",
"manual_review",
82,
"high",
field_keys=APPLICATION_FIELDS + ("material.plan_uploaded",),
),
DemoRiskRule(
"risk.application.software_without_purchase",
"软件服务费无采购申请",
"软件订阅、SaaS 服务或系统实施费用缺少采购申请或审批链。",
"申请前置",
"application_required",
"expense.application.policy",
"费用申请前置规则",
("reimbursement",),
("software",),
"软件服务费缺少采购申请或合同审批时触发。",
("软件采购", "SaaS", "采购申请"),
"high",
"manual_review",
85,
"high",
field_keys=APPLICATION_FIELDS + ("material.contract_uploaded",),
),
DemoRiskRule(
"risk.application.office_bulk_without_purchase",
"办公用品大额采购未申请",
"批量办公用品或设备采购达到阈值但未走采购申请。",
"申请前置",
"application_required",
"expense.application.policy",
"费用申请前置规则",
("reimbursement",),
("office",),
"办公用品单次金额达到采购阈值且缺少采购申请时触发。",
("办公采购", "大额办公用品", "采购申请"),
"medium",
"manual_review",
78,
"medium",
field_keys=APPLICATION_FIELDS,
),
DemoRiskRule(
"risk.application.meal_high_value_without_preapproval",
"大额业务招待未申请",
"业务招待金额或人均金额超过制度阈值但未事前审批。",
"申请前置",
"application_required",
"expense.application.policy",
"费用申请前置规则",
("reimbursement",),
("meal",),
"业务招待金额超过申请阈值且没有通过申请时触发。",
("业务招待", "人均超标", "未申请"),
"high",
"manual_review",
84,
"high",
field_keys=APPLICATION_FIELDS + ("material.attendee_list_uploaded",),
),
DemoRiskRule(
"risk.application.travel_large_without_preapproval",
"大额差旅未申请",
"多人出差、长周期出差或高金额差旅报销缺少出差申请。",
"申请前置",
"application_required",
"rule.expense.company_travel_expense_reimbursement",
"差旅住宿费标准",
("reimbursement",),
("travel", "hotel", "transport"),
"差旅金额达到大额阈值且缺少有效出差申请时触发。",
("差旅申请", "大额差旅", "未申请"),
"high",
"manual_review",
82,
"high",
field_keys=APPLICATION_FIELDS,
),
DemoRiskRule(
"risk.reimbursement.amount_over_application",
"报销金额超过申请金额",
"报销总金额超过已审批申请金额,需要按偏差规则复核。",
"报销偏差",
"amount_over_application",
"application.reimbursement.linkage.policy",
"申请报销关联规则",
("reimbursement",),
BUDGET_EXPENSE_TYPES,
"报销金额大于申请审批金额时触发。",
("超过申请金额", "报销偏差", "申请金额"),
"medium",
"manual_review",
76,
"medium",
field_keys=APPLICATION_FIELDS,
),
DemoRiskRule(
"risk.reimbursement.amount_over_application_10pct",
"报销金额超过申请金额 10%",
"报销金额比申请审批金额高出 10% 以上,需要升级审批或禁止提交。",
"报销偏差",
"amount_over_application",
"application.reimbursement.linkage.policy",
"申请报销关联规则",
("reimbursement",),
BUDGET_EXPENSE_TYPES,
"报销金额超过申请审批金额 10% 以上时触发。",
("超过申请10%", "金额偏差", "升级审批"),
"high",
"manual_review",
88,
"high",
field_keys=APPLICATION_FIELDS,
),
DemoRiskRule(
"risk.reimbursement.expense_type_mismatch_application",
"报销费用类型与申请不一致",
"报销单费用类型与关联申请单费用类型不一致。",
"报销偏差",
"expense_type_mismatch",
"application.reimbursement.linkage.policy",
"申请报销关联规则",
("reimbursement",),
BUDGET_EXPENSE_TYPES,
"报销费用类型与申请费用类型不一致时触发。",
("费用类型不一致", "申请报销不匹配", "类型偏差"),
"medium",
"manual_review",
74,
"medium",
field_keys=APPLICATION_FIELDS,
),
DemoRiskRule(
"risk.reimbursement.department_mismatch_application",
"报销部门与申请部门不一致",
"报销部门、成本中心与关联申请单不一致,且缺少调整说明。",
"报销偏差",
"department_mismatch",
"application.reimbursement.linkage.policy",
"申请报销关联规则",
("reimbursement",),
BUDGET_EXPENSE_TYPES,
"报销部门与申请部门不一致时触发。",
("部门不一致", "成本中心偏差", "申请部门"),
"medium",
"manual_review",
72,
"medium",
field_keys=APPLICATION_FIELDS,
),
DemoRiskRule(
"risk.reimbursement.period_outside_application",
"报销发生期间超出申请期间",
"费用发生日期不在已审批申请的起止日期范围内。",
"报销偏差",
"period_outside_application",
"application.reimbursement.linkage.policy",
"申请报销关联规则",
("reimbursement",),
BUDGET_EXPENSE_TYPES,
"发生日期超出申请有效期间时触发。",
("期间不一致", "超出申请期间", "日期偏差"),
"medium",
"manual_review",
70,
"medium",
field_keys=APPLICATION_FIELDS + ("application.start_date", "application.end_date"),
),
DemoRiskRule(
"risk.reimbursement.rejected_application_claimed",
"已驳回申请被用于报销",
"报销单关联的申请单为驳回、撤回或已取消状态。",
"报销偏差",
"invalid_application_status",
"application.reimbursement.linkage.policy",
"申请报销关联规则",
("reimbursement",),
BUDGET_EXPENSE_TYPES,
"关联申请状态不是已通过或已完成时触发。",
("申请驳回", "申请撤回", "无效申请"),
"high",
"block_submit",
92,
"high",
field_keys=APPLICATION_FIELDS,
),
DemoRiskRule(
"risk.reimbursement.duplicate_against_application",
"同一申请重复报销",
"同一申请单或同一合同/项目存在多笔疑似重复报销。",
"报销偏差",
"duplicate_reimbursement",
"application.reimbursement.linkage.policy",
"申请报销关联规则",
("reimbursement",),
BUDGET_EXPENSE_TYPES,
"同一申请的已报销金额与本次金额超过申请金额时触发。",
("重复报销", "同一申请", "重复占用"),
"high",
"manual_review",
86,
"high",
field_keys=APPLICATION_FIELDS,
),
DemoRiskRule(
"risk.standard.meal_participants_missing",
"业务招待缺少参与人清单",
"业务招待费要求提供客户名称、参与人清单和招待说明。",
"材料完整性",
"material_missing",
"expense.material.policy",
"材料完整性规则",
("reimbursement",),
("meal",),
"业务招待费缺少参与人清单或客户信息时触发。",
("参与人清单", "客户信息", "业务招待"),
"medium",
"manual_review",
72,
"medium",
requires_attachment=True,
field_keys=MATERIAL_FIELDS,
),
DemoRiskRule(
"risk.standard.software_contract_missing",
"软件服务费缺少合同",
"软件服务费、SaaS 订阅或系统实施费用缺少合同、订单或验收材料。",
"材料完整性",
"contract_missing",
"expense.material.policy",
"材料完整性规则",
("expense_application", "reimbursement"),
("software",),
"软件服务费要求合同或订单但未提供时触发。",
("合同缺失", "软件服务", "验收材料"),
"high",
"manual_review",
84,
"high",
requires_attachment=True,
field_keys=MATERIAL_FIELDS,
),
DemoRiskRule(
"risk.standard.marketing_acceptance_missing",
"市场推广费缺少验收材料",
"市场推广活动缺少投放截图、活动复盘、验收单或结案材料。",
"材料完整性",
"acceptance_missing",
"expense.material.policy",
"材料完整性规则",
("reimbursement",),
("marketing",),
"市场推广费要求验收或结案材料但未提供时触发。",
("验收材料", "活动结案", "投放截图"),
"high",
"manual_review",
82,
"high",
requires_attachment=True,
field_keys=MATERIAL_FIELDS,
),
DemoRiskRule(
"risk.standard.office_fixed_asset_as_office",
"固定资产伪装为办公用品费",
"办公用品费明细疑似包含固定资产、电子设备或应走采购入库的物品。",
"费用标准",
"expense_type_mismatch",
"expense.classification.policy",
"费用类型归类规则",
("reimbursement",),
("office",),
"办公用品费包含固定资产关键词或超过采购阈值时触发。",
("固定资产", "电脑", "显示器", "办公设备"),
"medium",
"manual_review",
78,
"medium",
field_keys=BASE_FIELDS,
),
DemoRiskRule(
"risk.standard.meeting_attendee_list_missing",
"会务费缺少参会名单",
"会务费报销缺少参会名单、会议通知、会议照片或会议纪要。",
"材料完整性",
"material_missing",
"expense.material.policy",
"材料完整性规则",
("reimbursement",),
("meeting",),
"会务费要求参会名单或会议材料但未提供时触发。",
("参会名单", "会议通知", "会议纪要"),
"medium",
"manual_review",
74,
"medium",
requires_attachment=True,
field_keys=MATERIAL_FIELDS,
),
)
def main() -> None:
RISK_RULE_DIR.mkdir(parents=True, exist_ok=True)
for rule in RULES:
path = RISK_RULE_DIR / f"{rule.code}.json"
payload = _rule_payload(rule)
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(f"Generated {len(RULES)} expense-control demo risk rule manifest(s).")
if __name__ == "__main__":
main()