Notes on tech,life,etc.
by YNotes.cc
设计一套工业级的会计规则引擎 (Accounting Rule Engine, ARE),其核心目标是实现 “业务语言”到“会计语言”的自动化翻译。
这套引擎必须具备高配置性(业务变动不改代码)、高扩展性(支持多准则)和可追溯性。
以下是基于 Java 生态的详细设计方案,包含数据模型设计、核心组件实现以及配置示例。
在设计代码之前,必须先定义“会计元数据”:
EventCode (事件码) + ProductCode (产品码) 唯一确定。机构号-科目号-币种-客户类)。我们需要将规则存入数据库。
ARE_SCENARIO_DEF)| 字段 | 类型 | 说明 | 示例 |
|---|---|---|---|
ID |
Long | 主键 | 1 |
SCENE_CODE |
String | 业务场景代码 | LOAN_DISBURSEMENT (贷款放款) |
PRODUCT_TYPE |
String | 产品类型 | PERSONAL_LOAN |
GAAP_TYPE |
String | 会计准则 | LOCAL (本地准则) / IFRS9 |
STATUS |
String | 状态 | ACTIVE |
ARE_ENTRY_RULE)这是核心表,定义一个场景下产生哪些分录。
| 字段 | 类型 | 说明 | 示例值 (Expression) |
|---|---|---|---|
ID |
Long | 主键 | 101 |
SCENE_ID |
Long | 外键关联场景 | 1 |
SEQ_NO |
Int | 排序号 | 1 |
DC_FLAG |
String | 借贷方向 | D (Debit) / C (Credit) |
GL_ACCOUNT_EXPR |
String | 科目解析表达式 | '1011.' + #event.branchId (拼接机构号) |
AMOUNT_EXPR |
String | 金额计算表达式 | #event.amount (直接取交易额) |
CONDITION_EXPR |
String | 生效条件表达式 | #event.currency == 'USD' (仅美元生效) |
DESC_TEMPLATE |
String | 摘要模板 | '放款给客户: ' + #event.customerName |
为了支持动态解析,我们引入轻量级脚本引擎。这里推荐使用 SpEL (Spring Expression Language) 或 Aviator(高性能,阿里开源)。
<!-- 高性能表达式引擎 -->
<dependency>
<groupId>com.googlecode.aviator</groupId>
<artifactId>aviator</artifactId>
<version>5.3.3</version>
</dependency>
// 输入:业务事实 (Context)
@Data
public class BusinessFact {
private String eventCode; // 事件: LOAN_REPAY
private String productCode; // 产品: S101
private BigDecimal amount; // 交易金额
private String currency; // 币种
private String branchId; // 机构号
private Map<String, Object> ext;// 扩展参数 (利息、罚息、税费等)
}
// 输出:会计凭证
@Data
public class AccountingVoucher {
private String voucherId;
private List<VoucherEntry> entries = new ArrayList<>();
// 借贷平衡校验
public boolean isBalanced() { ... }
}
@Data
@Builder
public class VoucherEntry {
private String glAccount; // 解析后的完整科目号
private String dcDirection; // D or C
private BigDecimal amount; // 计算后的金额
private String narrative; // 摘要
}
这是引擎的心脏,负责执行数据库里存的那串字符串表达式。
@Component
public class RuleExpressionEngine {
/**
* 解析科目表达式
* 示例表达式: "2001." + branchId + "." + currency
*/
public String evaluateAccount(String expression, BusinessFact fact) {
Map<String, Object> env = BeanUtil.beanToMap(fact); // 将对象转为 Map
// 加上扩展字段
if (fact.getExt() != null) env.putAll(fact.getExt());
// 使用 Aviator 执行
return (String) AviatorEvaluator.execute(expression, env);
}
/**
* 解析金额表达式
* 示例表达式: amount * 0.1 (比如计算税费)
*/
public BigDecimal evaluateAmount(String expression, BusinessFact fact) {
Map<String, Object> env = BeanUtil.beanToMap(fact);
if (fact.getExt() != null) env.putAll(fact.getExt());
return new BigDecimal(AviatorEvaluator.execute(expression, env).toString());
}
/**
* 解析条件
* 示例表达式: ext.isVip == true
*/
public boolean evaluateCondition(String expression, BusinessFact fact) {
if (StringUtils.isBlank(expression)) return true; // 无条件则默认生效
Map<String, Object> env = BeanUtil.beanToMap(fact);
if (fact.getExt() != null) env.putAll(fact.getExt());
return (Boolean) AviatorEvaluator.execute(expression, env);
}
}
@Service
public class AccountingRuleEngine {
@Autowired
private RuleRepository ruleRepo; // 数据库访问
@Autowired
private RuleExpressionEngine expressionEngine;
/**
* 核心接口:翻译
*/
public AccountingVoucher translate(BusinessFact fact) {
// 1. 查找规则集 (通常带缓存)
List<AreEntryRule> rules = ruleRepo.findRules(fact.getEventCode(), fact.getProductCode());
if (CollectionUtils.isEmpty(rules)) {
throw new AccountingException("No accounting rules found for: " + fact.getEventCode());
}
AccountingVoucher voucher = new AccountingVoucher();
voucher.setVoucherId(UUID.randomUUID().toString());
// 2. 遍历每一条规则,生成分录
for (AreEntryRule rule : rules) {
// 2.1 检查生效条件 (Condition Check)
if (!expressionEngine.evaluateCondition(rule.getConditionExpr(), fact)) {
continue; // 条件不满足,跳过此分录
}
// 2.2 动态计算金额
BigDecimal entryAmount = expressionEngine.evaluateAmount(rule.getAmountExpr(), fact);
// 金额为0通常不记账
if (entryAmount.compareTo(BigDecimal.ZERO) == 0) continue;
// 2.3 动态解析科目
String glAccount = expressionEngine.evaluateAccount(rule.getGlAccountExpr(), fact);
// 2.4 生成分录行
VoucherEntry entry = VoucherEntry.builder()
.dcDirection(rule.getDcFlag())
.glAccount(glAccount)
.amount(entryAmount)
.narrative(expressionEngine.evaluateAccount(rule.getDescTemplate(), fact)) // 复用解析String的方法解析摘要
.build();
voucher.getEntries().add(entry);
}
// 3. 借贷平衡自检 (Self-Validation)
if (!voucher.isBalanced()) {
// 处理舍入误差或抛出配置错误
handleUnbalancedVoucher(voucher);
}
return voucher;
}
}
让我们来看一个复杂的业务场景:贷款还款 (Loan Repayment)。 假设用户还了 1100 元,其中 1000 元是本金,100 元是利息。
输入数据 (BusinessFact):
{
"eventCode": "LOAN_REPAY",
"productCode": "MORTGAGE_001",
"branchId": "0755",
"ext": {
"principalAmt": 1000.00,
"interestAmt": 100.00,
"penaltyAmt": 0.00
}
}
数据库配置 (ARE_ENTRY_RULE):
| 序号 | 借/贷 | 描述 | 科目表达式 (GL_ACCOUNT_EXPR) | 金额表达式 (AMOUNT_EXPR) | 条件 (CONDITION_EXPR) |
|---|---|---|---|---|---|
| 1 | 借 | 扣客户款 | '2001.01.' + branchId (活期科目) |
principalAmt + interestAmt |
true |
| 2 | 贷 | 销贷款本金 | '1301.01.' + branchId (贷款科目) |
principalAmt |
principalAmt > 0 |
| 3 | 贷 | 记利息收入 | '5001.01.0000' (利息收入科目) |
interestAmt |
interestAmt > 0 |
引擎执行结果 (AccountingVoucher):
结果:借 1100 = 贷 1000 + 100。平衡。
一套优秀的规则引擎还需要考虑以下问题:
直接在表达式里写科目号(如 '2001.01.')还是太硬编码了。
MappingUtil.getGL('DEPOSIT_GL', productCode)。reverse(originalVoucher) 方法,直接将原凭证的借贷方向反转(D变C,C变D),或者保持方向不变但金额记为负数(红字)。findRules 时传入 StandardType。
findRules("LOAN_FEE", "P001", "LOCAL") -> 产生 1 条确认收入分录。findRules("LOAN_FEE", "P001", "IFRS") -> 产生 1 条递延收入分录。这套设计将Java 代码从繁琐的 if (isLoan) { ... } else if (isDeposit) { ... } 中解放了出来。
这就是现代银行核心系统中 “会计中台” 的雏形。
tags: