diy_offer 类型参考(o_diy_offer)

o_diy_offer 购物车插件活动速查。架构与计算顺序见 promotion-discount-checkout.md §4

与实现冲突时以 extend/diyoffers/DiyOfferService 为准。

目录


1. 类型总表

DB type实现类前台 data_from(常见)改价模式
minmaxofferDiyOffersMinmaxofferapp_minmaxofferInlinePrice
promotionDiyOffersPromotionapp_limitedtimeoffer / 空InlinePrice
bundlesaleDiyOffersBundlesaleapp_bundlesaleAccruedDiscount
skubundlesaleDiyOffersSkubundlesaleapp_skubundlesaleAccruedDiscount
giftDiyOffersGiftapp_giftLineRebuild

扩展标识(无独立 PHP 类):app_exitintentapp_id=226)、app_customeow 等,多数仍走 type=promotion 或对应插件逻辑。


2. 三种改价模式

模式汇总进 promotion_price体现在订单
InlinePrice通常 diy_offers.discount=0order_product.price / current_subtotal_price
AccruedDiscountdiy_offers[].discountcurrent_promotion_price + 行 discount_price 分摊
LineRebuild通常 增删行、礼品 final_price=0

3. 各 type params 与行为

minmaxoffer

店铺级,defaultDiyOffer(['minmaxoffer']) 自动打标,不需行上 diy_offer_id。后台保存时经 DiyOffersMinmaxoffer::validateSaveData 校验后写入 o_diy_offer.params(JSON 字符串)。

params 示例rule_type=3 时才会同时含 rule_minrule_maxrule_type=1 仅存 rule_minrule_type=2 仅存 rule_max):

{
  "rule_type": 3,
  "rule_min": { "amount": 100.00, "title": "最低客单价" },
  "rule_max": { "amount": 500.00, "title": "最高客单价", "lock_max_order_price": 1 },
  "hide_fee": 0
}

顶层字段

字段类型必填说明
rule_typeint锁定策略:1 仅锁最低客单价;2 仅锁最高客单价;3 同时锁最低与最高。决定 calDiscountcalMinPrice / calMaxPrice / calMaxAndMinPrice
rule_minobjectrule_type13 时必填最低客单价规则,见下表。
rule_maxobjectrule_type23 时必填最高客单价规则,见下表。rule_type=3rule_max.amount 须 ≥ rule_min.amount
hide_feeint否,默认 0保存时 intval 写入 params。当前 PHP 结账计算链路(DiyOffersMinmaxoffer::calDiscount未读取;仅作为配置落库,供后台/主题展示控制(如是否隐藏调价说明)。

rule_min 字段

字段类型必填说明
amountnumber最低商品小计阈值(店铺货币,两位小数语义)。计算基准为各行 price × quantity 之和(calProductTotalPrice)。当前小计低于该值时,按比例分摊把行价上调至目标总额。
titlestring前台展示文案。调价后写入行 property / original_propertytype=app_minmaxoffer 项的 valuehandleProperty)。

rule_max 字段

字段类型必填说明
amountnumber最高商品小计阈值。当前小计高于该值时,按比例分摊把行价下调至目标总额;并设 cart.has_maxoffer=true
titlestringrule_min.title,用于 app_minmaxoffer property 展示。
lock_max_order_priceint否,默认 00 时把 rule_max.amount 写入 cart.minmaxoffer_max_order_price。结账选支付方式时 PaymentService::lockMaxOrderPrice 用该上限约束 订单总价(含支付手续费等);并与 minmaxoffer_diff_price 配合修正 order.current_subtotal_price 精度。

运行时行为(calDiscount

场景行为
rule_type=1 且小计 ≥ rule_min.amountrecoverPrice 恢复原价,清除 app_minmaxoffer property
rule_type=2 且小计 ≤ rule_max.amount同上;has_maxoffer=false
rule_type=3 且小计在 [rule_min, rule_max]不调价,恢复原价
需要调价applyAdjustment 按行权重分摊目标总额;分摊舍入差写入 cart.minmaxoffer_diff_price;设 cart.has_minmaxoffer=truerefreshProductData=true
与其它 diy_offerhas_minmaxoffer=true跳过 bundlesale、gift 等其它 diy_offer 计算
o_promotion满减 仍执行(minmax 改的是行价/小计,不阻断标准满减)

分摊规则:以各行 price × quantity 为权重(0 元行用 0.01 虚拟权重);最后一行吸收舍入差,保证商品小计尽量等于目标 amount

promotion / limitedtimeoffer

DB type 均为 promotion;前台 data_from 常见 app_limitedtimeoffer(离开意图插件 app_id=226 时为 app_exitintent)。与 o_promotion 标准满减的区别见 promotion-discount-checkout.md §4.1.1

后台保存时 paramsjson_encode 写入 o_diy_offer.params;商品/专辑范围另写入 o_diy_offer.product_range + o_diy_offer_range(与 params.type 对应,见下表)。

params 示例(指定商品 + 限时):

{
  "type": "products",
  "data": [
    { "id": 123, "type": "discount", "value": 10, "range": [0, 999] }
  ],
  "show_page": ["product_detail", "cart"],
  "timer": 30,
  "sort": "best_selling"
}

type=all_ai 额外字段示例

{
  "type": "all_ai",
  "data": [{ "id": 0, "type": "reduction", "value": 5, "range": [0, 0] }],
  "show_page": ["product_detail"],
  "timer": 60,
  "enable_fallback": 1,
  "ai_cooccurrence_source": "add_to_cart",
  "related_product_limit": 5
}

顶层字段(params

字段类型必填说明
typestring优惠适用范围,与表字段 product_range 对齐:products 指定商品;collection 指定专辑;all 全店;all_ai AI 关联推荐商品页。决定 calDiscountchangeCartPrice 的分支(1/2/3)。
dataarray优惠规则列表,元素结构见下表。products 类型按 id=product_id 逐商品匹配;collection / all / all_ai 通常取 data[0] 作为统一规则。
show_pagestring[]活动展示页面路由列表,如 product_detailcartformatFountListData 仅当当前 $route 命中时才返回该活动。
timerint分钟(存库单位)。前台列表接口会 × 60 转为秒;若活动 ends_at 更近则取剩余秒数。用于倒计时 property promotion_timer 的有效期。
sortstring活动商品列表排序(getProductList):title_ascending / price_ascending / price_descending / best_selling / created_descending / created_ascending。空则按近期订单购买顺序 + ES 补全。
enable_fallbackintall_ai0 时 AI 协同商品不足则用店铺热销等兜底凑满 related_product_limit
ai_cooccurrence_sourcestringall_ai协同比对数据源:add_to_cart(默认)/ view_content / purchase
related_product_limitint否,默认 5all_ai 推荐商品数量上限。

data[] 元素字段

字段类型必填说明
idinttype=products 时为 商品 IDtype=collection 时为 专辑 IDall / all_ai 可为占位 0
typestring改价方式(getDiscount):definite_price 指定售价(value 为目标价);discount 百分比折扣(value 为折扣百分比,如 10 表示 10% off);reduction 直减(value 为减价金额)。
valuenumbertype 配合的数值,见上。
range[number, number]否,默认 [0,0]collection / all / all_ai 商品列表 ES 筛选:按 variant_price_min 过滤,[min,max]0 表示不限。

表级字段(非 params,保存时一并提交)

字段说明
starts_at / ends_at活动起止时间戳;ends_at=0 表示永久(存为 FOREVER_TIME)。status=0 或未开始时不改价。
product_range + idsparams.type 对应,写入 o_diy_offer_range(商品 ID 或专辑 ID 列表)。
status0 关闭 / 1 开启。

运行时行为(calDiscountchangeCartPrice

场景行为
行上无 promotion_timer property不改价;plan_properties_id = -diy_offer_id 来自 formatCartItemInfo
倒计时过期 / 活动 ends_at 已过diy_offer_idpropertydata_fromcartDataComposition 也会清 id
商品不在 data 范围 / 专辑不匹配同上,恢复原价
改价成功直接改行 price / final_line_priceInlinePricediy_offers.discount=0);refreshProductData=1
definite_price行价 = value
discount行价 = 原价 × (1 - value/100)
reduction行价 = max(原价 - value, 0)

bundlesale

组合销售;validateSaveData 强制 product_range=productsends_at=永久。行上须带 diy_offer_id(通常经 POST /cart/diyoffers 加购)。

params 示例

{
  "products": [
    { "product_id": 1, "num": 2, "master": 1 },
    { "product_id": 2, "num": 1, "master": 0 }
  ],
  "discount_type": "percentage",
  "discount_value": 20,
  "discount_rule": "all",
  "display_rule": "all"
}

顶层字段

字段类型必填说明
productsarray是,≥2 个组合内商品列表,元素见下表。保存时同步写入 o_diy_offer_range
discount_typestringfix 组合指定总价;percentage 百分比折扣(discount_value0 < value < 100);constant 直减金额。
discount_valuenumberdiscount_type 配合的目标价 / 折扣百分比 / 减价金额。
discount_rulestring否,默认 allall:每个配置商品的购物车数量须 等于 num 才生效;partial:数量 num 即可,仅统计达标商品的金额。
display_rulestring前台展示范围:all 任意组合内商品详情页均展示;mastermaster=1 的主商品页展示。

products[] 元素字段

字段类型必填说明
product_idint组合内商品 ID。
numint该商品在组合中要求的数量(见 discount_rule)。
masterint否,默认 01 表示主商品;配合 display_rule=master 控制详情页是否展示活动。

运行时行为(calDiscount

场景行为
数量条件不满足calTotalDiscount 返回 0filterCartItemOfferInfo 清除 行上 diy_offer_id / data_from
条件满足汇总 AccruedDiscountdiy_offers[].discount 为负值优惠额;按行 final_line_price 升序均摊到 productDiscounts
fix优惠 = min(0, discount_value - 组合原价合计)
percentage优惠 = -(合计 × value / 100)
constant优惠 = -min(value, 合计)
与标准满减参与 SKU 写入 mutexPromotionVariantUniqKey,与 o_promotion 互斥

skubundlesale

组合内 SKU 总件数 匹配 packages 档位;表级有 starts_at / ends_at / status,须在有效期内且 status=1 才计算。

params 示例

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

顶层字段

字段类型必填说明
productsarray是,≥1,≤100可参与组合的商品 ID 列表(product_id)。
packagesarray件数档位 配置的优惠方案,元素见下表。

products[] / packages[] 元素字段

路径字段类型必填说明
products[]product_idint组合候选商品。
packages[]numint触发档位所需的 SKU 总件数(同 diy_offer_id 下各行 quantity 之和须 等于 某档 num)。
packages[]discount_typestring同 bundlesale:fix / percentage / constant
packages[]discount_valuenumber同 bundlesale,相对该档组合 final_line_price 合计计算。

运行时行为(calDiscount

场景行为
活动未开始 / 已结束 / status=0返回 discount=0
总件数未命中任一 packages[].numdiscount=0
命中档位计算逻辑同 bundlesale;AccruedDiscount 汇总进 promotion_price
条件不满足保留 行上 diy_offer_id(产品需求:统计归因),discount=0refreshProductData=true
与标准满减同 bundlesale,写入 mutexPromotionVariantUniqKey

gift

满赠;触发条件看 o_diy_offer.product_range + o_diy_offer_range(保存时在 range 字段提交,不在 params JSON 内)。params 只存门槛规则与赠品池。

params 示例

{
  "discount_type": 1,
  "no_limit": 0,
  "rules": [
    {
      "condition": 100,
      "product_num": 1,
      "products": [{ "id": 999 }, { "id": 1000 }]
    },
    {
      "condition": 200,
      "product_num": 2,
      "products": [{ "id": 1001 }]
    }
  ]
}

顶层字段(params

字段类型必填说明
discount_typeint门槛计量方式:1金额(适用范围内商品 final_line_price 合计);2件数quantity 合计)。
no_limitint否,默认 00 命中 rules 中最高满足档 一次,赠送 product_num 件;1 每满 conditionproduct_num 件(floor(当前值/condition) × product_num)。
rulesarray阶梯规则,condition 升序配置;计算时 从高到低 匹配(array_reverse)。元素见下表。

rules[] 元素字段

字段类型必填说明
conditionnumber门槛值(金额或件数,取决于 discount_type)。
product_numint满足该档时可赠送的 礼品件数上限(顾客在 products 池中选择)。
productsarray可选赠品池,[{ "id": product_id }, ...]

表级字段(非 params

字段说明
product_range触发范围:all 全店 / products 指定商品 / collection 指定专辑;对应 o_diy_offer_range 行。
starts_at / ends_at活动时间;未在有效期内 getDiyOfferGifts 返回空。
同时段冲突product_range + 重叠 range 不可保存(validateSaveDataTIME_CONFLICT)。

运行时行为(calDiscountLineRebuild

场景行为
getDiyOfferGiftsproduct_range 汇总购物车统计值,匹配 rulesproductIds + 可赠 quantity
product_range switchbreak fall-throughallcollectionproducts 累加);all 类型会叠加专辑/商品统计,排障需注意
未达门槛删除该 diy_offer_id 旧 gift 行;不新增可赠礼品
达门槛删旧 gift 行 → 按已选 SKU 重建;可赠数量内 unavailable=0final_price=0;超出拆分为不可赠行
购物车 vs 结账购物车阶段不可赠标记 unavailable=1;结账页不可赠恢复为普通商品(清 diy_offer_id
与其它 diy_offerhas_minmaxoffer=true 时 gift 计算被跳过
汇总diy_offers.discount=0;优惠体现在 0 元礼品行 与小计扣减

4. 叠加与互斥

规则说明
diy_offer_id购物车 可并存cartDiyOffers 按 id 分组分别 calDiscount
minmaxoffer 激活禁止 其它 diy_offer(bundlesale、gift 等)
bundlesale / skubundlesale参与 SKU 移出满减池(mutexPromotionVariantUniqKey
同一行一般只绑 一个 diy_offer_id

5. 传参方式(前台 → Redis)

diy_offer 不是offer_from_name 那样在结账步 POST 一个 JSON 包;而是在 加购 / 改购 时把活动信息写在 购物车行 上,持久化到 Redis guestcart / customercart

5.1 主入口:POST /cart/diyoffers

路由:app/homeapi/route/route.phpCart/diyOffers

请求体(JSON):

{
  "action": "addtocart",
  "products": [
    {
      "product_id": 123,
      "sku_code": "abc-0-0-0-0-0-0",
      "quantity": 2,
      "diy_offer_id": 456,
      "diy_offer_name": "捆绑活动A",
      "data_from": "app_bundlesale",
      "property": [],
      "second": 3600,
      "unmodifiable": 0,
      "unavailable": 0,
      "label": ""
    },
    {
      "product_id": 789,
      "sku_code": "def-0-0-0-0-0-0",
      "quantity": 1,
      "diy_offer_id": 999,
      "diy_offer_name": "限时促销B",
      "data_from": "app_limitedtimeoffer",
      "property": [{ "plan_properties_id": -1, "name": "promotion_timer", "value": "30" }],
      "second": 1800
    }
  ],
  "checkout_type": ""
}
字段层级说明
actionaddtocart | buynow | batchbuynow | reducecart
products数组;每项 = 一行商品 + 该行绑定的 diy_offer
diy_offer_ido_diy_offer.idminmaxoffer 可不传(店铺级自动)
diy_offer_name展示/归因用
data_fromapp_bundlesaleapp_limitedtimeofferapp_gift
property定制属性;限时促销含 promotion_timer
second倒计时秒数 → ends_at = time() + second
unmodifiable / unavailable / labelgift 等场景

处理链:CartService::diyOfferscartStructBuildDiyOfferService::formatCartItems → 写 Redis。

5.2 普通加购 POST /cart/add

不带 diy_offer_id(仅 product_idsku_codequantitydata_fromproperty)。
无活动 id 的加购 不能 通过此接口绑 diy_offer;需走 /cart/diyoffers 或带活动的专用落地页。

5.2.1 还有哪些方式会写入 / 改变 diy_offer_id

方式是否前台传 diy_offer_id写哪里说明
POST /cart/diyoffers✅ 行上显式传Redis / buynow cart_data主路径action 含 addtocart、buynow、batchbuynow、reducecart
POST /cart/buynow❌ 无此参数checkoutcart cart_datacreateBuynowcartStructBuild(...) 默认 id=0
POST /cart/batchbuynowcheckoutcart同上
POST /cart/add / batchedguestcart/customercart同上
getList → minmax❌(店铺级自动)先内存,可回写 RedisdefaultDiyOfferDiyOffersMinmaxoffer::formatCartItemInfodiy_offer_id(第109行)
getList → gift❌(依赖已有行)内存 + resetCartListData满赠 重建/合并赠品行,保留活动 id;回写 Redis
getList → bundlesale内存 + 可能 resetCartListData条件不满足时 行上 diy_offer_idfilterCartItemInfo
限时过期内存;行数变时 saveCartListDatacartDataCompositiondiy_offer_id(834–837 行)
创单 saveProductso_order_product从 cart 行 拷贝 到订单,不是写 Redis

结论:前台要带活动 id 加购/立即购买,应走 /cart/diyoffers(或在同一接口里 action=buynow)。
minmax / gift / bundlesale 校验 可在 无 id 或 id 已存在 的行上,在 getList 计价阶段 自动 补写、清写或回写 Redis。

5.2.2 diy_offer_id 清理时机(代码索引)

⚡⚡⚡ 三处清理逻辑,触发条件不同,写入位置也不同。

1. 活动过期(通用)— cartDataComposition 第1步

适用:所有 diy_offer 类型(含限时促销)。 触发:ends_at 已过或 status=0。 代码:cartDataComposition 内检查并清 diy_offer_id;行数变化时 saveCartListData 回写 Redis。

2. bundlesale 条件不满足 — filterCartItemInfo

适用:仅 bundlesale。 触发:购物车内商品数量/配置不满足 bundlesale 条件。 代码:filterCartItemInfo 清除行 diy_offer_id;行数变化时回写。

3. 活动已删除(任意类型)— DiyOfferService::cartDiyOffersresetCartListData(第436–480行)

if (empty($diyOfferInfo)) {
    // 活动已删除
    $needResetRedis = 1;
    $clearCartDiyOfferIds[] = $diyOfferId;
} else {
    $diyOfferInfo['type'] == 'gift' && $needResetRedis = 1; // gift 每次都触发重建
}
// ...
if ($needResetRedis) {
    $this->resetCartListData($cart, $clearCartDiyOfferIds); // 清 clearCartDiyOfferIds 里的活动
}

resetCartListData(489–524行)根据 clearCartDiyOfferIds 清除对应行的 diy_offer_id、property、name 等字段,并 saveCartListData 回写 Redis。

gift 行清理不在 resetCartListData:gift 的行变化(删除旧赠品行、新增满足条件的赠品行)由 DiyOffersGift::calDiscount 直接操作 $cart['items']resetCartListData 只保证 Redis 数据同步(gift 类型每次都触发 needResetRedis=1 是为了重建同步)。

总结对照表

触发条件清理位置写 Redis 条件
活动过期(通用)cartDataComposition行数变化时 saveCartListData
bundlesale 条件不满足filterCartItemInfo行数变化时 saveCartListData
活动已从数据库删除cartDiyOffersresetCartListDataneedResetRedis=1 时调用
gift 行增删DiyOffersGift::calDiscount$cart['items']外部 resetCartListData 同步

5.2.4 单页与标准形态算券的数据源差异

场景数据来源说明
标准形态 CartService::getListRedis preCouponCode用户在购物车页领券后写入 Redis
单页 CheckoutOnePageService::getCartcalCartCouponPricetrace_info 结构体用户在结算页选择地址/支付方式后,trace_info 随请求传入

⚡⚡⚡ 两者数据源不同:标准形态读 Redis(预券),单页读 trace_info(当前请求上下文)。

⚡⚡⚡ 只有 minmaxoffer 是后台自动打 diy_offer_id 标记的实现类,bundlesale/gift 不在此处打标记。

CartService::getList 内三段调用(524–544 行)

// 第524行 — 先算 minmaxoffer(标记 + 计价)
(new DiyOfferService())->defaultDiyOffer($return, ['minmaxoffer']);
 
// 第527行 — minmax 未激活时才算其它 diy_offer
if (empty($return['has_minmaxoffer'])) {
    (new DiyOfferService())->cartDiyOffers($return, ['promotion','bundlesale', 'skubundlesale']);
}
 
// 第544行 — gift 也被 minmax 挡掉
if (empty($return['has_minmaxoffer'])) {
    (new DiyOfferService())->cartDiyOffers($return, ['gift']);
}

defaultDiyOffer 内部流程(DiyOfferService 699–753 行)

1. 从 Redis(defaultDiyOfferLists)读店铺级默认活动
2. 遍历 cart['items'] 每行
3. 调 $obj->formatCartItemInfo()    ← 这里打 diy_offer_id 标记
4. 调 cartDiyOffers() 算价格

打标记位置:DiyOffersMinmaxoffer::formatCartItemInfo()(第109行)

$cartItem['diy_offer_id']   = $offer['id'];
$cartItem['diy_offer_name'] = $offer['name'];
$cartItem['data_from']      = 'app_minmaxoffer';
// 同时填充 property

各实现类 formatCartItemInfo 行为对比

实现类diy_offer_id其它
DiyOffersMinmaxoffer✅ 打标记(第109行)填 name、data_from、property
DiyOffersBundlesale❌ 空方法,不打
DiyOffersGift❌ 不打填 name、unmodifiable、property(gift 条目)

添加新的后台自动计算 diy_offer(参考 minmaxoffer):

  1. extend/diyoffers/DiyOffersXxx.php 实现 calDiscount + formatCartItemInfo(后者打 diy_offer_id
  2. DiyOfferService::getExtendObj() 注册 case
  3. CartService::getListdefaultDiyOffer 调用里加 type,或仿照第527/544行单独调 cartDiyOffers

5.3 计价阶段(无需再传参)

结账 getList 时读 Redis 行上的 diy_offer_id,按 id 分组调 cartDiyOffers / defaultDiyOffer没有单独的「应用 diy_offer」POST。

5.4 能否一次用多个?

可以,模型是「多行、多个 id」,不是「一个 JSON 里列多个 offer」。

方式说明
同一请求多行products[] 里每行可不同 diy_offer_id(上例 A+B)
购物车多行不同 SKU 各绑不同活动;cartDiyOffers diy_offer_id 分组分别 calDiscount
结果数组内存 cart.diy_offers[]多条(每个产生 discount≠0 的活动一条)
同一行仅一个标量 diy_offer_id(见 §5.5)
minmaxoffer店铺级 全局唯一has_minmaxoffer不算 其它 diy_offer
bundlesale SKU与标准满减 互斥mutexPromotionVariantUniqKey

5.5 同一行为何一般只有一个 diy_offer_id?(代码依据)

1. 行结构只有一个字段

cartStructBuild 只写入 一个 diy_offer_id(标量),无 diy_offer_ids[] 数组。

2. 计价按该字段分组

DiyOfferService::cartDiyOffers$product['diy_offer_id']bucket key 分组;diy_offer_id=0 的行跳过。一行只会落入 一个 bucket。

3. 合并加购不覆盖活动 id

buildVariantUniqKey = product_id + sku_code + property不含 diy_offer_id)。
同 key 再次加购时(diyOffers addtocart 1676–1687 行)只累加 quantity保留原行 diy_offer_id / data_from,不会变成第二次加购的活动。

4. minmax 覆盖行 id

DiyOffersMinmaxoffer::formatCartItemInfo 执行 $cartItem['diy_offer_id'] = $offer['id'],店铺 minmax 激活后 整行归属 minmax

5.6 同一商品能否享受多个 diy_offer?怎么处理?

不能在同一 Redis 行上并存多个 diy_offer_id 多活动靠以下方式:

场景处理
同 SKU、不同活动不同 property(如限时带 promotion_timer)→ 不同 uniqueKey → 两行,各行一个 id
同 key 重复加购先加购的活动 id 保留(合并逻辑不更新 id)
gift新增赠品行,独立 diy_offer_id
整 cart 多活动多行不同 id → cart.diy_offers[] 多条
diy_offer + o_promotion可叠加(不同流水线);bundlesale 可能 mutexPromotionVariantUniqKey 排除满减
minmax + 其它 diyminmax 后 跳过 其它 cartDiyOffersCartService 524–527 行)
限时 promotion 改价changeCartPrice 扫整 cart,但每行用 property 首项 的负 plan_properties_id 定活动(current($v['property'])

没有「一行挂 id 列表、分别计多次 diy_offer」的实现。

offer_from_name(订单级) 对比:

diy_offerorder_diy_offer
传参时机加购 / diyoffers API物流步 / complete 的 offer_from_name
多选形式products[] 多行 或 cart 多行一个 JSON 对象多个 key
存储Redis 行字段 + 计价内存o_order_diy_offer

6. 落库字段

位置字段
o_order_productdiy_offer_iddiy_offer_namedata_frompricefinal_price
o_ordercurrent_promotion_price ← AccruedDiscount 类 discount 汇总( order_diy_offer)

discount_price 分摊:OrderPriceService::calOrderProductDiscountPrice()(bundlesale/skubundlesale 的 diy_offers[].products[].discount)。


7. 单测 / 排障要点

场景断言 / 排查
minmax + bundlesale不应同时生效;查 has_minmaxoffer
bundlesale 数量边界all vs partial;不满足应清 offer 或 discount=0
限时促销过期ends_atpromotion_timer property
gift 规则切换行重建、Redis reset
current_promotion_price 有值、promotion_id常见为 bundlesale/skubundlesale 或订单头未写满减 id
buynow 加购formatCartItemInfo 对 bundlesale 空实现,依赖前端 data_from

8. 代码索引

模块路径
编排common/services/DiyOfferService.phpcartDiyOffersdefaultDiyOffer
加购common/services/CartService.phpdiyOfferscartStructBuild
Handlerextend/diyoffers/DiyOffers*.php
分摊common/services/OrderPriceService.php