Przeglądaj źródła

docs: 订单套餐自动定价实施计划

Task1 迁移 bundle_sku_id;Task2 贪心算法+单测;Task3 BundlePricingService;
Task4 MemberPriceResolver 共享口径;Task5 购物车 bundlePromo;Task6 后端权威重算;
Task7 前端展示;Task8 部署验证。
Xiaogang Liao 1 tydzień temu
rodzic
commit
c7fad8bea0

+ 198 - 0
docs/superpowers/plans/2026-10-31-product-bundle-autopricing.md

@@ -0,0 +1,198 @@
+# 实施计划:订单套餐自动定价(Product Bundle Auto-Pricing)
+
+- 日期:2026-10-31
+- 上游文档:`docs/superpowers/specs/2026-10-30-product-bundle-autopricing-design.md`(已获用户批准)
+- 目标分支:`cfclub`
+- 影响:cfc-backend(结算/迁移/权威重算)、cfc-frontend(小程序展示,需用户自行构建上传)
+- 不包含:cfc-web 管理端 bundle_sku_id 配置 UI(设计文档标为可选,本期不下沉);DAN 示例数据(不随迁移发布)
+
+## 最终业务规则(用户已拍板)
+
+1. 套餐定义:`product_bundle_items` 表新增 `bundle_sku_id` 列(套餐 SKU 锚点,套餐价 = 该 SKU 的 `price`)。`bundle_sku_id` 非空 = 定价套餐行;为空 = 既有出库联动行(`InventoryService` 不动)。
+2. 结算时扫描下单商品:基础款(`child_product_id` + 可选 `child_sku_id`,`quantity` 为组成数量)数量凑够 → 该部分自动按套餐价结算。
+3. 凑整 + 单品兜底:买 3 个、套餐组成 2 个 → 1 套套餐 + 1 个单品。
+4. 多套餐可凑时**贪心选节省额最大**(每轮取最大节省额套餐,扣减组成数量后重试)。
+5. **节省额 = Σ(消费的下单口径 unitPrice×qty) − 套餐价,仅当节省额 > 0 才触发**(避免会员价低于套餐价时“按套餐更贵”)。
+6. **后端权威**:下单金额由 `ProductOrderService` 服务端重算套餐优惠计入 `moneyAmount`;前端传入的 `discountAmount` 语义改为“规则优惠(不含套餐)”,套餐部分一律服务端算。
+7. 废弃“送会员商品原价达标 → 一口价 1980”:仅移除 `CartService.computeGiftPromo` 的读取与结算;**保留** `ProductOrderService` 基于 `gift_membership_original_threshold` 的单笔订单送会员权益**与** `gift_membership_threshold` 累计送会员(均不动)。
+8. 页面只提示优惠(不做换购);适用范围:所有配置了定价套餐行的商品。
+
+## 关键代码事实(勘察结论,写码前勿再翻查)
+
+### 后端
+- **CartItemDTO.GiftPromo**(`dto/CartItemDTO.java:58-71`):`giftOriginalTotal/threshold/payPrice/discount/active/remaining`。本次**新增** `BundlePromo` 嵌套类,`giftPromo` 字段保留但置空(避免旧小程序读 `giftPromo.active` 出错)。
+- **CartService.computeGiftPromo**(`service/CartService.java:411-490`):读取 `gift_membership_original_threshold` / `gift_membership_pay_price`,算一口价。**整段替换**为套餐定价。调用点在 `CartService` 购物车列表方法(约 130-135,`dto.setGiftPromo(...)`)。依赖注入在 45-51 行(`productSkuService/productGiftRuleService/sysConfigService` 等)。
+- **CartService.buildCartItemDTO**(320-405):unitPrice 解析 = linked→linked.price / sku→sku.price / 否则 product.price;`memberPrice` 仅展示不参与 unitPrice。购物车当前**非会员口径**。
+- **ProductOrderService.createMultiItem(dto, buyerId)**(740-870):
+  - 循环 742-819:行项解析——`unitPrice = getEligibleMemberPrice(product, buyerId)`;有 sku 则 linked→换 product 用 linked.price,legacy→sku.price;`totalAmount += unitPrice*qty`;`OrderItem` 记录 `product.getId()`(**解析后** id)、`item.getSkuId()`、unitPrice。
+  - 820 后:`multiDiscountAmount = dto.getDiscountAmount()`(客户端)→ `+couponDiscount`(`couponService.apply`,服务端校验)→ `moneyAmount = max(0, totalAmount − pointsCost − multiDiscountAmount)`。
+  - **改造点**:循环内收集 LineItem(解析后 productId、skuId、qty、unitPrice),循环后 `bundleDiscount = bundlePricingService.compute(lines).getDiscount()`,`multiDiscountAmount += bundleDiscount`。
+- **ProductOrderService.getEligibleMemberPrice(product, buyerId)**(2213-2260):会员折扣逻辑(memberPrice>0 且等级 >= minDiscountLevel → memberPrice,否则 price)。**抽出**为共享组件,供 CartService 展示口径对齐。
+- **ProductOrderService** 送会员权益读取(约 1157,`gift_membership_original_threshold`)**不碰**。
+- **ProductBundleItemMapper**:纯 `BaseMapper<ProductBundleItem>`(无自定义方法)。加载定价行用 `selectList(new QueryWrapper<ProductBundleItem>().isNotNull("bundle_sku_id"))`。
+- **ProductBundleItem**(entity):`id/bundleProductId/childProductId/childSkuId/quantity/createdAt`。加 `bundleSkuId`。
+- **ProductSku**(entity):`id/productId/skuCode/specs/price/.../label/...`。套餐锚点名取 `label`(非空)否则 `specs`;价取 `price`。
+- **Migration 模式**:`DatabaseInitializer` 有 `columnExists(table, column)`(5373)辅助;`ALTER TABLE ... ADD COLUMN ...` 示例见 158-202。
+- **单测**:`src/test/java/com/etotem/cfc/unit/*Test.java`,JUnit5 + Mockito(config 测试已用)。纯算法类直接实例化。
+
+### 前端(cfc-frontend,微信小程序 uni-app)
+- **cart.vue**:`this.giftPromo = result.giftPromo`(约 316);`totalAmount()`(约 177-184)`if giftPromo.active → originalTotalAmount - giftPromo.discount`;gift-promo-section(96-112);写 storage `checkoutGiftPromo`(约 424)。
+- **checkout.vue**:onLoad 读 `checkoutGiftPromo`(648);gift-promo-section(229-240);`giftPromoDiscount` computed(576-580);提交 `discountAmount: discountInfo.discountAmount + giftPromoDiscount`(约 1152)。
+- **jest ^26.6.3**,测试放 `__tests__/utils/*.test.js`(纯函数)。
+
+---
+
+## Task 1:迁移 `product_bundle_items.bundle_sku_id`
+
+**TDD 说明**:迁移幂等性无法纯单测;沿用现有 `DatabaseInitializer` 迁移模式(列存在检查),部署后验证“重复执行不报错”。
+
+**改动**:
+- `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`:新增幂等迁移块(参照现有 `columnExists` 模式):
+  ```java
+  if (!columnExists("product_bundle_items", "bundle_sku_id")) {
+      jdbcTemplate.execute("ALTER TABLE product_bundle_items ADD COLUMN bundle_sku_id BIGINT NULL COMMENT '套餐SKU锚点:非空=定价套餐行(套餐价取该SKU.price);空=出库联动行'");
+  }
+  ```
+- 同步 `schema.sql`(cfc-backend 资源目录,找到 `product_bundle_items` 建表处补列)。
+- `entity/ProductBundleItem.java` 加 `private Long bundleSkuId;`。
+
+**提交**:`feat(bundle): product_bundle_items 新增 bundle_sku_id 列(定价套餐锚点,迁移幂等)`
+
+## Task 2:纯算法 `BundlePricingCalculator`(TDD 核心)
+
+新建 `cfc-backend/src/main/java/com/etotem/cfc/pricing/BundlePricingCalculator.java`(无状态、可实例化)。
+
+**数据结构**(放同包 `bundle` 下或作为嵌套静态类,随实现定):
+- `LineItem`:`{ Long productId; Long skuId; Integer quantity; Integer unitPrice; }`
+- `BundleChild`:`{ Long childProductId; Long childSkuId; Integer quantity; }`
+- `BundleDef`:`{ Long bundleSkuId; String bundleName; Integer bundlePrice; List<BundleChild> children; }`
+- `BundleHit`:`{ Long bundleSkuId; String bundleName; Integer times; Integer originalTotal; Integer bundlePrice; Integer saving; }`
+- `BundleDiscountResult`:`{ boolean active; Integer originalTotal; Integer payPrice; Integer discount; List<BundleHit> bundleList; }`
+
+**公开方法**:`BundleDiscountResult compute(List<LineItem> lines, List<BundleDef> defs)`
+
+**算法(贪心省最多)**:
+1. 聚合池:按 `(productId, skuId)` 归总 quantity;unitPrice 取该组行内(相同 sku 的行 unitPrice 一致;`skuId==null` 组取 product 级 unitPrice)。
+2. 每轮:对每个 def,判定可凑 1 套——每个 child 的池供应量(`childSkuId!=null` 精确匹配 sku;`==null` 按 productId 聚合所有 sku 行)≥ child.quantity。可凑时算节省额:按“消费顺序”(遍历池中匹配行、从前往后扣减)取 `child.quantity` 个的 `Σ(unitPrice)` 之和,再对全体 child 求和后减 `bundlePrice`。**仅节省额 > 0 记候选**。
+3. 取节省额最大候选中标;扣减池;累计 `hit.times++`、`discount += saving`、`originalTotal += 原价合计`、`payPrice += bundlePrice`。
+4. 无候选即终止。`active = discount > 0`;空 defs/空 lines → inactive。
+
+**单元测试** `src/test/java/com/etotem/cfc/unit/BundlePricingCalculatorTest.java`(先写测试):
+1. 单套餐命中:2×699(unitPrice 69900)vs def(128000, child product10 qty2) → active、discount 11800、originalTotal 139800、payPrice 128000。
+2. 凑整+兜底:3×699 → 1 hit(times=1)、剩余 1×699 单算 → discount 11800。
+3. 多套餐省最多:同 child 两个 def(节省 11800 vs 29800)→ 选 29800。
+4. 节省≤0 不触发:member 价 55900×2=111800 < 128000 → inactive。
+5. childSkuId 空按商品聚合:两行不同 sku(product10 skuA 69900×1、skuB 59900×1)配 product10 qty2 的 def → 命中,原价合计 129800(消费顺序扣减)。
+6. 空 defs / 空 lines → inactive。
+7. bundlePrice > 原价合计 → 不触发。
+8. 两个独立套餐(不同 child、不同基础款)同单各自命中。
+
+**提交**:`feat(bundle): BundlePricingCalculator 贪心省最多套餐匹配算法 + 单测`
+
+## Task 3:`BundlePricingService`(Spring 装载定义 + 委托)
+
+新建 `cfc-backend/src/main/java/com/etotem/cfc/service/BundlePricingService.java`(`@Service`)。
+
+**注入**:`ProductBundleItemMapper`、`ProductSkuService`、`ProductMapper`(供 sku 不存在时的降级表现)。
+
+**方法**:
+- `BundleDiscountResult compute(List<LineItem> lines)`:
+  1. `productBundleItemMapper.selectList(new QueryWrapper<ProductBundleItem>().isNotNull("bundle_sku_id"))`。
+  2. 按 `bundleSkuId` 分组 → `BundleDef`;`bundlePrice = sku.price`;`bundleName = sku.label 非空 ? label : specs`;skus 不存在该 id 则跳过该 def(防御)。
+  3. 委托 `BundlePricingCalculator.compute(lines, defs)`。
+
+**单元测试**(Mockito mock mapper + skuService):
+- 组装正确(def 数量/children/价格/名称)并委托 calculator;bundle sku 缺失跳过。
+
+**提交**:`feat(bundle): BundlePricingService 装载定价套餐定义并委托计算`
+
+## Task 4:抽共享价格口径 `MemberPriceResolver`
+
+新建 `cfc-backend/src/main/java/com/etotem/cfc/service/MemberPriceResolver.java`(`@Service`),把 `ProductOrderService.getEligibleMemberPrice`(2213-2260)原样抽出:注入 `UserMapper`、`MembershipService`。
+
+**方法**:
+- `int resolveEligibleMemberPrice(Product product, Long buyerId)`(原逻辑,行为不变)。
+- `ResolvedLine resolve(Product product, ProductSku sku, Long buyerId)`:返回 `{ Product product(解析后); int unitPrice; }`——linked sku → 换 product 用 `linkedProduct.price`;legacy sku → `sku.price`;无 sku → `resolveEligibleMemberPrice(product, buyerId)`。**与 `createMultiItem` 767-782 同口径**。
+
+**改造**:`ProductOrderService` 的 `getEligibleMemberPrice` 私有方法改为委托 resolver(行为保持,多处调用点不变);`createMultiItem` 的中间解析逻辑改调 `resolver.resolve(...)`(保持同行为)。
+
+**单元测试** `MemberPriceResolverTest`:memberPrice+等级达标/不达标、minDiscountLevel、linked sku、legacy sku.price、无 sku。
+
+**提交**:`refactor(order): 抽出 MemberPriceResolver 共享下单价格口径(行为不变)`
+
+## Task 5:CartService 购物车返回 `bundlePromo`(替换一口价)
+
+**改动 `dto/CartItemDTO.java`**:新增嵌套类 `BundleItemHit`(`bundleSkuId/bundleName/times/originalTotal/bundlePrice/saving`)与 `BundlePromo`(`active/originalTotal/payPrice/discount/bundleList`)+ 字段 `private BundlePromo bundlePromo;`。`GiftPromo` 保留。
+
+**改动 `service/CartService.java`**:
+- 删除 `computeGiftPromo`(411-490)中读取 `gift_membership_original_threshold` / `gift_membership_pay_price` 及一口价计算,整体替换为 `computeBundlePromo(List<Cart> cartList, Map<Long,Product> productMap, Long userId)`:
+  1. 逐购物车项构建 `LineItem`:resolver 解析 sku/linked/member 得到**下单口径 unitPrice**(保证展示=实付),`skuId` 传原始 `cart.getSkuId()`(bundle 匹配用原始身份;linked 转发产品才用解析后 id——与下单一致,取 resolver 返回的解析后 `product.getId()` 作 productId、`skuId` 用解析后的 skuId:linked sku 在订单里 skuId 仍是原 skuId,故 LineItem.skuId = cart.getSkuId(),productId = 解析后 product id)。
+  2. `BundleDiscountResult r = bundlePricingService.compute(lines)` → `dto.setBundlePromo(...)`;`dto.setGiftPromo(buildEmptyGiftPromo())`。
+- 购物车列表方法(约 130-135)调用点改为 `computeBundlePromo`。需要 `userId`(列表方法已有当前用户上下文)。
+- `sysConfigService` 若不再使用可保留注入(或剔除,随实现)。
+
+**单元测试**:可用 Mockito 对 CartService 最小装配验证 `bundlePromo` 绑定与 `giftPromo` 置空(mock mapper/resolver/bundlePricingService)。
+
+**提交**:`feat(bundle): 购物车返回 bundlePromo,移除一口价(threshold/payPrice)结算`
+
+## Task 6:ProductOrderService 后端权威(核心)
+
+**改动 `service/ProductOrderService.java`**:
+- `createMultiItem`(742-819 循环内):收集 `List<LineItem> lines`(`new LineItem(product.getId(), item.getSkuId(), item.getQuantity(), unitPrice)`,product 为解析后)。
+- 循环后、`multiDiscountAmount` 处(约 838):`int bundleDiscount = bundlePricingService.compute(lines).getDiscount();` → `multiDiscountAmount = (dto.getDiscountAmount() ?? 0) + couponDiscount + bundleDiscount;`(**语义:`dto.getDiscountAmount()` 现仅含规则优惠**)。
+- 单件 `create()`(227-310):同样收集单行(解析后 productId、`dto.getSkuId()`、`dto.getQuantity()`、最终 unitPrice)并在 `discountAmount` 处 `+ bundleDiscount`,保证“商品页直接购买多件”也享受套餐价。
+- 注入 `BundlePricingService`。
+
+**单元测试**(新 `OrderBundlePricingTest`,Mockito mock bundlePricingService + 现有依赖桩):
+1. 多件下单 `moneyAmount = totalAmount − pointsCost − 规则优惠 − 套餐优惠`(套餐优惠=服务端计算值)。
+2. 抗伪造:客户端传的 `discountAmount` 若含“套餐部分”,服务端以其**重算值**为准(mock 返回固定 bundleDiscount,断言只计入一次)。
+3. 无套餐可匹配(bundlePricingService 返回 inactive/discount=0)→ moneyAmount 不含套餐优惠,且**不再产生一口价 1980**。
+4. 送会员权益读取(`gift_membership_original_threshold` 单笔达标赠会员)仍生效(该逻辑未触碰,作回归断言)。
+
+**提交**:`feat(bundle): 下单后端权威重算套餐优惠,前端折扣仅规则优惠`
+
+## Task 7:前端(cart.vue / checkout.vue)
+
+**新建 `utils/bundle-pricing.js`**(纯函数,供展示汇总与单测):
+- `summarizeBundlePromo(bundlePromo)` → `{ originalTotal, payPrice, saving, rows: [{name, times, saving, ...}] }`(防御空值)。
+
+**新建 `__tests__/utils/bundle-pricing.test.js`**:空值/正常/多行汇总。
+
+**cart.vue**:
+- 读取 `result.bundlePromo`(替换 316 行 `giftPromo`)。
+- `totalAmount()`(177-184):`if bundlePromo && bundlePromo.active && discount>0 → originalTotalAmount − bundlePromo.discount`。
+- gift-promo-section(96-112)→ **bundle-promo-section**:命中明细行(`bundleList`:套餐名 ×times,原价合计 → 套餐价,省 X 元)+ 合计“已按套餐价结算,共优惠 Y 元”。无命中不显示。
+- storage 写入 `checkoutBundlePromo`(424 行替换 `checkoutGiftPromo`)。
+
+**checkout.vue**:
+- onLoad 读 `checkoutBundlePromo`(648 行)。
+- gift-promo-section(229-240)→ bundle-promo-section(同上文案)。
+- `giftPromoDiscount` (576) → `bundlePromoDiscount`(读 `bundlePromo.discount`)。
+- 提交(1152):`discountAmount: (discountInfo ? discountInfo.discountAmount : 0)`——**去掉 `+ giftPromoDiscount`**(套餐优惠由后端权威重算)。
+- 确认 `calcAmount`/合计展示扣减 `bundlePromoDiscount`。
+
+**注意**:小程序改动需你自行构建(`npm run build:mp-weixin` 或既有流程)后在微信开发者工具上传;后端部署即可生效(旧小程序在过渡期看到 `giftPromo.active=false`、不吃亏,见设计“过渡安全”说明)。
+
+**提交**(在远程 cfc-frontend 内):`feat(bundle): 购物车/结算页套餐优惠展示,提交仅传规则优惠`
+
+## Task 8:验证与部署(go/no-go 确认后执行)
+
+1. **本地/远程构建**:`cd /app/cfc/cfc-backend && mvn -q test`(跑全部单测,含新增);`mvn clean package -Dmaven.test.skip=true -Dmaven.compile.fork=true`。
+2. **预检 + 备份**:按 AGENTS.md——`git status`、`git fetch && git pull --rebase origin cfclub`、备份 `/app/pro/cfc`、健康基线 curl。
+3. **部署**:jar 到位 → `systemctl restart cfc-backend` → 日志检查(Tomcat started、0 ERROR)。
+4. **验证**:
+   - `/api/cart/list` 返回 `bundlePromo`;`giftPromo.active=false`。
+   - 配一行临时套餐数据(如一条 bundle_sku_id 定义)→ 购物车放足量基础款 → 页面展示套餐价与节省。
+   - 下单 `moneyAmount` = 服务端重算值(与展示一致);篡改前端 discountAmount 无效。
+   - 送会员(单笔/累计)触发不受影响。
+   - 迁移幂等:重启/重复执行不报错(列已存在忽略)。
+5. **留档**:迁移编号/时间、验证结果记入本计划文件末尾 `## 验证记录` 或口头汇报。
+
+## 部署顺序与过渡说明
+
+- **后端先行**(本次 deploy 即生效):`cart/list` 开始返回 `bundlePromo`、`giftPromo` 置空;下单后端权威重算。旧小程序:`giftPromo.active=false` → 不显示一口价、total 不减 → 提交的 `discountAmount` 无套餐部分 → 服务端补套餐优惠,实付=服务端值(安全,不会出现“旧前端多打折”)→ **请尽快构建上传新小程序**以保证展示一致。
+- **cfc-web(管理端)**:本期不做 UI;`bundle_sku_id` 配置走 SQL(示例见设计文档 §6)。
+
+## 不做(明确排除)
+
+- 不做换购交互、不做管理端 UI、不发布 DAN 种子数据、不删 `attachBundleMatches`(技术债保留)、不改 `InventoryService` 出库联动、不动送会员权益触发。