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

feat(bundle): 前端购物车/结算展示套餐优惠,提交 discountAmount 去掉一口价\n\n- utils/bundle-pricing.js 纯函数辅助(安全取值/格式化/扣减) + 9 组 jest 测试\n- cart.vue: 读取 bundlePromo、totalAmount/estimatedTotalAmount 扣套餐优惠、storage 存 checkoutBundlePromo、模板套餐提示区\n- checkout.vue: 读 checkoutBundlePromo、bundlePromoDiscount、finalAmount 扣减、提交 discountAmount 仅规则优惠(后端权威重算套餐)\n- 送会员一口价(giftPromo)页面逻辑保留(后端恒返回非激活,过渡兼容)

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

+ 58 - 0
cfc-frontend/__tests__/utils/bundle-pricing.test.js

@@ -0,0 +1,58 @@
+/**
+ * Tests for cfc-frontend utils/bundle-pricing.js
+ *
+ * 验证套餐优惠的纯函数语义:未命中/未激活 → 0;active=true + discount>0 才生效。
+ */
+const {
+  bundleDiscountOf,
+  formatBundleLines,
+  applyBundleDiscount
+} = require('@/utils/bundle-pricing')
+
+describe('bundleDiscountOf', () => {
+  it('null/undefined → 0', () => {
+    expect(bundleDiscountOf(null)).toBe(0)
+    expect(bundleDiscountOf(undefined)).toBe(0)
+  })
+  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 + 正折扣 → 折扣值', () => {
+    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)
+  })
+})
+
+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)
+  })
+})

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

@@ -107,6 +107,22 @@
       <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>
@@ -142,6 +158,7 @@
 <script>
 import config from '@/config.js'
 import { getMyMembership, getGiftMembershipProgress } from '@/utils/api.js'
+import { applyBundleDiscount } from '@/utils/bundle-pricing.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
@@ -155,7 +172,8 @@ export default {
       taskId: null,
       isMember: false,
       giftProgress: null,  // 赠会员累计进度
-      giftPromo: null      // 送会员一口价促销信息
+      giftPromo: null,     // 送会员一口价促销信息(已废弃,后端恒返回非激活)
+      bundlePromo: null    // 订单套餐自动定价优惠(后端权威下发)
     }
   },
   computed: {
@@ -174,16 +192,14 @@ export default {
       return count
     },
     totalAmount() {
-      if (this.giftPromo && this.giftPromo.active === true && this.giftPromo.discount > 0) {
-        return this.originalTotalAmount - this.giftPromo.discount
-      }
       var total = 0
       for (var i = 0; i < this.items.length; i++) {
         if (this.items[i].checked) {
           total = total + (this.items[i].subtotal || this.items[i].unitPrice * this.items[i].quantity)
         }
       }
-      return total
+      // 套餐自动定价优惠(后端权威),命中则扣减
+      return applyBundleDiscount(total, this.bundlePromo)
     },
     originalTotalAmount() {
       var total = 0
@@ -204,18 +220,11 @@ export default {
           if (this.isMember && this.items[i].memberPrice && this.items[i].memberPrice > 0 && this.items[i].memberPrice < price) {
             price = this.items[i].memberPrice
           }
-          // 送会员一口价折扣:若达标,从总价中减去 discount
-          if (this.giftPromo && this.giftPromo.active === true && this.giftPromo.discount > 0 && this.items[i].giftLevelName) {
-            // 不在此处单独扣除,由 totalAmount() 已在 top 层减去;这里仅作备选兼容
-          }
           total = total + (price * this.items[i].quantity)
         }
       }
-      // 若 giftPromo 达标,从总价中减去 discount
-      if (this.giftPromo && this.giftPromo.active === true && this.giftPromo.discount > 0) {
-        total = total - this.giftPromo.discount
-      }
-      return total
+      // 套餐自动定价优惠(后端权威),命中则扣减
+      return applyBundleDiscount(total, this.bundlePromo)
     },
     hasGiftItems() {
       for (var i = 0; i < this.items.length; i++) {
@@ -314,6 +323,7 @@ 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()
           }
         },
@@ -422,6 +432,7 @@ 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) {

+ 30 - 2
cfc-frontend/pages/shop/checkout/checkout.vue

@@ -238,6 +238,24 @@
         </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">
@@ -486,6 +504,7 @@ 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'
@@ -537,7 +556,8 @@ export default {
       giftItemIds: [],
       giftProgress: null,   // 赠会员累计进度
       discountInfo: null,
-      giftPromo: null,       // 送会员一口价促销信息
+      giftPromo: null,       // 送会员一口价促销信息(已废弃,后端恒返回非激活)
+      bundlePromo: null,    // 订单套餐自动定价优惠(后端权威下发)
       taskId: null,          // 购买任务打卡:透传至支付页
       // 限时特价(扫码闪购):由 product-detail 经 storage 一次性带入,onLoad 消费后即清除
       checkoutSpecialDealId: null,
@@ -577,6 +597,10 @@ export default {
       if (!this.giftPromo || this.giftPromo.active !== true) return 0
       return this.giftPromo.discount || 0
     },
+    // 订单套餐自动定价优惠(后端权威),命中则展示与扣减
+    bundlePromoDiscount() {
+      return bundleDiscountOf(this.bundlePromo)
+    },
     // 限时特价(扫码闪购):特价单且活动禁止优惠券时隐藏优惠券选择区。
     // 活动信息未返回(加载中/失败)时默认视为不可用券(特价单保守处理)。
     isSpecialCouponDisabled() {
@@ -597,6 +621,7 @@ 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
     },
@@ -646,8 +671,10 @@ 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()
@@ -1149,7 +1176,8 @@ export default {
           addressId: that.selectedAddress.id,
           purchaseInfo: that.purchaseForm,
           pointsUsed: that.pointsUsed,
-          discountAmount: (that.discountInfo ? that.discountInfo.discountAmount : 0) + that.giftPromoDiscount,
+          // discountAmount 仅含规则优惠(不含套餐/一口价):套餐优惠由服务端权威重算计入
+          discountAmount: (that.discountInfo ? that.discountInfo.discountAmount : 0),
           couponId: that.selectedCoupon ? that.selectedCoupon.id : null,
           // 特价单透传(无则传 null):后端 createSpecialDealOrder 校验活动后按特价结算
           specialDealId: that.checkoutSpecialDealId || null,

+ 44 - 0
cfc-frontend/utils/bundle-pricing.js

@@ -0,0 +1,44 @@
+/**
+ * 订单套餐自动定价 · 前端展示辅助(纯函数)
+ *
+ * 权威计算在后端:购物车/下单金额均由后端按 product_bundle_items 定价行重算,
+ * 前端仅消费后端下发的 bundlePromo 做展示与总价扣减,杜绝前端篡改优惠。
+ */
+
+/**
+ * 安全读取套餐优惠金额(分):未命中/未激活/非法值一律返回 0。
+ * @param {Object|null} promo 后端下发的 bundlePromo
+ * @returns {number}
+ */
+export function bundleDiscountOf(promo) {
+  if (!promo || promo.active !== true) return 0
+  const d = promo.discount
+  return typeof d === 'number' && d > 0 ? d : 0
+}
+
+/**
+ * 套餐命中明细 → 展示行(过滤脏数据、补齐默认值)。
+ * @param {Array|null} bundleList
+ * @returns {Array<{name: string, times: number, saving: number}>}
+ */
+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)
+}