Просмотр исходного кода

feat(consumer): 购物车套餐「一键替换为套餐」提示(替代自动优惠)

Xiaogang Liao 1 неделя назад
Родитель
Сommit
6c6df41971

+ 30 - 44
cfc-frontend/__tests__/utils/bundle-pricing.test.js

@@ -1,58 +1,44 @@
 /**
  * Tests for cfc-frontend utils/bundle-pricing.js
  *
- * 验证套餐优惠的纯函数语义:未命中/未激活 → 0;active=true + discount>0 才生效。
+ * 验证购物车套餐offer展示辅助:comboOfferOf 命中/未激活语义;formatComboOfferText 提示文案;
+ * 总价不再被自动扣减(纯展示,权威计算在后端 POST /api/cart/combo-offer)。
  */
 const {
-  bundleDiscountOf,
-  formatBundleLines,
-  applyBundleDiscount
+  comboOfferOf,
+  formatComboOfferText
 } = require('@/utils/bundle-pricing')
 
-describe('bundleDiscountOf', () => {
-  it('null/undefined → 0', () => {
-    expect(bundleDiscountOf(null)).toBe(0)
-    expect(bundleDiscountOf(undefined)).toBe(0)
+describe('comboOfferOf', () => {
+  it('null/undefined → null', () => {
+    expect(comboOfferOf(null)).toBeNull()
+    expect(comboOfferOf(undefined)).toBeNull()
   })
-  it('active 非 true → 0', () => {
-    expect(bundleDiscountOf({ active: false, discount: 11800 })).toBe(0)
-    expect(bundleDiscountOf({ active: true, discount: 11800 })).not.toBe(0)
-    expect(bundleDiscountOf({})).toBe(0)
+  it('active 非 true → null', () => {
+    expect(comboOfferOf({ active: false, comboName: '套餐X' })).toBeNull()
+    expect(comboOfferOf({})).toBeNull()
   })
-  it('active=true + 正折扣 → 折扣值', () => {
-    expect(bundleDiscountOf({ active: true, discount: 11800 })).toBe(11800)
-  })
-  it('active=true + 非正折扣 → 0', () => {
-    expect(bundleDiscountOf({ active: true, discount: 0 })).toBe(0)
-    expect(bundleDiscountOf({ active: true, discount: -1 })).toBe(0)
+  it('active=true → 返回 offer(纯展示,不篡改总价)', () => {
+    const offer = { active: true, comboName: '套餐X', comboPrice: 15000, saving: 2000, groups: 1 }
+    expect(comboOfferOf(offer)).toBe(offer)
   })
 })
 
-describe('formatBundleLines', () => {
-  it('null/空数组 → []', () => {
-    expect(formatBundleLines(null)).toEqual([])
-    expect(formatBundleLines([])).toEqual([])
-  })
-  it('过滤无 bundleName 的行', () => {
-    const rows = formatBundleLines([
-      { bundleName: '套餐一', times: 1, saving: 11800 },
-      { times: 2 }
-    ])
-    expect(rows).toHaveLength(1)
-    expect(rows[0].name).toBe('套餐一')
-  })
-  it('补齐默认 times/saving', () => {
-    const rows = formatBundleLines([{ bundleName: '套餐一' }])
-    expect(rows[0].times).toBe(1)
-    expect(rows[0].saving).toBe(0)
-  })
-})
-
-describe('applyBundleDiscount', () => {
-  it('未命中 → 原总价', () => {
-    expect(applyBundleDiscount(139800, null)).toBe(139800)
-  })
-  it('命中 → 扣减套餐优惠', () => {
-    expect(applyBundleDiscount(139800, { active: true, discount: 11800 })).toBe(128000)
+describe('formatComboOfferText', () => {
+  it('无 offer → 空串', () => {
+    expect(formatComboOfferText(null)).toBe('')
+    expect(formatComboOfferText({ active: false })).toBe('')
+  })
+  it('有 offer → 「这N件组成套餐「XX」,套餐价 ¥Y(省 ¥Z)」', () => {
+    const txt = formatComboOfferText({ active: true, comboName: '豪华套餐', comboPrice: 15000, saving: 2000 }, 3)
+    expect(txt).toContain('3 件')
+    expect(txt).toContain('豪华套餐')
+    expect(txt).toContain('150.00')
+    expect(txt).toContain('20.00')
+  })
+  it('有 offer 无 checkedCount → 用 groups 兜底;saving=0 不显示省', () => {
+    const txt = formatComboOfferText({ active: true, comboName: '豪华套餐', comboPrice: 15000, saving: 0, groups: 2 })
+    expect(txt).toContain('2 件')
+    expect(txt).not.toContain('省')
   })
 })

+ 3 - 3
cfc-frontend/pages/discover-detail/product-detail/product-detail.vue

@@ -149,9 +149,9 @@
         <mp-html :content="product.description" :domain="apiBaseUrl" :tag-style="contentTagStyle" />
       </view>
 
-      <!-- 套餐包含(bundle商品展示子商品组成) -->
+      <!-- 套餐内含清单(bundle商品展示子商品SKU×数量) -->
       <view v-if="isBundleProduct && bundleItems.length > 0" class="bundle-section">
-        <text class="section-title">套餐包含</text>
+        <text class="section-title">内含清单</text>
         <view class="bundle-item" v-for="(bi, idx) in bundleItems" :key="getBundleKey(bi, idx)">
           <image v-if="bi.coverImage" class="bundle-item-img" :src="getImageUrl(bi.coverImage)" mode="aspectFill" />
           <view class="bundle-item-info">
@@ -561,7 +561,7 @@ export default {
         that.loading = false
         if (res.code === 200 && res.data) {
           that.product = res.data
-          that.bundleItems = (res.data && res.data.bundleItems) || []
+          that.bundleItems = (res.data && res.data.comboItems) || []
           // 计算可得CF值:floor(原价/100) × pointsMultiplier
           if (res.data.price && res.data.pointsMultiplier) {
             that.cfEarnedValue = Math.floor(res.data.price / 100) * res.data.pointsMultiplier;

+ 86 - 25
cfc-frontend/pages/shop/cart/cart.vue

@@ -107,27 +107,18 @@
       <view v-else class="gift-promo-reached">已达标,支付后自动赠送会员</view>
     </view>
 
-    <!-- 订单套餐自动定价优惠提示(后端权威下发,命中才显示) -->
-    <view v-if="bundlePromo && bundlePromo.active" class="bundle-promo-section">
-      <view class="bundle-promo-header">
-        <text class="bundle-promo-icon">📦</text>
-        <text class="bundle-promo-title">订单套餐自动匹配,已享套餐价</text>
-      </view>
-      <view v-for="(b, idx) in (bundlePromo.bundleList || [])" :key="idx" class="bundle-promo-row">
-        <text class="bundle-promo-name">{{ b.bundleName }} × {{ b.times }}</text>
-        <text class="bundle-promo-save">省 {{ formatPriceWithSymbol(b.saving) }}</text>
-      </view>
-      <view class="bundle-promo-total">
-        <text class="bundle-promo-total-label">套餐优惠合计</text>
-        <text class="bundle-promo-discount">-{{ formatPriceWithSymbol(bundlePromo.discount) }}</text>
-      </view>
-    </view>
-
     <view v-else-if="giftIsGranted" class="gift-granted-hint">
       <text class="gift-granted-icon">✅</text>
       <text class="gift-granted-text">本家庭已赠送会员,请前往会员中心查看</text>
     </view>
 
+    <!-- 购物车套餐offer(勾选子集,后端权威下发,纯展示不扣款) -->
+    <view v-if="comboOffer && comboOffer.active" class="combo-offer-section">
+      <view class="combo-offer-title">{{ comboOfferText }}</view>
+      <view class="combo-offer-price">套餐价 {{ formatPriceWithSymbol(comboOffer.comboPrice) }} · 省 {{ formatPriceWithSymbol(comboOffer.saving) }}</view>
+      <button class="combo-replace-btn" @click="replaceWithCombo">一键替换为套餐</button>
+    </view>
+
     <view class="bottom-bar" v-if="items.length > 0">
       <view class="bottom-left">
         <text class="selected-count">已选 {{ selectedCount }} 件</text>
@@ -157,8 +148,8 @@
 
 <script>
 import config from '@/config.js'
-import { getMyMembership, getGiftMembershipProgress } from '@/utils/api.js'
-import { applyBundleDiscount } from '@/utils/bundle-pricing.js'
+import { getMyMembership, getGiftMembershipProgress, queryComboOffer, replaceCartWithCombo } from '@/utils/api.js'
+import { formatComboOfferText } from '@/utils/bundle-pricing.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
@@ -173,7 +164,8 @@ export default {
       isMember: false,
       giftProgress: null,  // 赠会员累计进度
       giftPromo: null,     // 送会员一口价促销信息(已废弃,后端恒返回非激活)
-      bundlePromo: null    // 订单套餐自动定价优惠(后端权威下发)
+      comboOffer: null,    // 勾选子集套餐offer(后端权威下发,纯展示提示)
+      offerTimer: null     // 勾选变化防抖定时器
     }
   },
   computed: {
@@ -198,8 +190,7 @@ export default {
           total = total + (this.items[i].subtotal || this.items[i].unitPrice * this.items[i].quantity)
         }
       }
-      // 套餐自动定价优惠(后端权威),命中则扣减
-      return applyBundleDiscount(total, this.bundlePromo)
+      return total
     },
     originalTotalAmount() {
       var total = 0
@@ -211,6 +202,13 @@ export default {
       }
       return total
     },
+    comboOfferText() {
+      var count = 0
+      for (var i = 0; i < this.items.length; i++) {
+        if (this.items[i].checked) count++
+      }
+      return formatComboOfferText(this.comboOffer, count)
+    },
     estimatedTotalAmount() {
       var total = 0
       for (var i = 0; i < this.items.length; i++) {
@@ -223,8 +221,7 @@ export default {
           total = total + (price * this.items[i].quantity)
         }
       }
-      // 套餐自动定价优惠(后端权威),命中则扣减
-      return applyBundleDiscount(total, this.bundlePromo)
+      return total
     },
     hasGiftItems() {
       for (var i = 0; i < this.items.length; i++) {
@@ -297,6 +294,44 @@ export default {
         }
       }).catch(function() {})
     },
+    scheduleOfferCheck() {
+      if (this.offerTimer) clearTimeout(this.offerTimer)
+      this.offerTimer = setTimeout(() => { this.queryOffer() }, 400)
+    },
+    queryOffer() {
+      var that = this
+      var ids = []
+      for (var i = 0; i < this.items.length; i++) {
+        if (this.items[i].checked && this.items[i].id) ids.push(this.items[i].id)
+      }
+      if (!ids.length) {
+        that.comboOffer = null
+        return
+      }
+      queryComboOffer({ checkedCartItemIds: ids }).then(function(res) {
+        if (res && res.code === 200 && res.data && res.data.active) {
+          that.comboOffer = res.data
+        } else {
+          that.comboOffer = null
+        }
+      }).catch(function() {})
+    },
+    replaceWithCombo() {
+      var that = this
+      var ids = []
+      for (var i = 0; i < this.items.length; i++) {
+        if (this.items[i].checked && this.items[i].id) ids.push(this.items[i].id)
+      }
+      if (!ids.length || !this.comboOffer) return
+      replaceCartWithCombo({ checkedCartItemIds: ids, comboSkuId: this.comboOffer.comboSkuId }).then(function(res) {
+        if (res && res.code === 200) {
+          uni.showToast({ title: '已替换为套餐', icon: 'success' })
+          that.loadCart()
+        } else {
+          uni.showToast({ title: (res && res.message) || '替换失败', icon: 'none' })
+        }
+      }).catch(function() {})
+    },
     goUpgrade() {
       uni.navigateTo({ url: '/pages/membership/upgrade' })
     },
@@ -323,8 +358,8 @@ export default {
               that.items[i].subtotal = that.items[i].unitPrice * that.items[i].quantity
             }
             that.giftPromo = result.giftPromo || null
-            that.bundlePromo = result.bundlePromo || null
             that.loadGiftProgress()
+            that.scheduleOfferCheck()
           }
         },
         fail: function() {
@@ -343,6 +378,7 @@ export default {
       for (var i = 0; i < this.items.length; i++) {
         this.items[i].checked = newState
       }
+      this.scheduleOfferCheck()
     },
     toggleItem(productId) {
       for (var i = 0; i < this.items.length; i++) {
@@ -351,6 +387,7 @@ export default {
           break
         }
       }
+      this.scheduleOfferCheck()
     },
     increaseQty(item) {
       item.quantity = item.quantity + 1
@@ -432,7 +469,6 @@ export default {
       }
       uni.setStorageSync('checkoutItems', selected)
       uni.setStorageSync('checkoutGiftPromo', this.giftPromo)
-      uni.setStorageSync('checkoutBundlePromo', this.bundlePromo)
       uni.navigateTo({ url: '/pages/shop/checkout/checkout' + (this.taskId ? '?taskId=' + this.taskId : '') })
     },
     formatPriceWithSymbol(price) {
@@ -836,4 +872,29 @@ export default {
   font-size: calc(26rpx * var(--font-scale, 1));
   color: #15803D;
 }
+.combo-offer-section {
+  margin: 20rpx $uni-space-page;
+  padding: 20rpx;
+  background: #FFF7E6;
+  border-radius: 16rpx;
+}
+.combo-offer-title {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $status-warning-strong;
+  line-height: 1.5;
+}
+.combo-offer-price {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $status-warning-deep;
+  margin-top: 8rpx;
+}
+.combo-replace-btn {
+  margin-top: 16rpx;
+  padding: 14rpx 24rpx;
+  background: $status-warning-deep;
+  color: #FFFFFF;
+  border-radius: 40rpx;
+  font-size: calc(26rpx * var(--font-scale, 1));
+  text-align: center;
+}
 </style>

+ 0 - 27
cfc-frontend/pages/shop/checkout/checkout.vue

@@ -238,24 +238,6 @@
         </view>
       </view>
 
-      <!-- 订单套餐自动定价优惠(后端权威下发,命中才显示) -->
-      <view v-if="bundlePromo && bundlePromo.active" class="section bundle-promo-section">
-        <view class="bundle-promo-row">
-          <text class="bundle-promo-icon">📦</text>
-          <view class="bundle-promo-content">
-            <text class="bundle-promo-title">订单套餐自动匹配,已享套餐价</text>
-            <view v-for="(b, idx) in (bundlePromo.bundleList || [])" :key="idx" class="bundle-promo-line">
-              <text class="bundle-promo-name">{{ b.bundleName }} × {{ b.times }}</text>
-              <text class="bundle-promo-save">省 {{ formatPriceWithSymbol(b.saving) }}</text>
-            </view>
-            <view class="bundle-promo-total">
-              <text class="bundle-promo-total-label">套餐优惠合计</text>
-              <text class="bundle-promo-discount">-{{ formatPriceWithSymbol(bundlePromoDiscount) }}</text>
-            </view>
-          </view>
-        </view>
-      </view>
-
       <!-- Coupon Section -->
       <view v-if="!isSpecialCouponDisabled" class="section coupon-section" @click="openCouponPicker">
         <view class="info-row">
@@ -504,7 +486,6 @@ import { BrandColors } from '@/utils/brand-colors.js'
 import config from '@/config.js'
 import { getCouponList, getCouponCheckoutList, addressList, getProductRequiredFields, getMyMembership, getGiftMembershipProgress, productDetail, getFamilyPlatformBalance, exchangeCouponByCf, shopDiscountCalculate, getSpecialPriceInfo, applyCoupon } from '../../../utils/api.js'
 import { parseDate } from '@/utils/format.js'
-import { bundleDiscountOf } from '@/utils/bundle-pricing.js'
 import CountdownTag from '@/pages/shop/components/countdown-tag.vue'
 import HandSignature from '@/components/hand-signature/hand-signature.vue'
 import fontScaleMixin from '@/mixins/font-scale.js'
@@ -557,7 +538,6 @@ export default {
       giftProgress: null,   // 赠会员累计进度
       discountInfo: null,
       giftPromo: null,       // 送会员一口价促销信息(已废弃,后端恒返回非激活)
-      bundlePromo: null,    // 订单套餐自动定价优惠(后端权威下发)
       taskId: null,          // 购买任务打卡:透传至支付页
       // 限时特价(扫码闪购):由 product-detail 经 storage 一次性带入,onLoad 消费后即清除
       checkoutSpecialDealId: null,
@@ -597,10 +577,6 @@ export default {
       if (!this.giftPromo || this.giftPromo.active !== true) return 0
       return this.giftPromo.discount || 0
     },
-    // 订单套餐自动定价优惠(后端权威),命中则展示与扣减
-    bundlePromoDiscount() {
-      return bundleDiscountOf(this.bundlePromo)
-    },
     // 限时特价(扫码闪购):特价单且活动禁止优惠券时隐藏优惠券选择区。
     // 活动信息未返回(加载中/失败)时默认视为不可用券(特价单保守处理)。
     isSpecialCouponDisabled() {
@@ -621,7 +597,6 @@ export default {
       total = total - (this.couponDiscount || 0)
       total = total - (this.pointsUsed || 0)
       total = total - this.giftPromoDiscount
-      total = total - this.bundlePromoDiscount
       if (total < 0) total = 0
       return total
     },
@@ -671,10 +646,8 @@ export default {
     var stored = uni.getStorageSync('checkoutItems')
     var giftIds = uni.getStorageSync('checkoutGiftItemIds')
     var giftPromo = uni.getStorageSync('checkoutGiftPromo')
-    var bundlePromo = uni.getStorageSync('checkoutBundlePromo')
     this.giftItemIds = (giftIds && giftIds.length > 0) ? giftIds : []
     this.giftPromo = giftPromo || null
-    this.bundlePromo = bundlePromo || null
     if (stored && stored.length > 0) {
       this.items = stored
       this.calcAmount()

+ 4 - 0
cfc-frontend/utils/api.js

@@ -606,6 +606,10 @@ export const getGiftMembershipProgress = () => {
 	return request('/api/membership/gift-membership-progress', 'POST')
 }
 
+// 购物车套餐offer(勾选子集检测)与「一键替换为套餐」
+export const queryComboOffer = (payload) => request('/api/cart/combo-offer', 'POST', payload)
+export const replaceCartWithCombo = (payload) => request('/api/cart/replace-combo', 'POST', payload)
+
 export const getAllQuotaStatus = () => {
 	return request('/api/basic-quota/status/all', 'POST')
 }

+ 27 - 33
cfc-frontend/utils/bundle-pricing.js

@@ -1,44 +1,38 @@
 /**
- * 订单套餐自动定价 · 前端展示辅助(纯函数)
+ * 购物车套餐offer展示辅助(纯函数)
  *
- * 权威计算在后端:购物车/下单金额均由后端按 product_bundle_items 定价行重算,
- * 前端仅消费后端下发的 bundlePromo 做展示与总价扣减,杜绝前端篡改优惠。
+ * 权威计算在后端:POST /api/cart/combo-offer 按勾选子集检测 offer,
+ * 前端仅消费后端下发的 comboOffer 做提示与「一键替换为套餐」,不做任何金额扣减。
  */
 
 /**
- * 安全读取套餐优惠金额(分):未命中/未激活/非法值一律返回 0。
- * @param {Object|null} promo 后端下发的 bundlePromo
- * @returns {number}
+ * 安全读取套餐offer:未命中/未激活/非法值一律返回 null。
+ * @param {Object|null} offer 后端下发的 comboOffer
+ * @returns {Object|null}
  */
-export function bundleDiscountOf(promo) {
-  if (!promo || promo.active !== true) return 0
-  const d = promo.discount
-  return typeof d === 'number' && d > 0 ? d : 0
+export function comboOfferOf(offer) {
+  if (!offer || offer.active !== true) return null
+  return offer
 }
 
 /**
- * 套餐命中明细 → 展示行(过滤脏数据、补齐默认值)。
- * @param {Array|null} bundleList
- * @returns {Array<{name: string, times: number, saving: number}>}
+ * 套餐offer提示文案:这N件组成套餐「XX」,套餐价 ¥Y(省 ¥Z)
+ * @param {Object|null} offer 后端下发的 comboOffer
+ * @param {number} [checkedCount] 勾选子集件数(后端响应未含,由前端传入)
+ * @returns {string}
  */
-export function formatBundleLines(bundleList) {
-  if (!Array.isArray(bundleList)) return []
-  return bundleList
-    .filter(h => h && h.bundleName)
-    .map(h => ({
-      name: h.bundleName,
-      times: (typeof h.times === 'number' && h.times > 0) ? h.times : 1,
-      saving: (typeof h.saving === 'number' && h.saving > 0) ? h.saving : 0
-    }))
-}
-
-/**
- * 对总价应用套餐优惠(安全扣减)。
- * @param {number} total 总价(分)
- * @param {Object|null} promo
- * @returns {number}
- */
-export function applyBundleDiscount(total, promo) {
-  const base = typeof total === 'number' ? total : 0
-  return base - bundleDiscountOf(promo)
+export function formatComboOfferText(offer, checkedCount) {
+  const o = comboOfferOf(offer)
+  if (!o) return ''
+  const n = (typeof checkedCount === 'number' && checkedCount > 0) ? checkedCount
+    : ((typeof o.groups === 'number' && o.groups > 0) ? o.groups : 0)
+  const name = o.comboName || ''
+  const price = (typeof o.comboPrice === 'number' && o.comboPrice >= 0) ? o.comboPrice : 0
+  const saving = (typeof o.saving === 'number' && o.saving > 0) ? o.saving : 0
+  let txt = ''
+  if (n) txt += n + ' 件'
+  if (name) txt += '组成套餐「' + name + '」'
+  txt += ',套餐价 ¥' + (price / 100).toFixed(2)
+  if (saving) txt += '(省 ¥' + (saving / 100).toFixed(2) + ')'
+  return txt
 }