diy_offer 结账计算逻辑详解
本文档覆盖
o_diy_offer表中 5 种 type 的完整计算过程,含公式说明与 Mock 数据样例。
订单级插件费用(o_order_diy_offer)不在本文档范围,详见架构说明末尾的附录。
一、总览
| type | 中文名 | 计算方式 | 结果去向 |
|---|---|---|---|
promotion | 限时促销 | 直接修改购物车商品单价 | 改写 items[].price,不进 diy_offers 折扣数组 |
bundlesale | 捆绑组合销售 | 汇总参与商品行总价→计算总折扣→按行逆序分摊 | 负折扣写入 diy_offers[],并入 promotion_price |
skubundlesale | SKU 多变体捆绑 | 统计购物车 SKU 总件数→匹配套餐包→同 bundlesale | 同上 |
gift | 满额/满件赠品 | 判断是否达到触发条件→将赠品行价置 0 | 赠品 price = 0,不进折扣数组 |
minmaxoffer | 订单自定义费用 | 按权重将所有商品价格调整到目标客单价 | 直接改写行价,写 has_minmaxoffer=true;启用后屏蔽其他 diy_offer |
二、各类型详细计算
2.1 promotion(限时促销)
一句话总结:在倒计时有效期内,按配置的折扣方式(指定价/百分比/直减)直接改写每件商品的单价,不产生独立折扣项。
前置条件
活动 status == 1 AND starts_at <= now AND ends_at > now
商品 property 中存在 type == "promotion_timer"
商品 ends_at(倒计时)> now
任一不满足则恢复原价,清空商品 diy_offer_id。
折扣计算公式
// 活动参数 params.data[].type 决定折扣方式
switch (discount_config.type) {
case "definite_price": // 指定售价
discount = max(value, 0) // discount 直接作为新单价(正值)
case "discount": // 百分比折扣
discount = -( price × value / 100 ) // 负数,代表优惠额
case "reduction": // 固定减价
discount = -value // 负数
}
新单价计算
// discount > 0 表示"指定价",直接作为新单价
// discount <= 0 表示"减价额",原价 + 减价额(不低于 0)
discountPrice = discount > 0 ? discount : max(originalPrice + discount, 0)
Mock 数据样例
配置(params.data[0]):
{ "type": "discount", "value": 20 }购物车商品行(执行前):
{
"product_id": 1001,
"sku_code": "SKU-A",
"price": 100.00,
"quantity": 2,
"final_line_price": 200.00,
"original_price": 100.00,
"original_line_price": 200.00
}计算过程:
discount = -(100.00 × 20 / 100) = -20.00
discountPrice = max(100.00 + (-20.00), 0) = 80.00
final_line_price = 80.00 × 2 = 160.00
执行后:
{
"price": 80.00,
"unit_price": 80.00,
"final_price": 80.00,
"final_line_price": 160.00,
"original_price": 100.00,
"original_line_price": 200.00
}definite_price(指定售价)样例:
配置 value = 59.90
discount = max(59.90, 0) = 59.90 → 新单价 = 59.90(正值直接取)
final_line_price = 59.90 × 2 = 119.80
reduction(固定减价)样例:
配置 value = 15
discount = -15
discountPrice = max(100 - 15, 0) = 85.00
final_line_price = 85.00 × 2 = 170.00
2.2 bundlesale(捆绑组合销售)
一句话总结:购物车中各参与商品的数量完全满足套餐要求后,计算整个套餐的总折扣,再按行总价升序逆序分摊到各行。
触发条件(discount_rule)
| discount_rule | 说明 |
|---|---|
all(默认) | 每件商品 quantity 必须 恰好等于 配置 num;否则整体折扣为 0 |
partial | 只要有商品 quantity >= num,累加满足部分的行总价参与计算 |
总折扣计算
// 遍历 params.products,累加 totalPrices
foreach (products as $p) {
$payNum = cart_quantity[$p.product_id]
$price = cart_price[$p.product_id]
if (discount_rule == 'all' && payNum != p.num) return 0 // 数量不匹配直接失效
if (discount_rule == 'partial' && payNum < p.num) continue // 不满足跳过
$totalPrices += payNum × price
}
switch (discount_type) {
"fix": discount = min(0, discount_value - totalPrices) // 负数(总价 > 指定价才有折扣)
"percentage": discount = -(totalPrices × discount_value / 100) // 负数
"constant": discount = -min(discount_value, totalPrices) // 负数
}折扣分摊到各行
// 先按 final_line_price 升序排列(低价商品先被分摊)
sort by final_line_price ASC
foreach ($products as $index => $product) {
$remaining = count($products) - $index // 剩余待分摊行数
$productDiscount = $totalDiscount × (1 / $remaining) // 本行应分摊额
$productDiscount = -min(abs($productDiscount), $product.final_line_price) // 不超过行总价
$productDiscount = round($productDiscount, decimal_num)
$totalDiscount -= $productDiscount // 剩余折扣递减,最后一行承担尾差
}Mock 数据样例
活动配置:
{
"discount_rule": "all",
"discount_type": "percentage",
"discount_value": 15,
"products": [
{ "product_id": 2001, "num": 1 },
{ "product_id": 2002, "num": 2 }
]
}购物车商品行:
[
{ "product_id": 2001, "quantity": 1, "price": 80.00, "final_line_price": 80.00 },
{ "product_id": 2002, "quantity": 2, "price": 60.00, "final_line_price": 120.00 }
]计算过程:
检查 discount_rule = 'all':
product_id=2001 quantity=1 == num=1 ✓
product_id=2002 quantity=2 == num=2 ✓
totalPrices = 1×80 + 2×60 = 200.00
discount_type = "percentage", discount_value = 15
totalDiscount = -(200.00 × 15 / 100) = -30.00
按 final_line_price 升序排列:
[0] product_id=2001 final_line_price=80.00
[1] product_id=2002 final_line_price=120.00
分摊:
index=0, remaining=2
productDiscount = -30.00 × (1/2) = -15.00
productDiscount = -min(15.00, 80.00) = -15.00
totalDiscount = -30.00 - (-15.00) = -15.00
index=1, remaining=1
productDiscount = -15.00 × (1/1) = -15.00
productDiscount = -min(15.00, 120.00) = -15.00
结果:
cart['diy_offers'][] = {
"id": 活动id,
"type": "bundlesale",
"discount": -30.00,
"products": [
{ "variantUniqKey": "...", "discount": -15.00 }, // product_id=2001
{ "variantUniqKey": "...", "discount": -15.00 } // product_id=2002
]
}
promotion_price += -30.00
fix(指定套餐总价)样例:
totalPrices = 200.00,discount_value = 160
totalDiscount = min(0, 160 - 200) = -40.00
constant(直接减价)样例:
totalPrices = 200.00,discount_value = 25
totalDiscount = -min(25, 200) = -25.00
2.3 skubundlesale(SKU 多变体捆绑)
一句话总结:统计参与活动的所有 SKU 总件数,精确匹配 packages[].num,命中对应套餐包后计算折扣,分摊逻辑与 bundlesale 完全一致。
核心差异(与 bundlesale 对比)
| 维度 | bundlesale | skubundlesale |
|---|---|---|
| 触发判断 | 每个 product_id 的 quantity 逐一核对 | 所有商品 quantity 求和 == package.num |
| 折扣配置位置 | params.discount_type / params.discount_value | params.packages[].discount_type / discount_value |
| 有效期 | 长期(ends_at = FOREVER) | 有时间范围(starts_at / ends_at) |
总折扣计算
$skuCount = sum(products[].quantity) // 购物车 SKU 总件数
$totalPrices = sum(products[].final_line_price)
// 在 packages 数组中找 num == skuCount 的套餐
$usePackage = packages.find(p => p.num == skuCount)
if (!usePackage) return 0 // 无匹配套餐,不打折
switch (usePackage.discount_type) {
"fix": discount = min(0, usePackage.discount_value - totalPrices)
"percentage": discount = -(totalPrices × usePackage.discount_value / 100)
"constant": discount = -min(usePackage.discount_value, totalPrices)
}Mock 数据样例
活动配置(packages):
{
"products": [
{ "product_id": 3001 },
{ "product_id": 3002 }
],
"packages": [
{ "num": 2, "discount_type": "percentage", "discount_value": 10 },
{ "num": 3, "discount_type": "constant", "discount_value": 20 },
{ "num": 4, "discount_type": "fix", "discount_value": 100 }
]
}场景 A:购买 3 件(product_id=3001 × 1 + product_id=3002 × 2)
skuCount = 1 + 2 = 3
totalPrices = 1×50 + 2×40 = 130.00
匹配 packages[1](num=3,constant,value=20)
discount = -min(20, 130) = -20.00
场景 B:购买 4 件(各 2 件,每件 50/40)
skuCount = 2 + 2 = 4
totalPrices = 2×50 + 2×40 = 180.00
匹配 packages[2](num=4,fix,value=100)
discount = min(0, 100 - 180) = -80.00
场景 C:购买 5 件(不存在 num=5 的套餐)
skuCount = 5
usePackage = null → discount = 0,不打折
2.4 gift(满额/满件赠品)
一句话总结:根据购物车金额或件数判断满足哪一级赠品条件,然后将对应赠品行的价格置为 0。
触发门槛判断
// params.discount_type 决定按金额还是按件数
$diyOfferCartValue = (discount_type == 2)
? cart_statistics.total_quantity // 按件数
: cart_statistics.total_price // 按金额(默认)
// 倒序遍历 rules,找第一个满足 condition 的层级
$rules = array_reverse(params.rules)
foreach ($rules as $rule) {
if ($diyOfferCartValue >= $rule.condition) {
$gifts.productIds = rule.products[].id
if (params.no_limit == 0) {
$gifts.quantity = rule.product_num // 固定赠品数量
} else {
// 上不封顶:每满 condition 送 product_num 件
$gifts.quantity = floor(diyOfferCartValue / rule.condition) * rule.product_num
}
break
}
}改价逻辑
// 购物车阶段:所有赠品行(含 unavailable)均置 0
// 结账阶段:只有 unavailable == 0 的赠品行置 0
if (is_cart_stage || item.unavailable == 0) {
cart.total_price -= item.final_line_price
item.price = 0
item.final_line_price = 0
}Mock 数据样例
活动配置:
{
"discount_type": 1,
"no_limit": 0,
"rules": [
{ "condition": 50, "product_num": 1, "products": [{ "id": 4001 }] },
{ "condition": 100, "product_num": 2, "products": [{ "id": 4001 }, { "id": 4002 }] },
{ "condition": 200, "product_num": 3, "products": [{ "id": 4001 }, { "id": 4002 }, { "id": 4003 }] }
]
}场景 A:购物车商品金额 = 120(非赠品),已加入赠品 4001×2
diyOfferCartValue = 120
倒序遍历 rules:
condition=200 → 120 < 200,跳过
condition=100 → 120 >= 100,命中!
productIds = [4001, 4002]
quantity = 2(固定,no_limit=0)
赠品行对应关系:
product_id=4001,quantity=2
giftProducts.quantity=2 >= product.quantity=2
→ 全部为可购买赠品 (unavailable=0)
→ price=0, final_line_price=0
cart.total_price -= 赠品原始行价
场景 B:购物车金额 = 120,但用户加了 4001×1
giftProducts.quantity=2
product_id=4001 quantity=1 < giftProducts.quantity=2
→ 1件可购买赠品(price=0)
→ 剩余 quantity=1 继续,下一件赠品处理
no_limit=1(上不封顶)样例:
{ "condition": 50, "product_num": 1, "no_limit": 1 }diyOfferCartValue = 180
gifts.quantity = floor(180 / 50) × 1 = 3 件赠品
2.5 minmaxoffer(订单自定义费用 / 客单价锁定)
一句话总结:商家设定目标客单价区间,若实际购物车总价超出区间,则按各商品价值权重将所有商品单价等比例调整到目标金额,其他折扣活动在此模式下全部失效。
三种规则模式
| rule_type | 说明 | 触发条件 |
|---|---|---|
| 1 | 锁定最小客单价 | 当前总价 < rule_min.amount 时提价 |
| 2 | 锁定最大客单价 | 当前总价 > rule_max.amount 时降价 |
| 3 | 同时锁定最小&最大 | < min 时提价,> max 时降价,在区间内恢复原价 |
核心权重分摊算法
// 1. 计算虚拟权重总和(0 元商品用 0.01 占位)
$virtualTotal = 0
foreach ($items as $item) {
$price = $item.price > 0 ? $item.price : 0.01
$virtualTotal += $price × $item.quantity
}
// 2. 按权重分摊,最后一行承担尾差
$runningTotal = 0
foreach ($items as $index => $item) {
if (!is_last) {
$ratio = (item.price > 0 ? item.price : 0.01) × item.quantity / virtualTotal
$lineTarget = round(targetAmount × ratio, 2)
} else {
$lineTarget = round(targetAmount - runningTotal, 2) // 最后一行兜底
}
$unitPrice = round(lineTarget / item.quantity, 2)
item.price = item.unit_price = item.final_price = unitPrice
item.final_line_price = round(unitPrice × item.quantity, 2)
$runningTotal += item.final_line_price
}
// 3. 因四舍五入产生的尾差暂存
$cart.minmaxoffer_diff_price = targetAmount - runningTotal
$cart.has_minmaxoffer = true注意:
minmaxoffer_diff_price在支付方式保存时才计入总价,通过PaymentService::lockMaxOrderPrice做最终修正。
Mock 数据样例
活动配置(rule_type = 2,最大客单价):
{
"rule_type": 2,
"rule_max": { "amount": 100.00, "title": "Special Price" }
}购物车商品(执行前):
[
{ "product_id": 5001, "price": 60.00, "quantity": 1, "final_line_price": 60.00 },
{ "product_id": 5002, "price": 40.00, "quantity": 2, "final_line_price": 80.00 }
]当前总价 = 60 + 80 = 140.00 > rule_max.amount 100.00,触发降价
targetAmount = 100.00
virtualTotal = 60×1 + 40×2 = 140.00
item[0] (5001, qty=1, price=60):
ratio = (60 × 1) / 140 = 0.4286
lineTarget = round(100 × 0.4286, 2) = 42.86
unitPrice = round(42.86 / 1, 2) = 42.86
final_line_price = 42.86
item[1] (5002, qty=2, price=40) [最后一行]:
lineTarget = round(100.00 - 42.86, 2) = 57.14
unitPrice = round(57.14 / 2, 2) = 28.57
final_line_price = round(28.57 × 2, 2) = 57.14
runningTotal = 42.86 + 57.14 = 100.00
minmaxoffer_diff_price = 100.00 - 100.00 = 0.00
执行后:
[
{ "product_id": 5001, "price": 42.86, "quantity": 1, "final_line_price": 42.86 },
{ "product_id": 5002, "price": 28.57, "quantity": 2, "final_line_price": 57.14 }
]
cart.total_price = 100.00
cart.has_minmaxoffer = true含 0 元商品的特殊场景(虚拟权重 0.01):
商品 A:price=100, qty=1 权重 = 100
商品 B:price=0, qty=1 权重 = 0.01(虚拟)
targetAmount = 80
virtualTotal = 100 + 0.01 = 100.01
item[A] (非最后):
ratio = 100 / 100.01 ≈ 0.9999
lineTarget = round(80 × 0.9999, 2) = 79.99
item[B] (最后):
lineTarget = 80.00 - 79.99 = 0.01
unitPrice = 0.01
三、计算调用链与互斥规则
CartService::getList
│
├─ DiyOfferService::defaultDiyOffer(['minmaxoffer'])
│ └─ 若命中 → cart.has_minmaxoffer = true
│
├─ if (!has_minmaxoffer)
│ ├─ cartDiyOffers(['promotion', 'bundlesale', 'skubundlesale'])
│ └─ cartDiyOffers(['gift'])
│
└─ PromotionHandlerService::getCartPromotion
└─ 排除 mutexPromotionVariantUniqKey(bundlesale/skubundlesale 命中的 SKU)
promotion_price = sum(满减折扣) + sum(diy_offers[].discount)
互斥规则:
minmaxoffer启用时,promotion、bundlesale、skubundlesale、gift全部跳过。bundlesale/skubundlesale命中后,其覆盖的 SKU 写入mutexPromotionVariantUniqKey,标准满减 不再统计这些 SKU。
四、折扣最终去向汇总
| 类型 | 折扣存储位置 | 订单落库字段 |
|---|---|---|
promotion | items[].price 直接改写 | order_product.price(行价降低) |
bundlesale | cart.diy_offers[].discount | 并入 promotion_price → current_promotion_price |
skubundlesale | cart.diy_offers[].discount | 同上 |
gift | items[].price = 0 | order_product.price = 0 |
minmaxoffer | items[].price 直接改写 | order_product.price + minmaxoffer_diff_price |
订单级附加费(随机折扣、积分抵扣、配送保护等)单独存储在 o_order_diy_offer,汇总为 current_offer_price,不在本文档范围内。
五、params 字段结构速查
promotion
{
"type": "products | collection | all | all_ai",
"timer": 30,
"show_page": ["product", "cart"],
"data": [
{ "id": 1001, "type": "discount | definite_price | reduction", "value": 20 }
]
}bundlesale
{
"discount_rule": "all | partial",
"discount_type": "fix | percentage | constant",
"discount_value": 15,
"display_rule": "master | all",
"products": [
{ "product_id": 2001, "num": 1, "master": 1 },
{ "product_id": 2002, "num": 2, "master": 0 }
]
}skubundlesale
{
"products": [{ "product_id": 3001 }, { "product_id": 3002 }],
"packages": [
{ "num": 2, "discount_type": "percentage", "discount_value": 10 },
{ "num": 3, "discount_type": "constant", "discount_value": 20 }
]
}gift
{
"discount_type": 1,
"no_limit": 0,
"rules": [
{
"condition": 100,
"product_num": 2,
"products": [{ "id": 4001 }, { "id": 4002 }]
}
]
}
discount_type = 1:按金额;discount_type = 2:按件数
no_limit = 0:固定赠品数;no_limit = 1:上不封顶(每满 condition 送 product_num 件)
minmaxoffer
{
"rule_type": 1,
"rule_min": { "amount": 50.00, "title": "最低消费" },
"rule_max": { "amount": 200.00, "title": "封顶价格" },
"hide_fee": 0,
"is_discount": 0
}