#!/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 = ("all",) SUPPORTED_DEMO_EXPENSE_TYPES = { "all", "travel", "hotel", "transport", "meal", "office", "communication", } 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",), "差旅金额达到大额阈值且缺少有效出差申请时触发。", ("差旅申请", "大额差旅", "未申请"), "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, ), ) COMMUNICATION_RULES: tuple[DemoRiskRule, ...] = ( DemoRiskRule( "risk.standard.communication_amount_over_policy", "通信费金额超过月度标准", "通信费、话费、流量费或宽带费超过公司月度标准,且缺少岗位必要性或专项审批说明。", "费用标准", "expense_standard_over_limit", "expense.communication.policy", "通信费报销规则", ("expense_application", "reimbursement"), ("communication",), "通信费金额超过公司标准且没有岗位、项目或专项审批说明时触发。", ("通信费", "话费", "流量费", "宽带费", "超标准"), "medium", "manual_review", 68, "medium", field_keys=BASE_FIELDS + ("material.invoice_uploaded",), ), DemoRiskRule( "risk.standard.communication_account_mismatch", "通信账户归属与报销人不一致", "通信票据、运营商账单或号码归属信息与报销人不一致,且缺少代垫或统一缴费说明。", "费用归属", "expense_owner_mismatch", "expense.communication.policy", "通信费报销规则", ("reimbursement",), ("communication",), "通信账户归属与报销人不一致且没有代垫、统一缴费或部门公共号码说明时触发。", ("号码归属", "账户不一致", "代垫", "统一缴费", "公共号码"), "high", "manual_review", 82, "high", requires_attachment=True, field_keys=MATERIAL_FIELDS, ), ) def _is_supported_demo_rule(rule: DemoRiskRule) -> bool: return all(expense_type in SUPPORTED_DEMO_EXPENSE_TYPES for expense_type in rule.expense_types) RULES = tuple(rule for rule in RULES if _is_supported_demo_rule(rule)) + COMMUNICATION_RULES 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()