current_promotion_price 全链路(超详细版)

1. 业务定义(一句话)

⚡⚡⚡ 购物车商品匹配商家后台活动规则,计算出总优惠金额,按商品价格占比均摊到每个商品行。

2. 后台配置到前台计算的关系

2.1 商家后台可控项(配置映射表)

后台配置项典型存储/来源前台读取点对计算的直接影响
活动类型(满减/满件打折/第X件Y折/满件一口价/满X免Y)promotion.typegetCartPromotion决定调用哪个计算函数
活动时间(起止时间)promotion.starts_at/ends_atgetCartPromotion时间外活动不参与计算
商品范围(全场/指定商品/指定专辑)promotion.product_range + promotion_rangecalGeneralDiscount决定哪些商品能匹配此活动
活动规则(满XX元减YY/满XX件YY折等)promotion.rule_param['rule']discountPrice满额满件门槛与折扣值
封顶配置(单次/上不封顶)promotion.rule_param['allocation_limit']discountPrice上不封顶时可叠加多次
互斥商品(一个商品只能属一个活动)PromotionHandlerService::unsetProductsByMutexVariantUniqKeygetCartPromotion同一商品被先命中活动排除后命中活动
满件一口价排序方式(价格从低到高/高到低)rule_param['promotion_xy_product_price_sort']calSinceDiscount第X件Y折中商品顺序决定哪些商品先享受折扣
会员价优先(minmaxoffer)cart['has_maxoffer_discount'] + cart['has_maxoffer']handlerOrderSurchrge若会员折扣比活动优惠更大,以会员价为准

2.2 全链路时序图(配置 → 计算 → 落库)

flowchart TD
A[商家配置活动规则] --> B[PromotionService.getAllCartPromotionByCache 获取所有活动]
C[购物车 items] --> D[unsetProductsByMutexVariantUniqKey 过滤互斥商品]
D --> B
B --> E{"遍历每个活动:时间是否在有效期?"}
E -->|否| Z[跳过]
E -->|是| F[getCartPromotionRange 获取活动商品范围]
F --> G{活动类型判断}
G -->|第X件Y折| H[calSinceDiscount]
G -->|满件一口价| I[calFixedPriceDiscount]
G -->|满X免Y| J[calBuyXFeeY]
G -->|其他活动| K[calGeneralDiscount]
H --> L[handlerOrderSurchrge 处理会员价优先]
I --> L
J --> L
K --> L
L --> M["$return['promotion_price'] = Σdiscount + Σdiy_offers.discount"]
M --> N[写入 cart.promotion_price]

3. 核心入口与数据结构

3.1 核心方法链路

CartService.php:550
  └─ PromotionHandlerService::getCartPromotion($cart)
        │
        ├─ step1: getAllCartPromotionByCache()          获取所有在有效期活动
        ├─ step2: 构建 not_ranges(互斥过滤)            同一商品只能用于一个活动
        ├─ step3: 按类型分发计算
        │       ├─ calSinceDiscount()        type=5 第X件Y折
        │       ├─ calFixedPriceDiscount()   type=6 满件一口价
        │       ├─ calBuyXFeeY()             type=7 满X免Y
        │       └─ calGeneralDiscount()      type=1/2/3/4 其他全场或部分活动
        └─ handlerOrderSurchrge()            会员价优先兜底逻辑

3.2 活动类型 → 计算方法映射

type 常量值含义计算方法计费基数
1满额减元(满XX元减YY元)calGeneralDiscountdiscountPrice商品总价(unit_price × quantity)
2上不封顶(满XX件YY折,可叠加)calGeneralDiscountdiscountPrice商品总价
3满件减元(满XX件减YY元)calGeneralDiscountdiscountPrice商品总件数
4满额打折(满XX元YY折)calGeneralDiscountdiscountPrice商品总价
5第X件Y折calSinceDiscount按商品件数逐层匹配阶梯
6满件一口价calFixedPriceDiscount找满足件数的最低价商品组合
7满X免YcalBuyXFeeY按商品件数计算可免数量

4. 完整计算逻辑

4.1 step1:获取活动列表

$allPromotion = (new PromotionService())->getAllCartPromotionByCache();
foreach ($allPromotion as $promotion) {
    if ($startTime <= time() && $endTime >= time()) {
        $promotion['ranges'] = (new PromotionService())
            ->getCartPromotionRangeByCache($promotion['id']);
        $promotionList[] = $promotion;
    }
}

关键行为:

  • 只取在有效期的活动(starts_at ≤ now ≤ ends_at
  • 无限期活动(ends_at = -1)视为永久有效
  • 同时读取每个活动的商品范围(ranges,含 product_idcollection_id

4.2 step2:互斥过滤(同一商品只属一个活动)

// 按 product_range_sort 升序:指定商品 > 指定专辑 > 全场
array_multisort(array_column($promotionList, 'product_range_sort'), SORT_ASC);
// 遍历构建 not_ranges
foreach ($promotionList as $key => $item) {
    $item['not_ranges']['product']    = array_filter($productIds);    //已被占用的商品ID
    $item['not_ranges']['collection'] = array_filter($collectionIds);   //已被占用的专辑ID
    // 若商品A已在 $productIds 中,则在当前活动的 not_ranges 中标记
}

优先级指定商品活动 > 指定专辑活动 > 全场活动

即:商品A 先被「指定商品A活动」占用后,不会再参与「指定专辑X活动」或「全场活动」。

4.3 step3:按类型分发计算

4.3.1 calGeneralDiscount(type 1/2/3/4)

适用:满额减元、满件打折、满额打折、上不封顶

第一步:过滤商品范围

根据 product_range 分三种情况:

product_range含义过滤逻辑
0 (STATUS_ALL)全场排除已被其他活动占用的商品/专辑
1 (PRODUCT_RANGE_NOT_ALL)指定商品只保留在 ranges 中且不在 not_ranges 中的商品
2 (PRODUCT_RANGE_COLLECTION)指定专辑只保留商品 collection_idsranges.collection_id 有交集的商品

第二步:计算优惠金额

调用 PromotionService::discountPrice(),规则:

// 遍历配置的阶梯规则,找满足条件的最高阶梯
foreach ($ruleParam['rule'] as $val) {
    $condition     = $val['ge'];   // 门槛值
    $discountValue = $val['value']; // 折扣值
 
    // 满额类:用总价判断
    if (in_array($type, [满额减元, 满额打折])) {
        if ($cartTotal < $condition) break;
    }
    // 满件类:用总件数判断
    if (in_array($type, [满件打折, 满件减元])) {
        if ($cartCount < $condition) break;
    }
 
    if ($allocationLimit == 上不封顶) {
        // 可叠加次数 = floor(总价或总件数 / 门槛)
        $discount = 折扣值 × floor(基数 / 门槛);
    } else {
        // 单次:取当前阶梯的折扣值
        $discount = 折扣值; // 或 总价 × 折扣比例
    }
}

满额减元 type=1 示例:满100减10,上不封顶,买200元 → 减20

满件打折 type=4 示例:满3件8折,买5件 → 80% × 总价

第三步:均摊到商品

$discount = $discount * -1;  // 转为负数
foreach ($productList as $key => $product) {
    $productPrice               = $product['unit_price'] * $product['quantity'];
    $product['promotion_price'] = $productPrice / $price * $discount * -1;
    // 即:商品优惠 = (商品行原价 / 购物车总价) × 总优惠
    $productList[$key] = $product;
}
$promotion['discount'] = $discount * -1;  // 负数

⚠️ 注意:若 isOriginalPrice = true(满足会员价优先条件),计费基数用 original_price 而非 unit_price

4.3.2 calSinceDiscount(type=5 第X件Y折)

特点:按商品件数逐层匹配阶梯折扣,每层可独立生效。

两种模式

模式A:每层都生效(topPromotion = 0,默认)

$productInc = 0;
foreach ($productList as $key => $product) {
    for ($i = 0; $i < $quantity; $i++) {
        $product['quantity'] = 1;
        $productInc += 1;
        foreach ($ruleParams['rule'] as $rule) {
            if ($productInc >= $condition && !in_array($condition, $hasHandlerCondition)) {
                $discountPrice = ($product['unit_price'] * $value / 100) * -1;
                $product['promotion_price'] += $discountPrice;  // 叠加
                $promotion['discount']      += $discountPrice;  // 累加
                $hasHandlerCondition[] = $condition; // 该层已处理
            }
        }
    }
}

示例:活动「满3件8折、满5件7折」,购买5件,按价格从低到高排:

  • 件1-3:8折优惠(每件都享)
  • 件4-5:7折优惠(替换3件的8折?不,每件独立叠加)

模式B:顶层生效(topPromotion = 1

$productCount = Σ(quantity);
foreach ($ruleParams['rule'] as $inc => $rule) {
    if ($condition <= $productCount && $nextCondition > $productCount) {
        $promotion['discount'] = ($商品总价 × 折扣值 / 100) * -1;
        // 只有最顶层折扣生效
    }
}

⚠️ 排序控制:通过 ruleParam['promotion_xy_product_price_sort'] 配置 asc(低到高)或 desc(高到低),决定哪些商品先被计算。

4.3.3 calFixedPriceDiscount(type=6 满件一口价)

核心思想:从购物车中选出一组商品,以固定总价成交。

// 商品按价格升序排列(便宜的先选)
// 遍历阶梯规则,找满足件数的最低价组合
foreach ($ruleParams['rule'] as $rule) {
    if ($totalQuantity >= $rule['ge']) {
        // 取 $rule['ge'] 个商品,检查是否比一口价高
        $productPricesSlice = array_slice($productPrices, 0, $rule['ge']);
        if (array_sum($productPricesSlice) > $ruleFixedPrice) {
            $totalDiscount += (原价和 - 一口价);
            if (allocationLimit != 上不封顶) break; // 单次只取最低组合
        }
    }
}

示例:满3件一口价¥30,购物车有4件:价格[10, 15, 20, 25]

  • 取前3件(10+15+20=45)→ 优惠45-30=15

上不封顶时:取完一组后,剩余商品继续匹配下一阶梯

4.3.4 calBuyXFeeY(type=7 满X免Y)

核心思想:买够X件,最便宜的Y件免单。

// 商品按价格升序(便宜的先免)
$totalFreeNumber = intval($discountValue * floor($totalQuantity / $condition));
// 循环:从低到高逐商品累加免单数量
foreach ($productList as $key => $product) {
    $currentProductFreeNumber = min($quantity, $needFreeNumber);
    $promotionPrice = -1 * ($product['price'] * $currentProductFreeNumber);
    $productList[$key]['promotion_price'] = $promotionPrice;
}

示例:满3件免1件,购买4件:价格[10, 15, 20, 25]

  • 免单数量 = 1 × floor(4/3) = 1
  • 最便宜的1件(10元)免单 → 优惠 -10

4.4 handlerOrderSurchrge(会员价优先兜底)

当同时存在会员折扣(has_maxoffer)和「第X件Y折」活动(type=5)时:

if ($isMinmaxoffer && $max > 0) {
    // 1. 把所有活动优惠 discount 清零
    foreach ($promotionList as $key => &$value) {
        $value['discount'] = 0;
    }
    // 2. 以会员折扣优先:取 max(原价 - 上限价, 活动优惠最大值)
    $firstTypeKey = array_search(type=5, array_column($promotionList, 'type'));
    $promotionList[$firstTypeKey]['discount'] = max(($originalPrice - $max), max($discounts)) * -1;
    // 3. 商品单价恢复为 original_price(会员价)
    foreach ($cart['items'] as $key => $item) {
        $cart['items'][$key]['price']            = $item['original_price'];
        $cart['items'][$key]['unit_price']      = $item['original_price'];
        $cart['items'][$key]['final_price']      = $item['original_price'];
        $cart['items'][$key]['final_line_price'] = $item['original_line_price'];
    }
}

触发条件:会员折扣比活动优惠更大时,用会员价替换活动价。

5. 字段输出结构

5.1 cart.promotion_price 计算

// CartService.php:552
$return['promotion_price'] = price_format(
    array_sum(array_column($promotion, 'discount'))
    + array_sum(array_column($return['diy_offers'] ?? [], 'discount'))
);
  • promotion 为数组,每个活动一项,含 discount(负数)
  • diy_offers 为自定义活动优惠(由 DiyOfferService 计算)
  • 最终 promotion_price 为负数(优惠为正数时用绝对值)

5.2 cart.items 每个商品行的 promotion_price

每个活动返回 promotion['product_list'],其中每个商品有 promotion_price(负数):

$product['promotion_price'];  // 该商品在此活动中的优惠额(负数)

同一商品若匹配多个活动,各活动的优惠额会叠加(同一个商品行可能有多个来源的 promotion_price)。

5.3 输出字段一览

字段类型含义示例
cart.promotion_pricefloat总优惠金额(负数)-15.00
cart.promotionarray活动详情列表[{"type":5,"discount":-5,...}]
cart.promotion[].discountfloat该活动总优惠(负数)-10.00
cart.promotion[].typeint活动类型(1-7)5
cart.promotion[].product_listarray参与此活动的商品列表[{product_id:1,promotion_price:-3}]
cart.promotion[].product_list[].promotion_pricefloat该商品在此活动中的优惠(负数)-3.00
cart.promotion[].possible_productsarray符合活动条件的商品(未参与互斥过滤前)-
cart.promotion[].rangesarray活动配置的商品/专辑范围[{"product_id":1}]
cart.promotion[].rule_paramarray活动规则JSON(解码后){"rule":[{"ge":3,"value":10}]}

6. 优惠互斥与优先级规则

6.1 商品/专辑互斥

  • 同一商品不能同时用于多个活动
  • 同一专辑不能同时用于多个活动
  • product_range_sort 升序处理:范围越小优先级越高

6.2 活动类型之间是否互斥?

同类型活动:互斥(通过 not_ranges 实现)

不同类型活动:可叠加,例如:

  • 满100减10(type=1)+ 第X件Y折(type=5)可以同时生效
  • 满X免Y(type=7)+ 满件一口价(type=6)可同时生效

6.3 与其他价格字段的互斥

组合行为
promotion + couponpromotion 先算,coupon 后算;coupon 的 REPLACE_WITH_PROMOTION 模式会跳过 promotion 直接抵扣
promotion + membership_discount会员折扣可能触发 handlerOrderSurchrge 兜底替换 promotion 结果
promotion + diy_offers最后合并:promotion_price = Σpromotion.discount + Σdiy_offers.discount

7. 边界场景说明

7.1 无任何活动

  • getAllCartPromotionByCache() 返回空 → promotion_price = 0
  • 循环直接跳过,不做任何计算

7.2 活动商品不在购物车

  • calGeneralDiscount 过滤后 productList 为空 → discount = 0,该活动不参与求和

7.3 互斥导致所有活动失效

  • 商品被前面更高优先级活动占满 → not_ranges 覆盖全部商品 → 该活动 discount = 0

7.4 优惠金额大于商品总额

if ($discount > $price) {
    $discount = $price;  // 优惠不超商品总价
}

7.5 满件一口价中商品部分参与

商品A买5件参与满3件一口价:

  • 只有前3件参与活动享受一口价,后2件按原价
  • 优惠按商品原价占比均摊给参与活动的商品

7.6 第X件Y折 + 上不封顶叠加

买10件,满3件8折、满5件7折(均为上不封顶):

  • 每3件享一次8折:3×8折 + 3×8折 + 剩余4件(不满足任何阶梯?或按最后一层?)
  • 取决于规则配置和 topPromotion 标志

8. 与总价 total_price 的关系

total_price 的组成(9字段):
  subtotal_price        = Σ(final_line_price)          商品行小计
  discount_price        = 整单折扣(负数)
  refund_price          = 退款口径独立
  current_total_price   = subtotal + shipping           仅展示
  total_price           = subtotal - promotion - coupon + shipping + tax + tip + payment + insurance
                        └─ promotion_price 在此处被扣减

⚡⚡⚡ promotion_price 是负数,在 total_price 公式中做加法(减去负数即增加)。

9. 关键代码索引

逻辑文件:行号
入口:CartService 调用CartService.php:550-552
入口:PromotionHandlerService.getCartPromotionPromotionHandlerService.php:17-150
互斥商品过滤PromotionHandlerService.php:573-593
第X件Y折计算PromotionHandlerService.php:158-287
满件一口价计算PromotionHandlerService.php:594-697
满X免Y计算PromotionHandlerService.php:702-812
全场/部分活动计算PromotionHandlerService.php:295-485
会员价优先兜底PromotionHandlerService.php:892-939
优惠金额计算(discountPrice)PromotionService.php:957-1003
活动类型常量定义PromotionModel.php:12-18