huage / flexible-billing-engine
灵活计费引擎 - 支持多层级条件判断、公式计算、规则匹配
Requires
- php: >=7.4
- symfony/expression-language: ^5.0|^6.0
Requires (Dev)
- phpunit/phpunit: ^9.0
README
composer require huage/flexible-billing-engine:dev-main
计费引擎完整功能说明文档
一、功能概述
本计费引擎是一个灵活的、可扩展的 PHP 计费系统,支持:
- 单条计费:计算单个订单/地址的费用
- 批量计费:批量计算多个地址的费用,支持组合超重、拦截、超SKU等复杂场景
- 多种计费场景:配比费用、组合价格、超重、超箱、超SKU等
- 灵活的条件判断:支持多种运算符和公式计算
二、核心架构
1. 单条计费流程
公共参数 + 地址参数 → RuleEngine → BillingRule → 条件匹配 → BillingResult
2. 批量计费流程
公共参数 → BatchBillingEngine → 一批地址参数 → 批量计算 → 多个 BatchBillingResult
三、支持的条件类型
1. 公共条件(CommonCondition)
用于过滤规则,只有满足所有公共条件的规则才会被评估。
支持的运算符:
==,!=,>,<,>=,<=:比较运算符in,not_in:集合运算符between:范围运算符formula:公式运算符
2. 配比费用条件(RatioCondition)
当条件满足时,使用公式计算费用。
特点:
- 支持变量替换
- 支持数学表达式
- 支持自定义公式
3. 拦截条件(InterceptCondition)
优先级最高,满足后直接返回,不再检查其他条件。
批量模式下的处理:
- 返回基础提拆费用
- 同时返回空费用,备注"拦截后续费用"
4. 组合价格条件(CombinationCondition)
支持多变量、精度处理、最小值限制的组合价格计算。
匹配规则:
- 组合价格的匹配条件是:
address_id或地址类型在address_group中 address_group是一个数组,包含多个地址ID或地址类型- 只要
address_id或地址类型其中一个在address_group中,就匹配成功
新增功能:
- 类型支持:
提拆或提拆派 - 提拆类型:使用配置的单价进行计算
- 提拆派类型:
- 需要匹配地址价格(从公共参数的
地址价格映射中获取) - 计算公式:
地址价格 * 变量(托盘/体积) - 如果地址未匹配:
- 检查地址类型
- 如果是"散板",抛出异常
- 如果不是"散板",使用默认单价
- 需要匹配地址价格(从公共参数的
- 私人地址处理:
- 如果地址类型是"私人地址"
- 返回组合价格 + 空费用(备注:详见后续账单)
匹配示例:
// 规则配置 $combination = new CombinationCondition( [ ['field' => 'address_group', 'operator' => 'in', 'value' => [1, 2, 3, '标准地址', '特殊地址']] ], 200, '提拆', ... ); // 地址参数1:address_id = 1,匹配成功(1 在 address_group 中) $address1 = ['address_id' => 1, '托盘' => 5]; // 地址参数2:地址类型 = '标准地址',匹配成功('标准地址' 在 address_group 中) $address2 = ['地址类型' => '标准地址', '托盘' => 5]; // 地址参数3:address_id = 5,地址类型 = '其他类型',不匹配(都不在 address_group 中) $address3 = ['address_id' => 5, '地址类型' => '其他类型', '托盘' => 5];
变量配置:
name:变量名precision:精度(如 0.5、1),自动向上取整min:最小值formula:变量公式
内置公式:
BUILTIN_VAR1_DIV_VAR2_MUL_PRICE:变量1 / 变量2 * 单价
5. 超重条件(OverweightCondition)
根据重量和板数计算超重费用。
判断公式:(重量 * 2.2046 - 阈值 * 板数) > 0
计算逻辑:
- 超重值 =
(重量 * 2.2046 - 阈值 * 板数) - 倍数 =
ceil(超重值 / 500) * 0.5,最小为 1 - 费用 =
倍数 * 单价
批量模式下的两种处理方式:
模式1:单条超重模式(默认)
- 每条费用如果判定超重,除了返回组合单价,还要返回超重费用
- 费用备注会写清楚超重计算方式
- 示例:
费用项1:组合价格 1000 CNY 费用项2:超重费用 200 CNY 备注:超重计算方式:(重量500 * 2.2046 - 阈值1000 * 板数1) = 102.3,倍数 = ceil(102.3 / 500) * 0.5 = 1
模式2:组合超重模式
- 当获得了一批超重费用时,如果公共参数传的是组合超重模式
- 不再返回单个费用的超重费用,而是返回一个组合超重费用
- 组合的超重费用计算:把组合的全部重量加起来,然后计算超重费用
- 费用备注会写清楚超重计算方式
- 组合超重费用会添加到第一个地址的结果中
- 示例:
地址1费用项:组合价格 1000 CNY 地址2费用项:组合价格 1200 CNY 地址1费用项(追加):组合超重费用 300 CNY 备注:组合超重计算方式:(总重量1000 * 2.2046 - 阈值1000 * 总板数2) = 1204.6,倍数 = ceil(1204.6 / 500) * 0.5 = 1.5
6. 超箱条件(OverContainerCondition)
根据箱数计算超箱费用。
判断公式:箱数 - 阈值 > 0
计算逻辑:
- 超箱值 =
箱数 - 阈值 - 计数 =
ceil(超箱值 / 500) - 费用 =
计数 * 单价
7. 超SKU条件(OverSkuCondition)
根据SKU数计算超SKU费用。
判断公式:sku数 - 阈值 > 0
计算逻辑:
- 超SKU值 =
sku数 - 阈值 - 费用 =
超SKU值 * 单价
批量模式下的处理:
- 如果传入的公共参数里有"整柜提拆SKU数"满足条件:
- 返回固定费用(
整柜提拆SKU费用) - 然后所有地址都走散板匹配逻辑
- 但只有地址类型是"散板"的才走散板费用
- 返回固定费用(
- 如果设置了"超SKU阈值"和"超SKU单价":
- 如果总SKU数超过阈值,返回费用:
(总SKU数 - 超SKU阈值) * 超SKU单价
- 如果总SKU数超过阈值,返回费用:
四、批量计费功能
1. BatchBillingEngine(批量计费引擎)
功能:
- 支持传入公共参数和一批地址参数
- 返回多个费用结果(每个地址一个结果)
- 支持组合超重模式
- 支持拦截处理
- 支持超SKU处理
2. 使用流程
// 1. 创建批量计费引擎 $batchEngine = new BatchBillingEngine(); // 2. 设置公共参数 $commonContext = new BillingContext([ 'user_id' => 1, 'container_type' => '40HQ', '组合超重模式' => false, // true=组合模式,false=单条模式 '基础提拆费用' => 500, '超重阈值' => 1000, '超重单位' => 500, '超重单价' => 200, '整柜提拆SKU数' => 10, // 可选:超过10个SKU触发整柜提拆逻辑 '整柜提拆SKU费用' => 500, // 可选:整柜提拆SKU时返回的固定费用 '超SKU规则' => [ // 可选:超SKU规则数组 ['threshold' => 8, 'price' => 50], // 超过8个SKU,单价50 ['threshold' => 15, 'price' => 100], // 超过15个SKU,单价100 ], '地址价格' => [ // 提拆派类型需要 1 => 200, 2 => 250, ], 'currency' => 'CNY' ]); $batchEngine->setCommonContext($commonContext); // 3. 设置规则(与单条计费相同) $rule = new BillingRule('rule_001', '综合规则', 10); // ... 添加各种条件 $batchEngine->getRuleEngine()->addRule($rule); // 4. 批量计算 $addressList = [ ['address_id' => 1, 'address_group' => '组1', '重量' => 500, '板数' => 1, '托盘' => 5], ['address_id' => 2, 'address_group' => '组2', '重量' => 600, '板数' => 1, '托盘' => 6], ]; $results = $batchEngine->calculateBatch($addressList); // 5. 处理结果 foreach ($results as $index => $result) { foreach ($result->getItems() as $item) { echo "{$item->getFeeType()}: {$item->getAmount()} {$item->getCurrency()}\n"; if ($item->getRemark()) { echo " 备注: {$item->getRemark()}\n"; } } }
3. 费用项结构(FeeItem)
每个费用项包含:
amount:费用金额currency:币种remark:备注(用于说明计算方式)feeType:费用类型
4. 批量结果结构(BatchBillingResult)
每个批量结果包含:
items:费用项列表(可能多个)totalAmount:总金额currency:币种matchedRule:匹配的规则
五、特殊场景处理
1. 拦截处理
触发条件:在获取组合费用时判定是拦截
处理逻辑:
- 按照基础提拆费用来计算费用
- 同时返回一条空的费用,备注是"拦截后续费用"
示例:
费用项1:拦截基础提拆费用 500 CNY,备注:拦截:按照基础提拆费用计算
费用项2:空费用 0 CNY,备注:拦截后续费用
2. 整柜提拆SKU数处理
触发条件:如果传入的公共参数里有"整柜提拆SKU数"满足条件
处理逻辑:
- 计算所有地址的总SKU数
- 如果总SKU数超过"整柜提拆SKU数":
- 返回固定费用(
整柜提拆SKU费用) - 然后所有地址都走散板匹配逻辑
- 但只有地址类型是"散板"的才走散板费用
- 返回固定费用(
公共参数配置:
$commonContext->set('整柜提拆SKU数', 10); // 超过10个SKU触发 $commonContext->set('整柜提拆SKU费用', 500); // 返回的固定费用
示例:
地址1:整柜提拆SKU费用 500 CNY(地址类型不是散板,不走散板费用)
地址2:整柜提拆SKU费用 500 CNY + 散板费用 300 CNY(地址类型是散板)
地址3:整柜提拆SKU费用 500 CNY(地址类型不是散板,不走散板费用)
3. 超SKU规则处理
触发条件:如果设置了"超SKU规则"(数组格式)
处理逻辑:
- 计算所有地址的总SKU数
- 遍历所有超SKU规则,如果总SKU数超过某个规则的阈值:
- 计算该规则的费用:
(总SKU数 - 阈值) * 单价 - 累加所有匹配规则的费用
- 费用添加到第一个地址的结果中
- 计算该规则的费用:
公共参数配置:
$commonContext->set('超SKU规则', [ ['threshold' => 8, 'price' => 50], // 超过8个SKU,单价50 ['threshold' => 15, 'price' => 100], // 超过15个SKU,单价100 // 或者使用中文键名 ['超SKU阈值' => 20, '超SKU单价' => 150], // 超过20个SKU,单价150 ]);
示例:
总SKU数 = 25
规则1:threshold = 8, price = 50,匹配,费用 = (25 - 8) * 50 = 850
规则2:threshold = 15, price = 100,匹配,费用 = (25 - 15) * 100 = 1000
规则3:threshold = 20, price = 150,匹配,费用 = (25 - 20) * 150 = 750
总超SKU费用 = 850 + 1000 + 750 = 2600 CNY
添加到第一个地址的结果中
3. 提拆派类型处理
特点:
- 需要匹配地址价格
- 公共参数需要传入地址价格映射
- 匹配成功后返回:
地址价格 * 变量(托盘/体积) - 如果没有匹配上:
- 检查地址类型
- 如果是"散板",抛出异常
- 如果不是"散板",使用默认单价
示例:
// 公共参数 $commonContext->set('地址价格', [ 1 => 200, // 地址1价格200 2 => 250, // 地址2价格250 ]); // 地址参数 $address = [ 'address_id' => 1, '托盘' => 5 ]; // 结果:200 * 5 = 1000 CNY
4. 私人地址处理
触发条件:组合价格计算时,地址类型是"私人地址"
处理逻辑:
- 返回组合价格
- 同时返回一个空价格,备注是"详见后续账单"
示例:
费用项1:组合价格 1000 CNY
费用项2:空费用 0 CNY,备注:详见后续账单
六、公共参数说明
重要说明:
- 必填参数:只有
currency(币种)是必填的 - 可选参数:其他所有参数都是可选的,根据不同的计费场景来设置
- 场景参数:每个场景需要设置相应的参数,不需要的场景可以不设置
- 最小配置示例:
$commonContext = new BillingContext([ 'currency' => 'CNY', // 只有币种是必填的 ]);
| 参数名 | 类型 | 说明 | 必填 | 默认值 | 使用场景 |
|---|---|---|---|---|---|
currency |
string | 币种 | 是 | CNY | 所有场景 |
组合超重模式 |
bool | true=组合模式,false=单条模式 | 否 | false | 组合超重场景 |
基础提拆费用 |
float | 拦截时使用的基础费用 | 否 | 0 | 拦截场景 |
超重阈值 |
float | 超重判断阈值 | 否 | 1000 | 超重场景 |
超重单位 |
float | 每多少单位增加0.5 | 否 | 500 | 超重场景 |
超重单价 |
float | 超重单价 | 否 | 0 | 超重场景 |
整柜提拆SKU数 |
int | 超过几个SKU触发整柜提拆逻辑 | 否 | - | 整柜提拆SKU场景 |
整柜提拆SKU费用 |
float | 整柜提拆SKU时返回的固定费用 | 否 | - | 整柜提拆SKU场景 |
超SKU规则 |
array | 超SKU规则数组,格式:[['threshold' => 8, 'price' => 50], ...] |
否 | [] | 超SKU场景 |
地址价格 |
array | 地址价格映射(提拆派类型需要) | 否 | [] | 提拆派场景 |
七、地址参数说明
| 参数名 | 类型 | 说明 | 必填 |
|---|---|---|---|
address_id |
int/string | 地址ID(提拆派类型需要) | 是(提拆派) |
address_name |
string | 地址名称(提拆派类型备用) | 否 |
address_group |
string | 地址组(用于条件匹配) | 是(条件匹配) |
地址类型 |
string | 地址类型(如:标准地址、私人地址、散板) | 否 |
重量 |
float | 重量(超重场景需要) | 是(超重) |
板数 |
float | 板数(超重场景需要) | 是(超重) |
托盘 |
float | 托盘数(组合价格需要) | 是(组合价格) |
sku数 |
int | SKU数(超SKU场景需要) | 是(超SKU) |
八、费用类型说明
| 费用类型 | 说明 |
|---|---|
combination |
组合价格 |
overweight |
超重费用(单条模式) |
combined_overweight |
组合超重费用(组合模式) |
intercept_base_fee |
拦截基础提拆费用 |
intercept_follow |
拦截后续费用(空费用) |
base_fee |
基础提拆费用(超SKU时使用) |
private_address |
私人地址后续账单(空费用) |
over_container |
超箱费用 |
over_sku |
超SKU费用 |
ratio |
配比费用 |
九、完整使用示例
示例1:单条计费
use FlexibleBilling\Rule\RuleEngine; use FlexibleBilling\Rule\BillingRule; use FlexibleBilling\Condition\CommonCondition; use FlexibleBilling\Condition\CombinationCondition; use FlexibleBilling\Context\BillingContext; $engine = new RuleEngine(); $rule = new BillingRule('rule_001', '组合价格规则', 10); $rule->addCommonCondition(new CommonCondition('user_id', '==', 1)); $combination = new CombinationCondition( [['field' => 'address_group', 'operator' => '==', 'value' => '组1']], 200, '提拆', [['name' => '托盘', 'precision' => 1, 'min' => 1]], '{单价} * {托盘}', '' ); $rule->addCombinationCondition($combination); $engine->addRule($rule); $context = new BillingContext([ 'user_id' => 1, 'address_group' => '组1', '托盘' => 5 ]); $result = $engine->calculate($context); echo "费用: {$result->getAmount()} {$result->getCurrency()}\n";
示例2:批量计费(组合超重模式)
use FlexibleBilling\Context\BillingContext; use FlexibleBilling\Rule\BatchBillingEngine; use FlexibleBilling\Rule\BillingRule; use FlexibleBilling\Condition\CombinationCondition; $batchEngine = new BatchBillingEngine(); // 设置公共参数 $commonContext = new BillingContext([ 'user_id' => 1, 'container_type' => '40HQ', '组合超重模式' => true, // 组合超重模式 '基础提拆费用' => 500, '超重阈值' => 1000, '超重单位' => 500, '超重单价' => 200, 'currency' => 'CNY' ]); $batchEngine->setCommonContext($commonContext); // 设置规则 $rule = new BillingRule('rule_001', '组合价格规则', 10); $rule->addCommonCondition(new CommonCondition('user_id', '==', 1)); $combination = new CombinationCondition( [['field' => 'address_group', 'operator' => '==', 'value' => '组1']], 200, '提拆', [['name' => '托盘', 'precision' => 1, 'min' => 1]], '{单价} * {托盘}', '' ); $rule->addCombinationCondition($combination); $batchEngine->getRuleEngine()->addRule($rule); // 批量计算 $addressList = [ [ 'address_group' => '组1', '重量' => 500, '板数' => 1, '托盘' => 5 ], [ 'address_group' => '组1', '重量' => 600, '板数' => 1, '托盘' => 6 ] ]; $results = $batchEngine->calculateBatch($addressList); // 处理结果 foreach ($results as $index => $result) { echo "地址" . ($index + 1) . ":\n"; foreach ($result->getItems() as $item) { echo " {$item->getFeeType()}: {$item->getAmount()} {$item->getCurrency()}\n"; if ($item->getRemark()) { echo " 备注: {$item->getRemark()}\n"; } } echo " 总计: {$result->getTotalAmount()} {$result->getCurrency()}\n\n"; }
示例3:提拆派类型
// 设置公共参数(包含地址价格映射) $commonContext = new BillingContext([ 'user_id' => 1, '地址价格' => [ 1 => 200, // 地址1价格200 2 => 250, // 地址2价格250 ], 'currency' => 'CNY' ]); // 设置规则(提拆派类型) $combination = new CombinationCondition( [['field' => 'address_group', 'operator' => '==', 'value' => '组2']], 200, // 默认单价(如果地址未匹配且不是散板,使用此单价) '提拆派', // 类型 [['name' => '托盘', 'precision' => 1, 'min' => 1]], '', '' ); // 地址参数 $address = [ 'address_id' => 1, 'address_group' => '组2', '地址类型' => '标准地址', '托盘' => 5 ]; // 结果:200 * 5 = 1000 CNY(地址1价格200)
十、功能总结
已实现的功能
✅ 单条计费:支持单个订单/地址的费用计算 ✅ 批量计费:支持批量计算多个地址的费用 ✅ 组合超重:支持两种模式(单条模式、组合模式) ✅ 拦截处理:支持拦截时返回基础提拆费用 + 空费用 ✅ 超SKU处理:支持组合模式下的超SKU处理 ✅ 提拆派类型:支持地址价格匹配,散板异常处理 ✅ 私人地址:支持私人地址时返回空费用备注 ✅ 费用备注:超重费用包含详细的计算方式说明 ✅ 变量验证:自定义公式和内置公式的变量验证 ✅ 多费用项:支持一个地址返回多个费用项
设计特点
- 灵活扩展:支持自定义运算符、自定义公式
- 类型安全:变量验证机制,防止计算错误
- 易于使用:清晰的API,丰富的示例
- 高性能:短路评估,按优先级排序
- 批量处理:支持批量计算,提高效率
十一、匹配流程总结
根据需求,匹配流程应该是:
1. 传入公共参数
2. 设置各种组合的价格信息(规则配置)
3. 设置一批地址参数
4. 最后执行获取一堆价格(批量计算)
实现方式:
// 1. 传入公共参数 $commonContext = new BillingContext([...公共参数...]); // 2. 设置各种组合的价格信息(规则配置) $rule = new BillingRule(...); $rule->addCombinationCondition(...); $batchEngine->getRuleEngine()->addRule($rule); // 3. 设置一批地址参数 $addressList = [ [...地址1参数...], [...地址2参数...], ... ]; // 4. 最后执行获取一堆价格 $results = $batchEngine->calculateBatch($addressList);
十二、注意事项
- 组合超重模式:如果启用,所有地址的超重费用会合并为一个组合超重费用,添加到第一个地址的结果中
- 拦截处理:拦截时使用基础提拆费用,不再计算其他费用
- 超SKU处理:如果总SKU数超过阈值,所有地址都返回基础提拆费用
- 提拆派类型:必须配置地址价格映射,否则无法匹配会抛出异常(如果地址类型是散板)
- 私人地址:会自动添加"详见后续账单"的空费用项
- 费用备注:超重费用会包含详细的计算方式说明
- 变量验证:使用自定义公式时,确保所有变量都已配置或存在于上下文中