|
@@ -0,0 +1,146 @@
|
|
|
|
|
+# 订单套餐自动定价设计(Product Bundle Auto-Pricing)
|
|
|
|
|
+
|
|
|
|
|
+- 日期:2026-10-30
|
|
|
|
|
+- 状态:待评审(builder check + 用户批准)
|
|
|
|
|
+- 分支:cfclub
|
|
|
|
|
+- 影响范围:cfc-backend(结算/迁移)、cfc-frontend(小程序结算展示)、cfc-web(管理端配置,可选增强)
|
|
|
|
|
+
|
|
|
|
|
+## 意图与背景(Intent & Why)
|
|
|
|
|
+
|
|
|
|
|
+现有「送会员一口价」机制(`gift_membership_original_threshold` 达标 → 送会员商品实付一口价 1980 元)结算方式粗暴且易误导。实际案例:购物车含还原水机(40800 元)+ 肠道菌群检测套装(1980 元)时,页面显示"商品原价 42780 → 实付 1980、已优惠 40800",用户误读为"商品白送"。
|
|
|
|
|
+
|
|
|
|
|
+用户希望:改为**订单内商品组合自动匹配套餐、按套餐价结算并提示优惠**——买 2 个基础款(如 2×699=1398)时,若系统配置了"2 个基础款 = 1280 元套餐",则自动按 1280 结算,页面提示优惠。
|
|
|
|
|
+
|
|
|
|
|
+## 目标(Goals)
|
|
|
|
|
+
|
|
|
|
|
+1. 结算/购物车时,扫描订单内基础款商品数量,凑够某套餐组成即自动按套餐 SKU 价结算该部分。
|
|
|
|
|
+2. 同一订单多个套餐同时可凑时,自动选**让用户省最多**的组合。
|
|
|
|
|
+3. 超出套餐组成数量的部分按单品价兜底(凑整 + 兜底)。
|
|
|
|
|
+4. 页面提示优惠金额与命中套餐明细(**不做换购交互**)。
|
|
|
|
|
+5. 废弃现有「送会员商品原价达标 → 一口价 1980」的**结算价**逻辑。
|
|
|
|
|
+
|
|
|
|
|
+## 非目标(Non-goals)
|
|
|
|
|
+
|
|
|
|
|
+- 不做换购/弹窗交互(不引导用户手动换成套餐 SKU)。
|
|
|
|
|
+- 不改变「送会员权益」触发逻辑(`gift_membership_threshold` 累计达标 → 赠送会员 保留原样;本次只替换结算价算法)。
|
|
|
|
|
+- 不改变现有 `product_bundle_items` 的**出库联动**语义(`InventoryService` 用 `bundle_product_id` 关联套餐商品出库,保持不动)。
|
|
|
|
|
+- 不改变套餐 SKU 的既有直接购买路径(商品页仍可直接买"套餐一"等 SKU)。
|
|
|
|
|
+
|
|
|
|
|
+## 约束(Constraints)
|
|
|
|
|
+
|
|
|
|
|
+- 复用 `product_bundle_items` 表与既有管理端套餐组成配置 UI(`ProductEdit.vue` / `AdminInventoryController`)。
|
|
|
|
|
+- 生产库 MySQL 8.0.34(迁移须幂等,注意 8.0 语法,不可在索引定义用函数表达式)。
|
|
|
|
|
+- 前端为微信小程序(cfc-frontend),改动后需用户自行在微信开发者工具构建上传;后端/管理端走标准部署(systemctl 重启)。
|
|
|
|
|
+- 结算价计算在后端(服务端权威),前端只做展示与 total 扣减。
|
|
|
|
|
+
|
|
|
|
|
+## 已确认决策(用户拍板)
|
|
|
|
|
+
|
|
|
|
|
+| 决策点 | 结论 |
|
|
|
|
|
+|---|---|
|
|
|
|
|
+| 套餐定义数据源 | `product_bundle_items` 表 |
|
|
|
|
|
+| 套餐价锚点 | **套餐 SKU 作锚点**:给 `product_bundle_items` 新增 `bundle_sku_id` 列,套餐价取该 SKU 的 `price`(改表) |
|
|
|
|
|
+| 适用范围 | **所有商品**(只要配了套餐定义即生效,不限"送会员商品") |
|
|
|
|
|
+| 匹配算法 | **凑整套餐 + 单品兜底**:买 3 个、套餐为 2 个 → 1 套套餐 + 1 个单品 |
|
|
|
|
|
+| 多套餐选择 | **自动选省最多**(比较各套餐节省额) |
|
|
|
|
|
+| 无套餐可匹配 | **废弃一口价**:彻底移除"达标→一口价 1980"逻辑与配置读取,无套餐时按正常价(会员价/优惠券等)结算 |
|
|
|
|
|
+| 交互 | 只提示优惠,不做换购 |
|
|
|
|
|
+
|
|
|
|
|
+## 设计(How)
|
|
|
|
|
+
|
|
|
|
|
+### 1. 数据模型(迁移)
|
|
|
|
|
+
|
|
|
|
|
+`product_bundle_items` 新增一列:
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+-- 幂等迁移(DatabaseInitializer 内实现,先查 INFORMATION_SCHEMA.COLUMNS 是否存在)
|
|
|
|
|
+ALTER TABLE product_bundle_items
|
|
|
|
|
+ ADD COLUMN bundle_sku_id BIGINT NULL COMMENT '套餐SKU锚点:非空表示该行用于【定价套餐匹配】,套餐价取该SKU.price;为空表示该行仅用于【出库联动】(bundle_product_id 语义)';
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**行语义区分**(关键,避免与既有出库联动冲突):
|
|
|
|
|
+- `bundle_sku_id IS NOT NULL` → 定价套餐行(本设计新增用途)。
|
|
|
|
|
+- `bundle_sku_id IS NULL` → 既有出库联动行(`InventoryService` 现状,不改)。
|
|
|
|
|
+
|
|
|
|
|
+字段用途(定价套餐行):
|
|
|
|
|
+| 字段 | 用途 |
|
|
|
|
|
+|---|---|
|
|
|
|
|
+| `bundle_sku_id` | 套餐锚点 SKU(套餐价 = `product_skus.price`) |
|
|
|
|
|
+| `child_product_id` | 基础款商品(必填) |
|
|
|
|
|
+| `child_sku_id` | 基础款 SKU(可空;空 = 按商品聚合,见算法) |
|
|
|
|
|
+| `quantity` | 组成该套餐所需的基础款数量(正整数) |
|
|
|
|
|
+| `bundle_product_id` | 定价套餐行建议置 NULL(避免误触发出库联动);如既有数据复用可保留 |
|
|
|
|
|
+
|
|
|
|
|
+### 2. 结算定价算法(后端权威)
|
|
|
|
|
+
|
|
|
|
|
+输入:订单/购物车商品行集合(每行含 `product_id`、`sku_id`、`quantity`、`unitPrice`=实际结算单价)。
|
|
|
|
|
+输出:命中套餐列表(每套:锚点 SKU、命中套数、套餐总价、原价合计、节省额)+ 优惠总额。
|
|
|
|
|
+
|
|
|
|
|
+**算法(贪心省最多)**:
|
|
|
|
|
+
|
|
|
|
|
+1. 聚合购物车数量:按 `(product_id, sku_id)` 归总;`sku_id` 为空的行按 `(product_id, NULL)` 归总。
|
|
|
|
|
+2. 取所有 `bundle_sku_id IS NOT NULL` 的定价套餐行。
|
|
|
|
|
+3. 对每个套餐,判定"可凑 1 套":其每个 child 组成(按 `child_product_id` + `child_sku_id` 匹配聚合行)的数量 ≥ `quantity`。
|
|
|
|
|
+4. 计算每个可凑套餐的**节省额**:`Σ(该套餐各 child 对应的购物车实际 unitPrice × quantity) − 套餐价(SKU.price)`。
|
|
|
|
|
+ - **仅当节省额 > 0 才视为可触发**(确保用户实际省钱,避免按套餐价反而更贵)。
|
|
|
|
|
+5. 贪心循环:每轮在"当前可凑套餐"中取**节省额最大**者,命中 1 套 → 扣减其各 child 对应聚合数量(每个扣 `quantity`),累计节省额;重复直至无可凑套餐(节省额>0)。
|
|
|
|
|
+6. 剩余数量按购物车实际单价(`unitPrice`)结算(单品兜底)。
|
|
|
|
|
+7. 返回命中套餐明细 + 优惠总额 = Σ节省额。
|
|
|
|
|
+
|
|
|
|
|
+**关键口径**:
|
|
|
|
|
+- 节省额用**购物车项实际 `unitPrice`**(用户真实应付口径),非配置原价——保证"按套餐价结算确实更省"才触发。
|
|
|
|
|
+- 套餐价取锚点 SKU 的 `price`(`product_skus.price`)。
|
|
|
|
|
+- 贪心为近似最优;对"同一基础款多套餐"场景已能选省最多;跨套餐共享基础款的全局最优可后续用 DP 增强(本期不要求,标注为可扩展点)。
|
|
|
|
|
+
|
|
|
|
|
+### 3. 后端改造
|
|
|
|
|
+
|
|
|
|
|
+- **迁移**:`DatabaseInitializer` 新增幂等迁移(列存在检查 + `ALTER TABLE`),MySQL 8 语法,同步 `schema.sql`。
|
|
|
|
|
+- **定价逻辑**:新增 `BundlePricingService`(纯计算,入参购物车行 + 套餐定义,出参优惠明细/总额);或复用 `CartService.computeGiftPromo` 改造为套餐定价。
|
|
|
|
|
+- **移除一口价**:`CartService` 中 `gift_membership_original_threshold` / `gift_membership_pay_price` 的**读取与结算逻辑删除**(配置值保留不删,标记废弃)。
|
|
|
|
|
+- **DTO**:`CartItemDTO.GiftPromo` 复用或新增 `BundlePromo`:`active`、`originalTotal`、`payPrice`、`discount`、`bundleList`(命中套餐明细数组:锚点名、套数、原价、套餐价、节省额)。
|
|
|
|
|
+- 购物车 `/api/cart/list` 返回的 `giftPromo` 字段改为承载套餐优惠(字段名可复用,结构新增明细),保证前端改动最小。
|
|
|
|
|
+- 送会员权益触发(`gift_membership_threshold` 累计送会员)**保持不动**。
|
|
|
|
|
+
|
|
|
|
|
+### 4. 前端(cfc-frontend 小程序)
|
|
|
|
|
+
|
|
|
|
|
+- `cart.vue`:`giftPromo` 展示区改造为"套餐优惠"提示(命中套餐明细 + 合计节省);total 扣减 `discount` 逻辑保持。
|
|
|
|
|
+- `checkout.vue`:`gift-promo-section` 文案改为套餐优惠("已按套餐价结算:xxx,省 Y 元");`giftPromoDiscount` 计算复用;total 扣减保持。
|
|
|
|
|
+- storage `checkoutGiftPromo` 结构沿用(字段扩展),`cart.vue:424` 写入点不变。
|
|
|
|
|
+- 纯算法(如节省额/聚合)可抽到 `utils` 纯函数并单测(如 `utils/bundle-pricing.js`)。
|
|
|
|
|
+
|
|
|
|
|
+### 5. 管理端配置(cfc-web,可选增强)
|
|
|
|
|
+
|
|
|
|
|
+- 现有 `ProductEdit.vue` 的"套餐组成"区块(`productType='bundle'`)已能配 `child_product_id/child_sku_id/quantity` 与 `bundle_product_id`。
|
|
|
|
|
+- **增强**:该区块增加"套餐锚点 SKU(`bundle_sku_id`)"选择(选当前商品/关联 SKU),存库走 `saveBundleItems`。
|
|
|
|
|
+- 若本期不做管理端 UI,可先用 SQL/迁移种子配置数据(用户已确认"用 product_bundle_items 表来配"),管理端增强列为后续可选项。
|
|
|
|
|
+
|
|
|
|
|
+### 6. 示例数据(DAN 测评场景,供验证)
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+-- 套餐:2 个基础款(DAN测评 商品10) = 套餐SKU"套餐一"(product_skus.id=3, 1280元)
|
|
|
|
|
+INSERT INTO product_bundle_items (bundle_product_id, bundle_sku_id, child_product_id, child_sku_id, quantity)
|
|
|
|
|
+VALUES (NULL, 3, 10, NULL, 2);
|
|
|
|
|
+-- 买 2 个 DAN测评(699×2=1398) → 命中,按 1280 结算,省 118 元
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+(注:`child_sku_id` 是否指定由业务按"基础款口径"决定;若用户实际购买按"单包"SKU(499) 而定义按商品(699),则以购物车实际 `unitPrice` 计算节省额,`unitPrice` 口径决定是否触发。)
|
|
|
|
|
+
|
|
|
|
|
+## 我的决定(Builder 可直接沿用)
|
|
|
|
|
+
|
|
|
|
|
+- 节省额按**购物车实际 unitPrice** 计算(保证真实省钱才触发),非配置原价。
|
|
|
|
|
+- 定价套餐行 `bundle_product_id` 置 NULL(避免误触发出库联动)。
|
|
|
|
|
+- 贪心(每轮取节省额最大者)为算法实现;全局最优 DP 列为可扩展点,本期不做。
|
|
|
|
|
+- 送会员权益触发(`gift_membership_threshold`)保留,不在本次范围。
|
|
|
|
|
+- 管理端"套餐锚点 SKU"配置增强列为**可选**;本期核心交付 = 迁移 + 后端定价 + 前端展示。
|
|
|
|
|
+
|
|
|
|
|
+## 测试策略(TDD)
|
|
|
|
|
+
|
|
|
|
|
+- 后端单测 `BundlePricingService`:单套餐命中 / 凑整+兜底(买3套1+单品1)/ 多套餐省最多 / 节省额≤0不触发 / 跨SKU聚合 / 无套餐定义时不触发 / 废弃一口价后无一口价字段。
|
|
|
|
|
+- 前端纯函数(如 `utils/bundle-pricing.js`):聚合、节省额、贪心选择。
|
|
|
|
|
+- 迁移幂等:重复执行不报错(列已存在忽略)。
|
|
|
|
|
+
|
|
|
|
|
+## 验证(部署后)
|
|
|
|
|
+
|
|
|
|
|
+- 购物车放 2 个基础款(配了套餐定义)→ 页面提示"按套餐价 X,省 Y"。
|
|
|
|
|
+- 下单金额 = 套餐价 + 单品兜底,与显示一致。
|
|
|
|
|
+- 无套餐可匹配 → 正常价结算,无一口价 1980。
|
|
|
|
|
+- 管理端(若做增强)可配 `bundle_sku_id`。
|