活动 / 优惠券 / diy_offer 优惠体系

本文档梳理前台购物车与结账流程中,四套彼此独立、命名易混 的优惠体系:类型枚举、计算顺序、叠加/互斥规则、代码入口与订单字段映射。与实现冲突时以代码为准。

相关文档

目录


1. 四套体系总览

体系数据表购物车/订单字段作用域
购物车插件活动o_diy_offer行上 diy_offer_id;汇总进 promotion_price商品行 / 购物车结构
标准满减活动o_promotioncart.promotion[]promotion_price / current_promotion_price商品 subtotal
优惠券o_couponcoupon_price / current_coupon_price商品 subtotal(满减之后)
订单级附加项o_order_diy_offerdiy_offer_price / current_offer_price订单级(积分、改价、Seel 等)

另有 运费折扣o_shipping_discount),只作用于运费方案价格,不参与商品 subtotal 优惠栈。

1.1 命名易混点(重构必读)

代码中的名称实际含义
cart.diy_offers[]o_diy_offer 插件活动在购物车阶段的折扣明细
cart.diy_offer_priceo_order_diy_offer 汇总金额,写入 order.current_offer_price
order_diy_offer / OrderDiyOfferService订单级附加项,与 DiyOfferService(购物车插件)无关

2. 计算顺序与架构

主入口:CartService::getList()common/services/CartService.php 523–758 行)。

flowchart TD
    subgraph cartPhase [CartService.getList 商品优惠阶段]
        A[cartDataComposition 行价组装] --> B[defaultDiyOffer minmaxoffer]
        B --> C{has_minmaxoffer?}
        C -->|否| D["cartDiyOffers: promotion / bundlesale / skubundlesale"]
        C -->|否| E[cartDiyOffers: gift]
        C -->|是| F[PromotionHandlerService.getCartPromotion]
        D --> E
        E --> F
        F --> G["promotion_price = Σ满减.discount + Σdiy_offers.discount"]
        G --> H[calPreCouponPrice 预用券]
        H --> I[checkCouponUseWithPromotionStatus 替换型券处理]
    end
    subgraph orderPhase [已有 checkout 订单阶段]
        I --> J[checkCartCoupon / 税费 / 运费 / 小费]
        J --> K[OrderDiyOfferService.updateDiyOfferInfo]
        K --> L[total_price 汇总]
    end

2.1 固定顺序(谁先谁后)

顺序阶段说明
1cartDataComposition从 Redis/订单组装行价;限时促销过期则清 diy_offer_id
2defaultDiyOffer(['minmaxoffer'])店铺级订单自定义费用;激活后设 has_minmaxoffer=true
3cartDiyOffers(['promotion','bundlesale','skubundlesale'])仅当 !has_minmaxoffer
4cartDiyOffers(['gift'])仅当 !has_minmaxoffer
5getCartPromotion标准满减(o_promotion);无论是否 minmaxoffer 均执行
6汇总 promotion_price满减 discount + diy_offers[].discount
7calPreCouponPrice从 Cookie/Cache 预应用券
8checkCouponUseWithPromotionStatus替换型券清零满减
9(有订单时)税费、运费、小费、OrderDiyOfferService订单级附加项
10total_price 汇总见下文公式

代码锚点:

  • minmaxoffer 与其他 diy_offer:CartService.php 524–545 行
  • 满减与 promotion_priceCartService.php 548–550 行
  • 优惠券:CartService.php 554–555 行
  • 订单级 diy_offer:CartService.php 708 行
  • 总价:CartService.php 714–749 行

2.2 总价公式

优惠类字段均为 负数或 0(抵扣)或 正数(附加费,如 Seel)。

total_price = subtotal
            + shipping_price
            + payment_price      // 支付手续费
            + tip_price
            + tax_price
            + coupon_price       // 负
            + insurance_price
            + promotion_price    // 负(含满减 + bundlesale 等 discount)
            + diy_offer_price    // 来自 o_order_diy_offer(积分负、Seel 正等)

3. o_order_diy_offer — 订单级附加项

不是购物车活动,是结账/订单阶段的附加费用或抵扣。Model:OrderDiyOfferModel(表 o_order_diy_offer)。调度:OrderDiyOfferService / OrderDiyOfferHandleService

3.1 已知 from_name 类型

from_name含义价格方向Handler
customer_points会员积分抵扣extend/orderdiyoffers/OrderDiyOfferPoints.php
admin_custom_price后台手动改价可正可负OrderService::updateOrderPriceDiyOffer()
app_deliveryprotec运输保障插件通常正extend/orderdiyoffers/OrderDiyOfferDeliveryprotec.php
app_seelSeel 延保/包裹保护通常正extend/orderdiyoffers/OrderDiyOfferSeel.php

扩展机制:OrderDiyOfferService::getExtendObj()from_name 下划线后缀映射 orderdiyoffers\OrderDiyOffer{Type}

3.2 各 Handler 详细行为

路由规则OrderDiyOfferService::getExtendObj()from_name 去掉第一个 _ 前缀后 camelCase → orderdiyoffers\OrderDiyOffer{Type}

customer_points(积分)

说明
save 入口OrderDiyOfferPoints::saveOrderDiyOfferPointsBalanceService::usePoints/savePoints
参数{"customer_points": 1} 使用 / 0 退还
DB 行price 负值、values=积分数、key_name/from_name=customer_points
积分基数subtotal + coupon + promotion + shipping + tax(不含 insurance/tip/seel/deliveryprotec)
Redis无 checkout 级 hash;启用时在 order_diy_offers_from_name:{storeId} 注册

admin_custom_price(后台改价)

说明
checkout save空实现(不在前台流程写入)
持久化Admin POST /api/order/updateOrderPriceDiyOfferOrderService::updateOrderPriceDiyOffer()
二次改价从 subtotal+shipping+…+其他 offer 重算基数,排除自身 admin_custom_price

app_deliveryprotec / app_seel

deliveryprotecseel
Redis Keyorder_diy_offer:{storeId}{checkoutToken}order_diy_offer:{storeId}seel:{checkoutToken}
PHP 侧仅 hGetAll 读取,写入在主题 JS / 第三方 App
save 流程del 旧行 → 读 Redis → insert → 打 order tag
强制处理saveDiyOffer() 始终 merge 这两种 type

o_order_diy_offer 表字段id, store_id, order_id, params, title, price, key_name, values, descript, from_id, from_name, created_at, updated_at

3.3 updateDiyOfferInfo vs saveDiyOffer

方法触发行为
saveDiyOffer前台 POST offer_from_name按 JSON 调各 Handler 写入
updateDiyOfferInfoCartService::getList 有订单时(708 行)读 DB 已有 from_name,全量重跑 save(积分金额随订单变化 reconcile)

3.4 offer_from_name 传参(可多个)

可以一次传多个。 字段类型为 字符串,内容是 JSON 对象(不是数组)。
入口:OrderDiyOfferHandleService::saveDiyOfferjson_decode($offerFromName, true)OrderDiyOfferService::saveDiyOffer($order, $map)

JSON 形状:key = from_name,value = 该 Handler 的开关/参数。

{
  "customer_points": 1,
  "app_seel": 1,
  "app_deliveryprotec": 1
}
key(from_name)value 含义说明
customer_points1 使用积分抵扣 / 0 退还积分OrderDiyOfferPoints 处理
app_seel任意 truthy(后端归一为 1从 Redis seel hash 读价后入库
app_deliveryprotec同上从 Redis deliveryprotec hash 读价后入库
admin_custom_price不走 前台 offer_from_name;仅 Admin API

后端强制 mergeOrderDiyOfferService::saveDiyOffer 58–62 行):即使 POST 未带,也会 始终 处理 app_deliveryprotecapp_seel(值为 1)。
传空字符串 / 不传 → 仅跑上述两种 + 无其它前台项。

各形态 POST 位置

形态字段路径
标准shipping_method POST:offer_from_name
渐进式shipping-method POST body:offer_from_name
单页complete / preCompletetrans_info.offer_from_name(仍为 JSON 字符串

示例(标准结账 form)

offer_from_name={"customer_points":1}

示例(积分 + 依赖 Redis 的插件,一次提交)

offer_from_name={"customer_points":1,"app_seel":1}

多类型落库:每种 from_name 各写一行 o_order_diy_offer(同 type 先删后插);current_offer_price = 各行 price 之和。

查询已启用类型:结账页变量 order_offer_from_nameOrderDiyOfferService::getOfferFromNameList()(Redis order_diy_offers_from_name:{storeId} + 积分规则)。

→ 详见 order-diy-offer-handlers.md §5

3.5 互斥与共存

规则说明
多类型可共存积分 + Seel + 后台改价可同时存在
from_name 互斥各 Handler save 前先 delOrderOffer,同类型仅保留最新
积分不可重复用已有 customer_points 记录则抛错
积分 UI 互斥结账页若已有积分 offer,不再返回 points_rule
已付订单禁退积分checkout 阶段 pointsBack()FINANCIAL_ADDITIONAL_PAID 抛错
与商品优惠独立不参与 promotion_price 计算链,只在 total 最后阶段加入

4. o_diy_offer — 购物车插件活动

配置表 o_diy_offer + 范围表 o_diy_offer_range。策略模式:DiyOfferService::getExtendObj($type)extend/diyoffers/DiyOffers{Ucfirst(type)}.php

4.1 活动类型与三种改价模式

所有购物车插件活动最终都落入三种 改价模式 之一,重构时应先统一抽象再映射到现有字段:

改价模式含义典型 type汇总方式
InlinePrice直接改 items[].price/final_priceminmaxoffer、promotion/limitedtimeoffer体现在 subtotal;diy_offers[].discount 常为 0
AccruedDiscount不改行价,产出负 discountbundlesale、skubundlesale、o_promotion计入 promotion_price
LineRebuild增删 cart 行、礼品价置 0giftsubtotal 变化 + 可能 discount=0
type实现类DB type改价模式
minmaxofferDiyOffersMinmaxofferminmaxofferInlinePrice
promotion / limitedtimeofferDiyOffersPromotion均为 promotionInlinePrice
bundlesaleDiyOffersBundlesalebundlesaleAccruedDiscount
skubundlesaleDiyOffersSkubundlesaleskubundlesaleAccruedDiscount
giftDiyOffersGiftgiftLineRebuild

limitedtimeoffer 无独立 PHP 类:DB type=promotion,前台用 data_from=app_limitedtimeoffer 区分;离开意图促销用 app_id=226data_from=app_exitintent

bundlesale 折扣子类型:fix(固定组合价)、percentage(折扣)、constant(直减)。

4.1.1 diy_offer 的 type=promotion vs 标准满减 o_promotion

二者名字都带 promotion,不是同一套体系

对比项o_diy_offer type=promotiono_promotion 标准满减
数据表o_diy_offer + o_diy_offer_rangeo_promotion + o_promotion_range
产品形态App 插件活动(限时促销、离开意图等)店铺后台 营销活动(满额减、满件折等 7 种 type)
绑定方式加购时行上 diy_offer_id + promotion_timer property购物车 自动匹配 范围,无需 per-line id
计算类DiyOffersPromotionchangeCartPricePromotionHandlerService::getCartPromotion
改价方式InlinePrice:直接改 items[].final_priceAccruedDiscount:产出负 discountcart.promotion[]
汇总字段多数 不进 promotion_price(discount=0),体现在 subtotal计入 promotion_price / current_promotion_price
订单追溯order_product.diy_offer_iddata_from=app_limitedtimeofferorder_product.promotion_id(行上)、满减名
限时强依赖 ends_atpromotion_timer、Redis promotionsValidity活动 starts_at / ends_at
与另一套关系可叠加 标准满减(除非替换型券清满减)与 diy promotion 独立计算;捆绑 SKU 可能被 mutex 排除

limitedtimeoffer:DB 仍为 type=promotion,用 data_from=app_limitedtimeoffer 区分,独立 PHP 类。

记忆口诀

  • diy promotion = 插件、绑在行上、改单价、像「单品促销/App 活动」
  • o_promotion = 店铺满减引擎、扫整 cart、出 discount、像「满 100 减 10」

→ 类型参数见 diy-offer-types-reference.md §promotion · 满减见 promotion-types-reference.md

4.2 加购全链路

sequenceDiagram
    participant FE as 前台
    participant API as homeapi/Cart.diyOffers
    participant CS as CartService.diyOffers
    participant DS as DiyOfferService.formatCartItems
    participant Redis as Redis cart

    FE->>API: POST action + products[]
    API->>CS: diyOffers(customerId, action, products)
    CS->>CS: cartStructBuild(diy_offer_id, data_from, ends_at)
    CS->>Redis: 写入原始 cart 行
    CS->>DS: formatCartItems 按 type 打标 property
    DS->>Redis: addtocart 合并 / buynow 覆盖

请求 JSON 形状CartService::diyOffers):

{
  "action": "addtocart | buynow | batchbuynow | reducecart",
  "products": [{
    "product_id": 123,
    "sku_code": "xxx-0-0-0-0-0-0",
    "quantity": 1,
    "diy_offer_id": 456,
    "diy_offer_name": "活动名",
    "data_from": "app_bundlesale",
    "property": [{"plan_properties_id": 1}],
    "second": 3600,
    "unmodifiable": 0,
    "unavailable": 0
  }]
}
  • secondends_at = time() + second(限时促销倒计时)
  • cartStructBuild()common/services/CartService.php ~1452 行

能否一次多个products[] 多行可各绑不同 diy_offer_id;购物车也可同时存在多行、多活动。计价按 id 分组计算,cart.diy_offers[] 可有多条。限制见 diy-offer-types-reference.md §5(minmax 全局独占、捆绑与满减互斥等)。

各 type 加购差异formatCartItemInfo):

type前端是否必传 diy_offer_idformatCartItemInfo 行为
minmaxoffer否(店铺级 defaultDiyOffer 自动打标)data_from=app_minmaxoffer、property
promotion / limitedtimeoffer追加 promotion_timer property
bundlesale是(buynow 场景)空实现,依赖前端传 data_from
skubundlesale仅设 diy_offer_name
giftunmodifiable=1、property app_gift

4.3 各 type 详细拆解

4.3.1 minmaxoffer(订单自定义费用)

文件extend/diyoffers/DiyOffersMinmaxoffer.php

params JSONvalidateSaveData 写入 DB):

{
  "rule_type": 1,
  "rule_min": { "amount": 100.00, "title": "最低客单价" },
  "rule_max": { "amount": 500.00, "title": "最高客单价", "lock_max_order_price": 1 },
  "hide_fee": 0
}
  • rule_type1=锁最低 / 2=锁最高 / 3=同时锁
  • ends_at 强制永久;product_range=all

calDiscount 行为

  • 返回 [$diyOfferDiscount=0, $productDiscounts=[]]
  • 直接改每个 itemprice/unit_price/final_price/final_line_price
  • 设 cart 级:has_minmaxofferhas_maxofferminmaxoffer_diff_pricerefreshProductData

边缘recoverPrice() 不满足条件时恢复原价;PaymentService::lockMaxOrderPrice() 支付阶段修正 subtotal 精度差。

4.3.2 promotion / limitedtimeoffer

文件extend/diyoffers/DiyOffersPromotion.php

params JSON

{
  "type": "products | collection | all | all_ai",
  "data": [{
    "id": 123,
    "type": "definite_price | discount | reduction",
    "value": 10,
    "range": [0, 999]
  }],
  "show_page": ["product_detail", "cart"],
  "timer": 30
}

折扣算法getDiscount):

  • definite_price → 绝对价
  • discount → 百分比减
  • reduction → 固定减价

calDiscount 行为

  • changeCartPrice($cart, 1|2|3) 扫描整个 cart.items(不仅当前 diy_offer_id 组)
  • 找带 promotion_timer property 且 plan_properties_id < 0 的行改价
  • 校验失败则清:diy_offer_id=0property=[]data_from=''
  • 返回 discount=0,不走 cart.diy_offers[]

过期cartDataComposition 834 行 + changeCartPrice 内双重校验 ends_at

4.3.3 bundlesale(捆绑组合销售)

文件extend/diyoffers/DiyOffersBundlesale.php

params JSON

{
  "products": [
    { "product_id": 1, "num": 2, "master": 1 },
    { "product_id": 2, "num": 1, "master": 0 }
  ],
  "discount_type": "fix | percentage | constant",
  "discount_value": 20,
  "discount_rule": "all | partial"
}
  • discount_rule=all:数量必须 精确等于 num
  • discount_rule=partial:数量 ≥ num 即参与

calDiscount 流程

  1. calTotalDiscount() 校验数量 → 算总折扣(负值)
  2. 折扣为 0 → filterCartItemOfferInfo() diy_offer_id/data_from
  3. calItemDiscount()final_line_price 升序逆序分摊
  4. 写入 cart.diy_offers[] + mutexPromotionVariantUniqKey
  5. 不改 final_price;折扣经 OrderPriceService 摊到 discount_price

4.3.4 skubundlesale(多变体捆绑)

文件extend/diyoffers/DiyOffersSkubundlesale.php

params JSON

{
  "products": [{ "product_id": 1 }, { "product_id": 2 }],
  "packages": [{ "num": 3, "discount_type": "fix", "discount_value": 50 }]
}

与 bundlesale 类似,但 filterCartItemOfferInfo 故意不清 diy_offer_id(2024-08 需求:无优惠也保留归因统计)。

4.3.5 gift(赠品)

文件extend/diyoffers/DiyOffersGift.php

params JSON

{
  "discount_type": 1,
  "no_limit": 0,
  "rules": [{
    "condition": 100,
    "product_num": 1,
    "products": [{ "id": 999 }]
  }]
}
  • discount_type1=按金额 / 2=按件数
  • no_limit=1:每满 condition 送 product_num 件

calDiscount 流程(最复杂):

  1. getDiyOfferGifts() 按 range 统计 cart,匹配 rules
  2. 删除 cart 中同 diy_offer_id 的旧 gift 行
  3. 重建 gift 行:可买(价 0)/ 不可买(unavailable=1
  4. 返回 [0, []];强制 refreshProductData + Redis reset

注意getDiyOfferGiftsswitch 分支 无 break(all→collection→products 累加),疑似 bug,重构时需单测覆盖。

4.4 cartDiyOffers 编排细节

DiyOfferService::cartDiyOffers()(427 行):

  1. 初始化 mutexPromotionVariantUniqKeydiy_offers
  2. 按行 diy_offer_id 分组
  3. 每组调 calDiscount()
  4. discount≠0 → 写入 cart.diy_offers[]
  5. bundlesale/skubundlesale → merge mutexPromotionVariantUniqKey
  6. 活动删除 / gift → resetCartListData() 回写 Redis

过期 / Reset 汇总

场景处理位置行为
limitedtimeoffer 到期cartDataComposition清 diy_offer_id
promotion timer 过期DiyOffersPromotion::changeCartPrice清 offer 标记
活动 DB 删除cartDiyOffers → reset清标记,回写 Redis
bundlesale 条件不满足filterCartItemOfferInfo清 diy_offer_id
skubundlesale 条件不满足保留 diy_offer_id,discount=0
gift 条件变化calDiscount删重建行
minmaxoffer 激活全局跳过其他 diy_offer

4.5 订单商品落库

OrderService::saveProducts()(~2116 行)从 cart item 写入:

  • diy_offer_iddiy_offer_namedata_from
  • pricefinal_price(已被 calDiscount 改过)

OrderPriceService::calOrderProductDiscountPrice():bundlesale/skubundlesale 的 diy_offers[].products[].discount 摊到行 discount_price;promotion/minmaxoffer/gift 已在 final_price 体现。


5. o_promotion — 标准满减活动

Model:PromotionModel(表 o_promotion)。计算:PromotionHandlerService::getCartPromotion()

注意PromotionModel.php 部分行内注释与常量语义不一致;以下 type 含义以 PromotionService.php 801 行注释及 PromotionHandlerService 分支为准。

5.1 七种 type

type常量业务含义
1PROMOTION_FULL_AMOUNT_MINUS_AMOUNT满额减元
2PROMOTION_FULL_NUM_MINUS_OFF满件打折
3PROMOTION_FULL_NUM_MINUS_AMOUNT满件减元
4PROMOTION_FULL_AMOUNT_MINUS_OFF满额打折
5PROMOTION_SINCE_COUNT_DISCOUNT第 X 件 Y 折
6PROMOTION_FIXED_PRICE满件一口价
7PROMOTION_BUY_X_FREE_Y满 X 件免 Y 件(低价优先免)

Type 5/6/7 有独立计算分支;1–4 走 calGeneralDiscountPromotionService::discountPrice()

5.4 getCartPromotion 三步流水线

PromotionHandlerService::getCartPromotion()(17–149 行):

步骤行号行为
019unsetProductsByMutexVariantUniqKey — 移除捆绑 SKU 行
129–52getAllCartPromotionByCache(),过滤时间窗,挂 ranges,设 product_range_sort
264–100按 sort 升序构建 not_ranges(已被窄范围活动占用的 product/collection)
3111–139按 type 调 cal*不过滤 discount=0 的活动

缓存键PromotionService):

KeyTTL失效
CacheKeyHelper::cartPromotion()7 天活动 CRUD 时 expire
CacheKeyHelper::cartPromotionRange($id)7 天同上
CacheKeyHelper::promotionById($id)1 天更新时 expire

5.5 各 type 算法摘要

约定promotion.discountproduct_list[*].promotion_price 一般为 负数

Type 1–4:calGeneralDiscount(294–470 行)

  • 输入:范围内 price=Σ(unit_price×qty)count=Σ qty
  • 范围三分支:全场 / 指定商品 / 专辑(均 respect not_ranges
  • 算法:PromotionService::discountPrice($ruleParam, $price, $count, $type)
    • 满额类(1,4):按 $cartTotal 与门槛 ge 比较
    • 满件类(2,3):按 $cartCount
    • allocation_limit=999:上不封顶(floor 倍数);否则取最后一档满足条件

输出:discount(负)、product_list(按行小计比例分摊 promotion_price)。

Type 5:calSinceDiscount(157–286 行)— 第 X 件 Y 折

  • top_promotion=0:每层生效,按件序号逐档触发
  • top_promotion≠0:仅最高档,对第一件达到 condition 的行减一次
  • 支持 promotion_xy_product_price_sort 排序 unit_price

Type 6:calFixedPriceDiscount(579–682 行)— 满件一口价

  • 商品按 unit_price 升序;滑动窗口找连续 ge 件原价和 > 一口价
  • ALLOCATION_LIMIT 时可循环「上不封顶」

Type 7:calBuyXFeeY(687–791 行)— 满 X 免 Y

  • 低价优先排序;free = value × floor(totalQty/ge)(上不封顶)或取最大档
  • 按行顺序免最低价商品

5.6 not_ranges vs mutexPromotionVariantUniqKey

机制语义写入消费
not_ranges多满减并存时 SKU/专辑 占用声明getCartPromotion step2各 cal* 过滤
mutexPromotionVariantUniqKey捆绑 SKU 整行移出 满减计算cartDiyOffers 463–467 行getCartPromotion 入口 19 行

二者独立:前者是「先到先得占用」;后者是「物理剔除行」。

5.2 商品范围优先级

活动按 product_range_sort 升序排列:

指定商品 < 专辑 < 全场(999)

5.3 多活动共存规则

非「取最优折扣」,而是 先到先得 + 全部相加

  1. 同一 product_id / collection_id 只能归属 先排序到的活动not_ranges 机制,PromotionHandlerService 59–100 行)
  2. 各活动独立算 discountarray_sum 全部相加
  3. 与捆绑销售 SKU 互斥(见 4.4 节)

6. o_coupon — 优惠券

Model:CouponModel(表 o_coupon)。计算:CouponService::getCouponPlan() / useCoupon();结账编排:CouponHandlerService

6.1 与满减的关系(use_with_promotion

常量行为
0NOT_USE_WITH_PROMOTION互斥promotion_price != 0 时拒绝(no_share
1USE_WITH_PROMOTION可叠加;满减已覆盖全部小计则拒绝;叠加超额则削减券额(CouponService::useCoupon 1207–1223 行)
2REPLACE_WITH_PROMOTION替换满减:用后清零 promotion / promotion_priceCouponHandlerService::checkCouponUseWithPromotionStatus 567–617 行)

计算顺序:代码注释明确 先算活动,再算优惠券CouponHandlerService.php 582 行)。

替换型券特殊分支checkCouponUseWithPromotionStatus):

  • action != useCouponcontroller == OrderSinglePage 时:清零满减并重置订单 current_promotion_price
  • action == useCoupon 时:设 tag_refresh_promotion_price = false,防止订单满减字段被误覆盖(case1/2/3 注释)

6.2 门槛与优惠形式

维度常量含义
门槛-满件COUPON_DISCOUNT_TYPE_NUM满件数
门槛-满额COUPON_DISCOUNT_TYPE_AMOUNT满金额
优惠-折扣COUPON_DISCOUNT_OFF折扣 %(1–99)
优惠-减额COUPON_DISCOUNT_AMOUNT固定减额

商品范围:PRODUCT_RANGE_ALL / PRODUCT_RANGE_NOT_ALL / PRODUCT_RANGE_COLLECTION

6.3 旧接口 getDiscount() 差异

CouponService::getDiscount() 793–805 行:不可叠加券会从满减计算中 排除券适用商品 后再算满减。

购物车主流程(CartService::getList)不走此分支;主要用于旧/预览接口。

6.4 getCouponPlan 校验链(逐步)

CouponService::getCouponPlan()(819–970 行):

顺序条件失败 msg
1$param 非空数组cart_empty
2$couponCode 非空code_not_found
3DB 查券存在
4$status && usage_limit==0用完
5商品范围过滤无适用商品
6范围内 totalPrice/totalNum > 0
7门槛 condition(满件/满额,含 max_value)未达门槛
8优惠额 discount(% 或固定额,固定额 min(value, totalPrice))

6.5 三个优惠券入口对比

方法场景写库与活动关系
calPreCouponPrice无订单,Cookie 预券否(内存)之后走 checkCouponUseWithPromotionStatus
useCouponHandler用券 API / 有订单有订单时 useCoupon + saveProducts传实时 promotion_price
checkCartCoupongetList 有订单 reconcile可能更新 order coupon 字段checkCoupon

标准结账顺序(548–559 行):活动 → calPreCouponPricecheckCouponUseWithPromotionStatus →(有联系信息)checkCartCoupon

6.6 预券 Storage

机制Key / 位置TTL
预用券码CacheKeyHelper::PreUseCouponKey($customerId)90 天(useCouponReal
预填邮箱PreCustomerEmail同上
Cookieoem_coupons
CODCodCouponService3 个月

6.7 REPLACE_WITH_PROMOTION 全路径

路径文件行为
useCouponCouponService:1226空 break,不在此清活动
购物车清活动CouponHandlerService:594-615非 useCoupon action 时清零 promotion
useCoupon actionCouponHandlerService:611tag_refresh_promotion_price=false
税费TaxService:586disable_promotion_update 时不重算满减
CODCodCouponServicecheckCouponUseWithPromotionStatus → 替换型券可能与活动双计

6.8 checkCoupon vs useCoupon 封顶差异

  • useCoupon(1207–1223):叠加型用 全单 subtotal 做封顶
  • checkCoupon(1367–1371):用 券适用商品 totalPrice 做封顶

重构时需统一或明确文档化差异。


7. 叠加 / 互斥矩阵

AB关系
minmaxoffer其他 diy_offer(bundlesale/gift/promotion 等)互斥has_minmaxoffer 跳过 cartDiyOffers)
minmaxoffer标准满减 o_promotion可叠加(minmaxoffer 改行价后仍算满减)
bundlesale / skubundlesale SKU标准满减互斥mutexPromotionVariantUniqKey
多个 o_promotion彼此可共存(不同商品/专辑;同商品先到先得,discount 相加)
diy_offer promotiono_promotion可叠加(先改行价,再算满减)
diy_offer gifto_promotion可叠加(gift 改结构后仍算满减)
优惠券 NOT_USE_WITH_PROMOTION任何 promotion_price != 0互斥
优惠券 USE_WITH_PROMOTIONpromotion_price可叠加(有上限裁剪)
优惠券 REPLACE_WITH_PROMOTIONpromotion_price替换(清零满减)
o_order_diy_offer上述所有商品优惠独立叠加(不参与 promotion 链)
o_shipping_discount商品优惠独立(只改运费方案价格)

8. Cart / Checkout 与四种下单方式

各形态可用优惠总表(含 COD 单页)见 promotions-by-checkout-type.md
主流程与四形态对比checkout-flows.md逐步 API 与落库表checkout-flows-detail.md
本节只保留 与优惠计价直接相关 的摘要。

8.1 计价入口(按形态)

形态checkout_type计价 Service是否读 o_order
标准 / 单页 / 渐进式standard / one_page / single_pageCartService::getList创单后是
CODcodCodCartService::getCartList

单页在 getList 之后另有 CheckoutOnePageService::calCartCouponPrice 等;渐进式用 POST use-coupon;标准用 GET /coupon/use/{token}

8.2 order_diy_offer 写入时机(因形态而异)

形态写入点
标准shipping_method POST
单页complete / preComplete 的 handlerOrderDiyOffer
渐进式shipping-method POST
COD o_order_diy_offer

已有订单刷新:CartService::getList 708 行 updateDiyOfferInfo(仅在线三种)。

8.3 CartService::getList 两阶段(摘要)

阶段 A — 无订单(checkout Redis type ≠ order)

读 checkoutcart / guest|customer cart
→ cartDataComposition → diy_offer → promotion → calPreCouponPrice
→ 汇总 total(内存,不写 o_order)

阶段 B — 有订单

从 o_order hydrate current_* / coupon / shipping
→ 同阶段 A 活动计算
→ 若 has_contact_information 或 orderInfo:
    · checkCartCoupon / tax / promotion / shipping reconcile
    · orderModel->save(partial)  // coupon/tax/promotion 字段
    · saveProducts(updateTotal=true)  // 商品行 + subtotal/total
    · OrderDiyOfferService::updateDiyOfferInfo  // 重跑 order diy offers
→ 汇总 total

8.4 各折扣持久化时机

详表见 checkout-flows-detail.md §5

8.5 用券流程(摘要)

CouponHandlerService::useCouponHandler()

  • 有订单CouponService::useCoupon(),传入实时 promotion_price_currency → 成功后 OrderService::saveProducts() 刷新行分摊
  • 无订单(购物车)useCoupon() + Cookie 预存券码

验券:CouponService::checkCartCoupon() / checkCoupon()

8.6 行级优惠分摊

OrderPriceService::calOrderProductDiscountPrice()(21–163 行),在 OrderService::saveProducts(2078 行)内调用:

步骤行号行为
基线37–40discount_price = exchange(final_price)(单价级)
DIY offer43–52+= exchange(diy行discount/quantity)
优惠券62–121按适用商品 final_price×qty 比例减
满减125–161promotion.product_list 匹配 key 分摊

顺序:diy_offers → 券 → 满减(均减在同一 discount_price 字段)。

8.7 订单字段映射

CheckoutOnePageService::orderPreData() 964–968 行:

Cart 字段Order 字段来源体系
promotion_pricecurrent_promotion_price满减 + diy_offers.discount
diy_offer_pricecurrent_offer_priceo_order_diy_offer
coupon_pricecurrent_coupon_priceo_coupon
total_pricetotal_price汇总

行上活动来源:order_product.diy_offer_id 关联 o_diy_offer(bundlesale、limitedtimeoffer 等),计入 current_promotion_price 侧,不是 o_order_diy_offer

8.8 优惠体现双轨

方式典型 type汇总字段
直接改行价minmaxoffer、promotion/limitedtimeoffer、gift体现在 subtotaldiy_offers[].discount 可能为 0
产出 discount 字段bundlesale、skubundlesale、o_promotion计入 promotion_price
订单级行外项o_order_diy_offerdiy_offer_price / current_offer_price

9. 运费折扣(独立模块)

ShippingDiscountService::handlerShippingDiscount() 在选出配送方案后,按 o_shipping_discount 配置改 运费价格,不参与 promotion_priceCartService::getList 商品优惠栈。

活动类型:ACTIVITY_TYPE_FIXED(固定折扣)、ACTIVITY_TYPE_LIMIT(限时阶段折扣)。


10. 关键代码索引

职责路径
购物车计价编排common/services/CartService.phpgetList()
COD 购物车计价common/services/CodCartService.phpgetCartList()
diy_offer 编排common/services/DiyOfferService.phpcartDiyOffers() / defaultDiyOffer()
diy_offer 插件实现extend/diyoffers/*.php
标准满减计算common/services/PromotionHandlerService.phpgetCartPromotion()
满减 CRUD/缓存common/services/PromotionService.php
优惠券验券/用券common/services/CouponService.php
结账用券/预用券common/services/CouponHandlerService.php
订单级 diy_offercommon/services/OrderDiyOfferService.php
订单级 diy_offer 调度common/services/OrderDiyOfferHandleService.php
行级分摊common/services/OrderPriceService.phpcalOrderProductDiscountPrice()
单页结账common/services/CheckoutOnePageService.php
运费折扣common/services/ShippingDiscountService.php

11. 重构建议附录

以下为当前实现的 已知复杂度,供重构时参考(本文档不改代码)。

11.1 命名分裂

  • diy_offer_price(订单级)vs diy_offers[](购物车插件)vs 表名 order_diy_offer / diy_offer 三处语义不一致,易误改。
  • 建议重构时引入明确命名,如 order_addon_price vs cart_plugin_offers

11.2 优惠体现双轨

同一 promotion_price 混合了「改行价型」与「discount 字段型」活动的汇总,排查问题时需区分 items[].price 是否已被 diy_offer 改写。

11.3 多满减非最优

多个 o_promotion 按范围排序先到先得、discount 相加,不会自动选择对消费者最优的组合。

11.4 替换型券分支

REPLACE_WITH_PROMOTIONuseCoupon action、OrderSinglePage controller 有多处特殊判断(case1/2/3),重构券模块时需保留等价行为或补集成测试。

11.5 常量注释漂移

PromotionModelCouponModel 部分常量旁注释与业务语义不符,应以 Service 层分支与 API 校验为准。

11.6 插件扩展点

  • 购物车活动:新增 o_diy_offer.type → 实现 extend/diyoffers/DiyOffers{Type}.phpDiyOfferService::getExtendObj()
  • 订单附加项:新增 from_name → 实现 extend/orderdiyoffers/OrderDiyOffer{Type}.php → 注册 Redis order_diy_offers_from_name

11.7 建议的重构切分(代码模块)

pricing/
├── Pipeline/
│   ├── CartPricingPipeline.php       # 从 CartService::getList 523-555 抽出
│   └── CheckoutPricingPipeline.php   # One Page calCart* 系列
├── Mutex/
│   └── PromotionMutexRegistry.php    # has_minmaxoffer + mutexPromotionVariantUniqKey + not_ranges
├── Ledger/
│   ├── LineAdjustmentLedger.php      # InlinePrice / AccruedDiscount / LineRebuild
│   ├── OrderAddonLedger.php          # o_order_diy_offer
│   └── ShippingAdjustmentLedger.php  # o_shipping_discount
├── Promotion/
│   ├── PromotionPipeline.php         # getCartPromotion 三步
│   ├── ProductRangeFilter.php        # 合并 calGeneralDiscount + filterCartNotRangeItem
│   └── Strategies/                   # 7 个 cal* Strategy
├── Coupon/
│   ├── CouponEligibilityService.php  # getCouponPlan 校验链
│   └── CouponPromotionPolicy.php     # NOT_USE / USE_WITH / REPLACE
├── DiyOffer/
│   ├── CartOfferOrchestrator.php     # cartDiyOffers / defaultDiyOffer
│   └── Types/                        # 5 个 Handler
└── Order/
    └── OrderLineDiscountAllocator.php # OrderPriceService 拆分

11.8 已知行为差异(重构前先修或测)

问题影响
COD 未调 checkCouponUseWithPromotionStatus替换型券与满减可能双计
checkCoupon vs useCoupon 封顶基数不一致预览价与落库价可能不同
DiyOffersGift switch 无 breakgift 规则可能累加误判
getCartPromotion 返回 discount=0 活动前端需自行过滤
calProductPromotion 同 key 后者覆盖多活动同行时 promotion_id 不准
bundlesale formatCartItemInfo 空实现强依赖前端传 data_from
deliveryprotec/seel Redis PHP 只读未入库需查前端/App 写入

12. Redis / Cache Key 索引

12.1 购物车 diy_offer

Key 方法用途TTL
diyOfferDetail($id)活动详情+range7 天
defaultDiyOfferLists()hash,minmaxoffer 等店铺级配置7 天
promotionsValidity($key, $visitId)限时促销倒计时动态
promotionProductList($diyOfferId)促销落地页商品5 分钟
giftProductList($md5)赠品关联商品 ES30 分钟
guestcartKey / customercartKey / checkoutcartKey原始 cart 行

12.2 满减 o_promotion

见 §5.4 缓存键表。

12.3 优惠券

见 §6.6 预券 Storage。

12.4 order_diy_offer

Key用途
orderDiyOfferCacheKey(store, token)deliveryprotec 暂存
orderDiyOfferSeelCacheKey(store, token)seel 暂存
orderDiyOfferFromName(storeId)已启用 offer 类型 registry

13. 前台 API 端点速查

用途方法路径
标准 checkoutGET/POST/{store}/checkouts/{checkout_token}
用券GET/coupon/use/{checkout_token}
验券GET/coupon/check/{checkout_token}
取消券DELETE/coupon/cancel/{checkout_token}
order diy offer 查询POST/order/orderdiyofferinfo
diy_offer 加购POSThomeapi Cart.diyOffers
diy_offer 前台列表GET/diy-offers/{type}/...
One-page 全套POST/{store}/one-page-checkouts/{token}/*
Single-page 全套GET/POST/{store}/single-page-checkouts/{token}/*
COD 下单POST/{store}/cod-checkouts/{token}
Admin 改价POST/api/order/updateOrderPriceDiyOffer

14. 排障清单

现象排查方向
积分已扣但 total 不对updateDiyOfferInfogetOrderPoints、主从延迟
deliveryprotec 未入库Redis order_diy_offer:{store}{token} 是否有前端写入
One-Page 积分未生效是否调了 complete/preComplete 且传 offer_from_name
券与活动同时使用报错use_with_promotion=NOT_USE 且 promotion_price≠0
替换型券后仍有满减是否走了 COD;或 useCoupon action 特殊分支
捆绑后仍享满减mutexPromotionVariantUniqKey 是否写入
限时促销价未变promotion_timer property、ends_at、Redis promotionsValidity
minmaxoffer 与其他活动并存不应发生;查 has_minmaxoffer

15. 可进一步拆分的子文档

主文档保留 架构、顺序、叠加矩阵;下列子文档用于 类型参数、Handler、单测 查阅。与 §11.7 代码模块建议 一一对应

15.1 结账与优惠(checkout-process)

文档内容状态
checkout-flows.md四种下单方式关系与主流程已写
checkout-flows-detail.mdAPI 逐步、落库表已写
promotions-by-checkout-type.md五形态可用优惠、顺序、落库已写
shopping-cart.md购物车 Redis、getList已写
faq.mdcheckoutcart、reconcile、登录 cart_token 等已写
cart-checkout-token-visit-id.mdtoken / Redis 结构已写
standard-checkout-flow.md分形态逐步链路已写
cod-checkout-flow.mdCOD 带 token已写
cod-one-page-checkout.mdCOD 单页已写

15.2 优惠类型参考(promotion/)

文档内容对应代码模块(§11.7)状态
diy-offer-types-reference.mdo_diy_offer 各 type、params、改价模式、单测要点DiyOffer/Types/*已写
promotion-types-reference.mdo_promotion 七种 type、rule_param 样例Promotion/Strategies/*已写
order-diy-offer-handlers.mdo_order_diy_offer Handler、Redis 写入方、触发时机OrderAddonLedger已写
README.md上三篇索引已写

15.3 仍可后续补充(非阻塞)

文档建议内容状态
promotion/coupon-reference.md券类型、use_with_promotion、REPLACE 全路径、check vs use 封顶差异待写(主文档 §6 已有摘要)
promotion/pricing-pipeline-sequence.mdgetList / One-page calCart* 逐步时序图(对照 §11.7 Pipeline)待写
promotion/shipping-discount-reference.mdo_shipping_discount 与商品优惠栈边界待写(主文档 §9 摘要)

阅读顺序:先看本文 §1–§8 → 形态对照 promotions-by-checkout-type.md → 按需查 promotion 子文档。

文档版本:基于代码库当前实现整理(含细拆附录)。行号引用以 common/services/CartService.php 等文件为准,若代码变更请同步更新本文。