diy_offer 结账计算逻辑详解

本文档覆盖 o_diy_offer 表中 5 种 type 的完整计算过程,含公式说明与 Mock 数据样例。
订单级插件费用(o_order_diy_offer)不在本文档范围,详见架构说明末尾的附录。


一、总览

type中文名计算方式结果去向
promotion限时促销直接修改购物车商品单价改写 items[].price,不进 diy_offers 折扣数组
bundlesale捆绑组合销售汇总参与商品行总价→计算总折扣→按行逆序分摊负折扣写入 diy_offers[],并入 promotion_price
skubundlesaleSKU 多变体捆绑统计购物车 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 对比)

维度bundlesaleskubundlesale
触发判断每个 product_id 的 quantity 逐一核对所有商品 quantity 求和 == package.num
折扣配置位置params.discount_type / params.discount_valueparams.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 启用时,promotionbundlesaleskubundlesalegift 全部跳过
  • bundlesale / skubundlesale 命中后,其覆盖的 SKU 写入 mutexPromotionVariantUniqKey,标准满减 不再统计这些 SKU。

四、折扣最终去向汇总

类型折扣存储位置订单落库字段
promotionitems[].price 直接改写order_product.price(行价降低)
bundlesalecart.diy_offers[].discount并入 promotion_pricecurrent_promotion_price
skubundlesalecart.diy_offers[].discount同上
giftitems[].price = 0order_product.price = 0
minmaxofferitems[].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
}