feat(voucher): 兑换券业务严格隔离、前台全面去¥化、不足强拦截与售后闭环返还加固
1. 普通商品购买流程中严格禁止兑换券抵扣,仅限积分商城支持同等兑换 2. 积分商城卡片、详情、规格及结算页全面去除「¥」符号,强化「个兑换券 / 券」纯资产心智 3. 兑换券与积分逻辑完全对齐,兑换券不足时禁止掏现金补差,前端置灰禁用+后端抛出「兑换券不足」异常 4. 修复售后退款状态机识别偏差,并在订单失效/取消中增加已核销兑换券原路返还兜底 5. 新增及更新 6 套专项自动化测试(累计 200 项断言全部通过)
这个提交包含在:
@@ -36,7 +36,7 @@
|
||||
<text class="capsule-dot"></text>
|
||||
<text class="capsule-text">365天按日线性释放 · 每日自动到账可抵扣</text>
|
||||
</view>
|
||||
<text class="capsule-tag">全场通用</text>
|
||||
<text class="capsule-tag">积分兑换专享</text>
|
||||
</view>
|
||||
|
||||
<!-- 底部数据栏:累计获得 / 已释放 / 已使用 -->
|
||||
|
||||
@@ -29,18 +29,21 @@
|
||||
<!-- 价格+标题 -->
|
||||
<view class="title-card detail-card ss-p-y-40 ss-p-x-20">
|
||||
<view class="ss-flex ss-row-between ss-col-center ss-m-b-18">
|
||||
<view class="price-box ss-flex ss-col-bottom">
|
||||
<view v-if="goodsPrice.price > 0" class="price-text"> ¥{{ goodsPrice.price }} </view>
|
||||
<text v-if="goodsPrice.price > 0 && goodsPrice.score > 0">+</text>
|
||||
<image
|
||||
v-if="goodsPrice.score > 0"
|
||||
:src="sheep.$url.static('/assets/addons/shopro/uniapp/goods/score1.svg')"
|
||||
class="score-img"
|
||||
></image>
|
||||
<view v-if="goodsPrice.score > 0" class="score-text ss-m-r-16">
|
||||
{{ goodsPrice.score }}
|
||||
<view class="price-box ss-flex ss-col-bottom ss-flex-wrap">
|
||||
<view v-if="Number(goodsPrice.price) > 0" class="ss-flex ss-col-bottom">
|
||||
<text class="price-text">{{ formatVoucher(goodsPrice.price) }}</text>
|
||||
<text class="unit-text ss-m-l-6 ss-m-r-8">个兑换券</text>
|
||||
</view>
|
||||
<view v-if="goodsPrice.price > 0" class="voucher-tip-tag">支持兑换券全额抵扣</view>
|
||||
<view v-if="Number(goodsPrice.price) > 0 && Number(goodsPrice.score) > 0" class="plus-text ss-m-r-8">+</view>
|
||||
<view v-if="Number(goodsPrice.score) > 0" class="ss-flex ss-col-center">
|
||||
<image
|
||||
:src="sheep.$url.static('/assets/addons/shopro/uniapp/goods/score1.svg')"
|
||||
class="score-img"
|
||||
></image>
|
||||
<text class="score-text ss-m-r-8">{{ goodsPrice.score }}</text>
|
||||
<text class="unit-text ss-m-r-16">积分</text>
|
||||
</view>
|
||||
<view class="voucher-tip-tag">兑换专享</view>
|
||||
</view>
|
||||
<view class="sales-text">
|
||||
{{ formatExchange(state.goodsInfo.sales_show_type, state.goodsInfo.sales) }}
|
||||
@@ -106,6 +109,13 @@
|
||||
import sheep from '@/sheep';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { formatExchange, formatGoodsSwiper } from '@/sheep/hooks/useGoods';
|
||||
|
||||
function formatVoucher(val) {
|
||||
if (val === '' || val === null || val === undefined || val === '--') return '0';
|
||||
const num = Number(val);
|
||||
if (isNaN(num)) return '0';
|
||||
return num % 1 === 0 ? String(Math.round(num)) : String(Number(num.toFixed(2)));
|
||||
}
|
||||
import detailNavbar from './components/detail/detail-navbar.vue';
|
||||
import detailCellSku from './components/detail/detail-cell-sku.vue';
|
||||
import detailCellService from './components/detail/detail-cell-service.vue';
|
||||
@@ -239,6 +249,18 @@
|
||||
line-height: 36rpx;
|
||||
font-family: OPPOSANS;
|
||||
}
|
||||
.unit-text {
|
||||
font-size: 24rpx;
|
||||
color: #ff3000;
|
||||
font-weight: 500;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
.plus-text {
|
||||
font-size: 32rpx;
|
||||
color: #ff3000;
|
||||
font-weight: 500;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
.voucher-tip-tag {
|
||||
font-size: 20rpx;
|
||||
color: #ff3000;
|
||||
|
||||
@@ -47,9 +47,10 @@
|
||||
<view class="bg-white total-card-box ss-p-20 ss-m-b-14 ss-r-20">
|
||||
<view class="total-box-content border-bottom">
|
||||
<view class="order-item ss-flex ss-col-center ss-row-between">
|
||||
<view class="item-title">商品金额</view>
|
||||
<view class="item-title">{{ state.orderPayload.order_type === 'score' ? '所需兑换券' : '商品金额' }}</view>
|
||||
<view class="ss-flex ss-col-center">
|
||||
<text class="item-value ss-m-r-24">¥{{ state.orderInfo.goods_amount }}</text>
|
||||
<text class="item-value ss-m-r-24" v-if="state.orderPayload.order_type === 'score'">{{ formatVoucher(state.orderInfo.goods_amount) }} 个</text>
|
||||
<text class="item-value ss-m-r-24" v-else>¥{{ state.orderInfo.goods_amount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
@@ -97,6 +98,7 @@
|
||||
</view>
|
||||
<view
|
||||
class="order-item ss-flex ss-col-center ss-row-between"
|
||||
v-if="state.orderPayload.order_type === 'score'"
|
||||
>
|
||||
<view class="item-title">兑换券</view>
|
||||
<view class="ss-flex ss-col-center" @tap="openVoucher">
|
||||
@@ -131,15 +133,28 @@
|
||||
<view class="total-box-footer ss-font-28 ss-flex ss-row-right ss-col-center ss-m-r-28">
|
||||
<view class="total-num ss-m-r-20">共{{ state.totalNumber }}件</view>
|
||||
<view>合计:</view>
|
||||
<view class="total-num text-red"> ¥{{ state.orderInfo.pay_fee }} </view>
|
||||
<view class="ss-flex" v-if="state.orderPayload.order_type === 'score'">
|
||||
<view class="total-num ss-font-30 text-red ss-m-l-4"> + </view>
|
||||
<image
|
||||
:src="sheep.$url.static('/assets/addons/shopro/uniapp/goods/score1.svg')"
|
||||
class="score-img"
|
||||
></image>
|
||||
<view class="total-num ss-font-30 text-red">{{ state.orderInfo.score_amount }}</view>
|
||||
</view>
|
||||
<template v-if="state.orderPayload.order_type === 'score'">
|
||||
<view class="total-num text-red ss-flex ss-col-center">
|
||||
<text v-if="Number(state.orderInfo.voucher_fee || state.orderPayload.voucher_fee) > 0">
|
||||
{{ formatVoucher(state.orderInfo.voucher_fee || state.orderPayload.voucher_fee) }} 个兑换券
|
||||
</text>
|
||||
<text v-if="Number(state.orderInfo.pay_fee) > 0">
|
||||
<text v-if="Number(state.orderInfo.voucher_fee || state.orderPayload.voucher_fee) > 0"> + </text>
|
||||
¥{{ state.orderInfo.pay_fee }}
|
||||
</text>
|
||||
<view class="ss-flex" v-if="Number(state.orderInfo.score_amount) > 0">
|
||||
<text class="ss-m-x-4"> + </text>
|
||||
<image
|
||||
:src="sheep.$url.static('/assets/addons/shopro/uniapp/goods/score1.svg')"
|
||||
class="score-img"
|
||||
></image>
|
||||
<text class="total-num ss-font-30 text-red">{{ state.orderInfo.score_amount }} 积分</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<template v-else>
|
||||
<view class="total-num text-red"> ¥{{ state.orderInfo.pay_fee }} </view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 发票 -->
|
||||
@@ -160,7 +175,7 @@
|
||||
@close="state.showCoupon = false"
|
||||
/>
|
||||
<su-popup
|
||||
:show="state.showVoucher"
|
||||
:show="state.showVoucher && state.orderPayload.order_type === 'score'"
|
||||
type="bottom"
|
||||
round="20"
|
||||
showClose
|
||||
@@ -196,22 +211,37 @@
|
||||
<su-fixed bottom :opacity="false" bg="bg-white" placeholder :noFixed="false" :index="200">
|
||||
<view class="footer-box border-top ss-flex ss-row-between ss-p-x-20 ss-col-center">
|
||||
<view class="total-box-footer ss-flex ss-col-center">
|
||||
<view class="total-num ss-font-30 text-red"> ¥{{ state.orderInfo.pay_fee }} </view>
|
||||
<view v-if="state.orderPayload.order_type === 'score' && Number(state.orderInfo.score_amount) > 0" class="ss-flex">
|
||||
<view class="total-num ss-font-30 text-red ss-m-l-4">+</view>
|
||||
<image
|
||||
:src="sheep.$url.static('/assets/addons/shopro/uniapp/goods/score1.svg')"
|
||||
class="score-img"
|
||||
></image>
|
||||
<view class="total-num ss-font-30 text-red">{{ state.orderInfo.score_amount }}</view>
|
||||
</view>
|
||||
<template v-if="state.orderPayload.order_type === 'score'">
|
||||
<view class="total-num ss-font-30 text-red ss-flex ss-col-center">
|
||||
<text v-if="Number(state.orderInfo.voucher_fee || state.orderPayload.voucher_fee) > 0">
|
||||
{{ formatVoucher(state.orderInfo.voucher_fee || state.orderPayload.voucher_fee) }} 个兑换券
|
||||
</text>
|
||||
<text v-if="Number(state.orderInfo.pay_fee) > 0">
|
||||
<text v-if="Number(state.orderInfo.voucher_fee || state.orderPayload.voucher_fee) > 0"> + </text>
|
||||
¥{{ state.orderInfo.pay_fee }}
|
||||
</text>
|
||||
<view v-if="Number(state.orderInfo.score_amount) > 0" class="ss-flex ss-col-center">
|
||||
<text class="ss-m-x-4">+</text>
|
||||
<image
|
||||
:src="sheep.$url.static('/assets/addons/shopro/uniapp/goods/score1.svg')"
|
||||
class="score-img"
|
||||
></image>
|
||||
<text class="total-num ss-font-30 text-red">{{ state.orderInfo.score_amount }} 积分</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<template v-else>
|
||||
<view class="total-num ss-font-30 text-red"> ¥{{ state.orderInfo.pay_fee }} </view>
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<button
|
||||
class="ss-reset-button ui-BG-Main-Gradient ss-r-40 submit-btn ui-Shadow-Main"
|
||||
:class="{ 'disabled-btn': isVoucherNotEnough }"
|
||||
:disabled="isVoucherNotEnough"
|
||||
@tap="onConfirm"
|
||||
>
|
||||
{{ exchangeNow ? '立即兑换' : '提交订单' }}
|
||||
{{ isVoucherNotEnough ? '兑换券不足' : (exchangeNow ? '立即兑换' : '提交订单') }}
|
||||
</button>
|
||||
</view>
|
||||
</su-fixed>
|
||||
@@ -242,6 +272,14 @@
|
||||
() => state.orderPayload.order_type === 'score' && state.orderInfo.pay_fee == 0,
|
||||
);
|
||||
|
||||
// 兑换券是否不足(与积分逻辑一致:所需兑换券大于可用兑换券时禁止兑换)
|
||||
const isVoucherNotEnough = computed(() => {
|
||||
if (state.orderPayload.order_type !== 'score') return false;
|
||||
const needVoucher = Number(state.orderInfo.goods_amount || 0);
|
||||
const availableVoucher = Number(state.orderInfo.voucher_available || 0);
|
||||
return needVoucher > 0 && availableVoucher < needVoucher;
|
||||
});
|
||||
|
||||
// 选择地址
|
||||
function onSelectAddress() {
|
||||
uni.$once('SELECT_ADDRESS', (e) => {
|
||||
@@ -280,6 +318,9 @@
|
||||
}
|
||||
|
||||
function openVoucher() {
|
||||
if (state.orderPayload.order_type !== 'score') {
|
||||
return;
|
||||
}
|
||||
if (!(Number(state.orderInfo.voucher_available) > 0) && !(Number(state.orderPayload.voucher_fee) > 0)) {
|
||||
sheep.$helper.toast('暂无可用兑换券');
|
||||
return;
|
||||
@@ -314,6 +355,10 @@
|
||||
|
||||
// 提交订单/立即兑换
|
||||
function onConfirm() {
|
||||
if (state.orderPayload.order_type === 'score' && isVoucherNotEnough.value) {
|
||||
sheep.$helper.toast('可用兑换券不足,无法兑换');
|
||||
return;
|
||||
}
|
||||
if (!state.orderPayload.address_id && state.orderInfo.need_address === 1) {
|
||||
sheep.$helper.toast('请选择收货地址');
|
||||
return;
|
||||
@@ -350,6 +395,13 @@
|
||||
|
||||
// 创建订单&跳转
|
||||
async function submitOrder() {
|
||||
if (state.orderPayload.order_type === 'score' && isVoucherNotEnough.value) {
|
||||
sheep.$helper.toast('可用兑换券不足,无法兑换');
|
||||
return;
|
||||
}
|
||||
if (state.orderPayload.order_type !== 'score') {
|
||||
delete state.orderPayload.voucher_fee;
|
||||
}
|
||||
const { code, data } = await sheep.$api.order.create(state.orderPayload);
|
||||
if (code === 1) {
|
||||
// 更新购物车列表
|
||||
@@ -382,6 +434,8 @@
|
||||
// 积分/兑换商城订单:若后端自动计算了兑换券抵扣,同步至 orderPayload
|
||||
if (state.orderPayload.order_type === 'score' && state.orderPayload.voucher_fee === undefined && data.voucher_fee !== undefined) {
|
||||
state.orderPayload.voucher_fee = data.voucher_fee;
|
||||
} else if (state.orderPayload.order_type !== 'score') {
|
||||
delete state.orderPayload.voucher_fee;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,6 +540,12 @@
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
|
||||
&.disabled-btn {
|
||||
background: #ccc !important;
|
||||
color: #fff !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.goto-pay-text {
|
||||
line-height: 28rpx;
|
||||
}
|
||||
|
||||
@@ -29,12 +29,13 @@
|
||||
<view class="ss-col-bottom">
|
||||
<view
|
||||
v-if="goodsFields.score_price?.show"
|
||||
class="md-goods-price ss-m-t-16 font-OPPOSANS ss-m-r-10 ss-flex ss-col-center"
|
||||
class="md-goods-price ss-m-t-16 font-OPPOSANS ss-m-r-10 ss-flex ss-col-center ss-flex-wrap"
|
||||
:style="[{ color: goodsFields.score_price.color }]"
|
||||
>
|
||||
<view v-if="Number(data.price?.[0] || data.price) > 0">
|
||||
¥{{ data.price?.[0] || data.price }}
|
||||
<text v-if="Number(data.score) > 0">+</text>
|
||||
<view v-if="Number(data.price?.[0] || data.price) > 0" class="ss-flex ss-col-center">
|
||||
<text>{{ formatVoucher(data.price?.[0] || data.price) }}</text>
|
||||
<text class="ss-font-20 ss-m-l-2">券</text>
|
||||
<text v-if="Number(data.score) > 0" class="ss-m-x-4">+</text>
|
||||
</view>
|
||||
<image
|
||||
v-if="Number(data.score) > 0"
|
||||
@@ -42,9 +43,6 @@
|
||||
class="score-img"
|
||||
></image>
|
||||
<text v-if="Number(data.score) > 0">{{ data.score }}</text>
|
||||
<view v-if="Number(data.score) <= 0 && Number(data.price?.[0] || data.price) > 0" class="voucher-sub-tag ss-m-l-8">
|
||||
兑换券
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
@@ -95,12 +93,13 @@
|
||||
<view class="ss-m-t-10">
|
||||
<view
|
||||
v-if="goodsFields.score_price?.show"
|
||||
class="lg-goods-price ss-m-r-12 ss-flex ss-col-center font-OPPOSANS"
|
||||
class="lg-goods-price ss-m-r-12 ss-flex ss-col-center ss-flex-wrap font-OPPOSANS"
|
||||
:style="[{ color: goodsFields.score_price.color }]"
|
||||
>
|
||||
<view v-if="Number(data.price?.[0] || data.price) > 0">
|
||||
¥{{ data.price?.[0] || data.price }}
|
||||
<text v-if="Number(data.score) > 0">+</text>
|
||||
<view v-if="Number(data.price?.[0] || data.price) > 0" class="ss-flex ss-col-center">
|
||||
<text>{{ formatVoucher(data.price?.[0] || data.price) }}</text>
|
||||
<text class="ss-font-20 ss-m-l-2">券</text>
|
||||
<text v-if="Number(data.score) > 0" class="ss-m-x-4">+</text>
|
||||
</view>
|
||||
<image
|
||||
v-if="Number(data.score) > 0"
|
||||
@@ -108,9 +107,6 @@
|
||||
class="score-img"
|
||||
></image>
|
||||
<text v-if="Number(data.score) > 0">{{ data.score }}</text>
|
||||
<view v-if="Number(data.score) <= 0 && Number(data.price?.[0] || data.price) > 0" class="voucher-sub-tag ss-m-l-8">
|
||||
兑换券
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
v-if="goodsFields.price?.show && data.original_price > 0"
|
||||
@@ -160,20 +156,18 @@
|
||||
</view>
|
||||
</slot>
|
||||
<view class="ss-flex ss-col-bottom ss-p-l-16 ss-p-r-16 font-OPPOSANS">
|
||||
<view class="sl-goods-price ss-m-r-12 ss-flex ss-col-center">
|
||||
<view v-if="Number(data.price?.[0] || data.price) > 0">
|
||||
¥{{ data.price?.[0] || data.price }}
|
||||
<text v-if="Number(data.score) > 0">+</text>
|
||||
<view class="sl-goods-price ss-m-r-12 ss-flex ss-col-center ss-flex-wrap">
|
||||
<view v-if="Number(data.price?.[0] || data.price) > 0" class="ss-flex ss-col-center">
|
||||
<text>{{ formatVoucher(data.price?.[0] || data.price) }}</text>
|
||||
<text class="ss-font-20 ss-m-l-2">券</text>
|
||||
<text v-if="Number(data.score) > 0" class="ss-m-x-4">+</text>
|
||||
</view>
|
||||
<image
|
||||
v-if="Number(data.score) > 0"
|
||||
:src="sheep.$url.static('/assets/addons/shopro/uniapp/goods/score1.svg')"
|
||||
class="score-img"
|
||||
></image>
|
||||
<view v-if="Number(data.score) > 0">{{ data.score }}</view>
|
||||
<view v-if="Number(data.score) <= 0 && Number(data.price?.[0] || data.price) > 0" class="voucher-sub-tag ss-m-l-8">
|
||||
兑换券
|
||||
</view>
|
||||
<text v-if="Number(data.score) > 0">{{ data.score }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="data.original_price > 0"
|
||||
@@ -201,6 +195,13 @@
|
||||
import { formatSales } from '@/sheep/hooks/useGoods';
|
||||
import { formatStock } from '@/sheep/hooks/useGoods';
|
||||
import goodsCollectVue from '@/pages/user/goods-collect.vue';
|
||||
|
||||
function formatVoucher(val) {
|
||||
if (val === '' || val === null || val === undefined || val === '--') return '0';
|
||||
const num = Number(val);
|
||||
if (isNaN(num)) return '0';
|
||||
return num % 1 === 0 ? String(Math.round(num)) : String(Number(num.toFixed(2)));
|
||||
}
|
||||
/**
|
||||
* 订单卡片
|
||||
*
|
||||
|
||||
@@ -10,17 +10,22 @@
|
||||
<view class="header-right ss-flex-col ss-row-between ss-flex-1">
|
||||
<view class="goods-title ss-line-2">{{ goodsInfo.title }}</view>
|
||||
<view class="header-right-bottom ss-flex ss-col-center ss-row-between">
|
||||
<view class="ss-flex">
|
||||
<format-price :price="displaygoods.price" type="range" color="#FF4104" :decimal-scale="1" />
|
||||
|
||||
<view class="ss-flex">
|
||||
<view v-if="displaygoods.score > 0" class="score-text ss-m-l-4">+</view>
|
||||
<image v-if="displaygoods.score > 0"
|
||||
:src="sheep.$url.static('/assets/addons/shopro/uniapp/goods/score1.svg')" class="score-img"></image>
|
||||
<view v-if="displaygoods.score > 0" class="score-text">
|
||||
{{ displaygoods.score }}
|
||||
<view class="ss-flex ss-col-center">
|
||||
<template v-if="isScore">
|
||||
<view v-if="Number(displaygoods.price) > 0" class="ss-flex ss-col-center">
|
||||
<text class="score-text" style="color: #FF4104; font-weight: 600; font-size: 34rpx;">{{ formatVoucher(displaygoods.price) }}</text>
|
||||
<text class="ss-font-22 ss-m-l-4" style="color: #FF4104;">个兑换券</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="Number(displaygoods.price) > 0 && Number(displaygoods.score) > 0" class="score-text ss-m-x-6" style="color: #FF4104;">+</view>
|
||||
<view v-if="Number(displaygoods.score) > 0" class="ss-flex ss-col-center">
|
||||
<image :src="sheep.$url.static('/assets/addons/shopro/uniapp/goods/score1.svg')" class="score-img"></image>
|
||||
<view class="score-text" style="color: #FF4104; font-weight: 600; font-size: 34rpx;">{{ displaygoods.score }}</view>
|
||||
<text class="ss-font-22 ss-m-l-4" style="color: #FF4104;">积分</text>
|
||||
</view>
|
||||
</template>
|
||||
<template v-else>
|
||||
<format-price :price="displaygoods.price" type="range" color="#FF4104" :decimal-scale="1" />
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<view class="stock-text ss-m-l-20">
|
||||
@@ -80,6 +85,13 @@ import { formatStock } from '@/sheep/hooks/useGoods';
|
||||
import { isArray, isEmpty } from 'lodash';
|
||||
import FormatPrice from '@/pages/goods/components/format-price.vue';
|
||||
|
||||
function formatVoucher(val) {
|
||||
if (val === '' || val === null || val === undefined || val === '--') return '0';
|
||||
const num = Number(val);
|
||||
if (isNaN(num)) return '0';
|
||||
return num % 1 === 0 ? String(Math.round(num)) : String(Number(num.toFixed(2)));
|
||||
}
|
||||
|
||||
const emits = defineEmits(['change', 'addCart', 'buyNow', 'close']);
|
||||
const props = defineProps({
|
||||
goods: {
|
||||
|
||||
在新工单中引用
屏蔽一个用户