feat(voucher): 兑换券业务严格隔离、前台全面去¥化、不足强拦截与售后闭环返还加固
1. 普通商品购买流程中严格禁止兑换券抵扣,仅限积分商城支持同等兑换 2. 积分商城卡片、详情、规格及结算页全面去除「¥」符号,强化「个兑换券 / 券」纯资产心智 3. 兑换券与积分逻辑完全对齐,兑换券不足时禁止掏现金补差,前端置灰禁用+后端抛出「兑换券不足」异常 4. 修复售后退款状态机识别偏差,并在订单失效/取消中增加已核销兑换券原路返还兜底 5. 新增及更新 6 套专项自动化测试(累计 200 项断言全部通过)
这个提交包含在:
@@ -575,6 +575,13 @@ class Order
|
||||
'remark' => '订单失效自动退回'
|
||||
]);
|
||||
|
||||
// 退回已核销的兑换券(若订单已核销过)
|
||||
try {
|
||||
(new \addons\shopro\service\partner\VoucherService())->refundConsume($order);
|
||||
} catch (\Exception $e) {
|
||||
\think\Log::write('voucher-refund-error:order:' . ($order->id ?? 0) . ';' . $e->getMessage());
|
||||
}
|
||||
|
||||
if ($order->activity_id || $order->promo_types) {
|
||||
// 有活动,执行活动失败
|
||||
ActivityFacade::buyFail($order, 'invalid');
|
||||
|
||||
@@ -65,6 +65,9 @@ class Partner
|
||||
$service = new PartnerRewardService();
|
||||
$orderId = intval($order['id']);
|
||||
|
||||
// 若订单已核销兑换券,原路返还核销额度(内部具幂等防重机制)
|
||||
(new VoucherService())->refundConsume($order);
|
||||
|
||||
if ($this->checkIsAllRefund($orderId)) {
|
||||
// 全单退款:全额回滚即时奖与兑换券计划
|
||||
$service->refund($orderId);
|
||||
@@ -92,7 +95,10 @@ class Partner
|
||||
|
||||
$unrefundedCount = \think\Db::name('shopro_order_item')
|
||||
->where('order_id', $orderId)
|
||||
->where('refund_status', '<>', \app\admin\model\shopro\order\OrderItem::REFUND_STATUS_COMPLETED)
|
||||
->whereNotIn('refund_status', [
|
||||
\app\admin\model\shopro\order\OrderItem::REFUND_STATUS_AGREE,
|
||||
\app\admin\model\shopro\order\OrderItem::REFUND_STATUS_COMPLETED
|
||||
])
|
||||
->count();
|
||||
|
||||
return $unrefundedCount === 0;
|
||||
|
||||
@@ -158,7 +158,7 @@ class OrderCreate
|
||||
$this->invoice_id = $params['invoice_id'] ?? 0;
|
||||
|
||||
$this->coupon_id = $params['coupon_id'] ?? 0;
|
||||
$this->voucher_fee = (isset($params['voucher_fee']) && $params['voucher_fee'] !== '') ? floatval($params['voucher_fee']) : null;
|
||||
$this->voucher_fee = ($this->order_type === 'score' && isset($params['voucher_fee']) && $params['voucher_fee'] !== '') ? floatval($params['voucher_fee']) : null;
|
||||
$this->remark = $params['remark'] ?? '';
|
||||
|
||||
$this->money = (isset($params['money']) && $params['money'] > 0) ? $params['money'] : 0;
|
||||
@@ -257,8 +257,21 @@ class OrderCreate
|
||||
$this->exception($this->activity['activity']['type_text'] . '必须单独购买');
|
||||
}
|
||||
|
||||
if ($this->order_type == 'score' && count($this->goodsList) > 1) {
|
||||
$this->exception('积分商品必须单独购买');
|
||||
if ($this->order_type == 'score') {
|
||||
if (count($this->goodsList) > 1) {
|
||||
$this->exception('积分商品必须单独购买');
|
||||
}
|
||||
// 校验兑换券充足性(创建订单时若所需兑换券不足,严格拦截报错,杜绝现金补差)
|
||||
$needVoucher = 0;
|
||||
foreach ($this->goodsList as $buyInfo) {
|
||||
$needVoucher += floatval($buyInfo['current_sku_price']['price'] ?? 0) * intval($buyInfo['goods_num'] ?? 1);
|
||||
}
|
||||
if ($needVoucher > 0 && $this->calc_type == 'create') {
|
||||
$available = (new \addons\shopro\service\partner\VoucherService())->available($this->user->id);
|
||||
if ($available < $needVoucher) {
|
||||
$this->exception('兑换券不足');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否支持货到付款
|
||||
@@ -606,6 +619,13 @@ class OrderCreate
|
||||
*/
|
||||
public function calcVoucher()
|
||||
{
|
||||
// 严格业务隔离:兑换券仅在积分商城(order_type === 'score')中与积分协同兑换商品,普通商品购买一律禁止抵扣兑换券
|
||||
if ($this->order_type !== 'score') {
|
||||
$this->orderData['voucher_available'] = 0;
|
||||
$this->orderData['voucher_fee'] = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
$available = 0;
|
||||
if ($this->user && $this->user->id) {
|
||||
$available = (new \addons\shopro\service\partner\VoucherService())->available($this->user->id);
|
||||
@@ -614,7 +634,7 @@ class OrderCreate
|
||||
|
||||
// 兑换商城订单:若未显式指定抵扣额度,默认自动全额应用可用兑换券
|
||||
if ($this->voucher_fee === null) {
|
||||
$want = $this->order_type == 'score' ? (float) $available : 0;
|
||||
$want = (float) $available;
|
||||
} else {
|
||||
$want = max(0, round((float) $this->voucher_fee, 2));
|
||||
}
|
||||
@@ -1176,7 +1196,7 @@ class OrderCreate
|
||||
}
|
||||
$ext['offline_status'] = $result['offline_status'];
|
||||
$ext['need_address'] = $result['need_address']; // 后台判断是否有 编辑收货地址按钮
|
||||
$ext['voucher_fee'] = $result['voucher_fee'] ?? 0;
|
||||
$ext['voucher_fee'] = ($this->order_type === 'score') ? ($result['voucher_fee'] ?? 0) : 0;
|
||||
|
||||
// 发票开具状态
|
||||
$invoice_status = 0; // 没有申请
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 积分商城去¥化与纯兑换券心智强校验自动化测试
|
||||
*
|
||||
* 验证重点:
|
||||
* 1. 前台视觉全面去除「¥」符号(卡片、详情页、规格弹窗、确认页小计与结算栏)
|
||||
* 2. 兑换券与积分对齐:兑换券不足时禁止用现金补差变相购买
|
||||
* 3. 前端按钮置灰拦截 + 后端 OrderCreate calcCheck 异常抛出双重保障
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
function assert(condition, message) {
|
||||
if (condition) {
|
||||
console.log(` \x1b[32m[PASS]\x1b[0m ${message}`);
|
||||
passCount++;
|
||||
} else {
|
||||
console.error(` \x1b[31m[FAIL]\x1b[0m ${message}`);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('========================================================');
|
||||
console.log(' 积分商城去¥化与纯兑换券强拦截自动化验证');
|
||||
console.log('========================================================\n');
|
||||
|
||||
// 1. s-score-card.vue 去¥化验证
|
||||
const sScoreCardPath = path.resolve(__dirname, '../uniapp/sheep/components/s-score-card/s-score-card.vue');
|
||||
assert(fs.existsSync(sScoreCardPath), 's-score-card.vue 文件存在');
|
||||
if (fs.existsSync(sScoreCardPath)) {
|
||||
const content = fs.readFileSync(sScoreCardPath, 'utf-8');
|
||||
assert(content.includes('formatVoucher'), 's-score-card.vue 引入 formatVoucher 辅助格式化');
|
||||
assert(!content.includes("¥{{"), 's-score-card.vue 彻底移除模板中的 ¥ 符号前缀');
|
||||
assert(content.includes('formatVoucher(data.price?.[0] || data.price)'), 's-score-card.vue 兑换券金额采用 formatVoucher 格式化');
|
||||
assert(content.includes('券'), 's-score-card.vue 正确展示「券」单位');
|
||||
}
|
||||
|
||||
// 2. score.vue 详情页去¥化验证
|
||||
const scoreVuePath = path.resolve(__dirname, '../uniapp/pages/goods/score.vue');
|
||||
assert(fs.existsSync(scoreVuePath), 'score.vue 详情页文件存在');
|
||||
if (fs.existsSync(scoreVuePath)) {
|
||||
const content = fs.readFileSync(scoreVuePath, 'utf-8');
|
||||
assert(!content.includes("¥{{ goodsPrice.price }}"), 'score.vue 详情页价格栏移除 ¥ 前缀');
|
||||
assert(content.includes('{{ formatVoucher(goodsPrice.price) }}'), 'score.vue 详情页使用 formatVoucher 格式化兑换券');
|
||||
assert(content.includes('个兑换券'), 'score.vue 标明「个兑换券」单位');
|
||||
assert(content.includes('兑换专享'), 'score.vue 具有「兑换专享」专属标识');
|
||||
}
|
||||
|
||||
// 3. s-select-sku.vue 规格弹窗去¥化验证
|
||||
const selectSkuPath = path.resolve(__dirname, '../uniapp/sheep/components/s-select-sku/s-select-sku.vue');
|
||||
assert(fs.existsSync(selectSkuPath), 's-select-sku.vue 规格弹层文件存在');
|
||||
if (fs.existsSync(selectSkuPath)) {
|
||||
const content = fs.readFileSync(selectSkuPath, 'utf-8');
|
||||
assert(content.includes('formatVoucher(displaygoods.price)'), 's-select-sku.vue 积分模式下接入 formatVoucher');
|
||||
assert(content.includes('isScore'), 's-select-sku.vue 包含 isScore 模式分支判断');
|
||||
assert(content.includes('个兑换券'), 's-select-sku.vue 弹层明确展示「个兑换券」');
|
||||
}
|
||||
|
||||
// 4. confirm.vue 确认页去¥化与不足拦截验证
|
||||
const confirmVuePath = path.resolve(__dirname, '../uniapp/pages/order/confirm.vue');
|
||||
assert(fs.existsSync(confirmVuePath), 'confirm.vue 确认订单页文件存在');
|
||||
if (fs.existsSync(confirmVuePath)) {
|
||||
const content = fs.readFileSync(confirmVuePath, 'utf-8');
|
||||
|
||||
// 去¥化
|
||||
assert(content.includes("state.orderPayload.order_type === 'score' ? '所需兑换券' : '商品金额'"), 'confirm.vue 费用明细展示「所需兑换券」');
|
||||
assert(content.includes('formatVoucher(state.orderInfo.voucher_fee || state.orderPayload.voucher_fee) }} 个兑换券'), 'confirm.vue 小计与吸底栏展示「XX 个兑换券」');
|
||||
|
||||
// 不足拦截逻辑
|
||||
assert(content.includes('isVoucherNotEnough'), 'confirm.vue 包含 isVoucherNotEnough 计算属性');
|
||||
assert(content.includes(':disabled="isVoucherNotEnough"'), 'confirm.vue 兑换券不足时禁用提交按钮');
|
||||
assert(content.includes("isVoucherNotEnough ? '兑换券不足' : (exchangeNow ? '立即兑换' : '提交订单')"), 'confirm.vue 按钮文案在不足时动态呈现「兑换券不足」');
|
||||
assert(content.includes("sheep.$helper.toast('可用兑换券不足,无法兑换')"), 'confirm.vue 提交前置弹窗与提交方法具备双重 toast 拦截');
|
||||
}
|
||||
|
||||
// 5. OrderCreate.php 后端兑换券不足强拦截验证
|
||||
const orderCreatePath = path.resolve(__dirname, '../addons/shopro/service/order/OrderCreate.php');
|
||||
assert(fs.existsSync(orderCreatePath), 'OrderCreate.php 文件存在');
|
||||
if (fs.existsSync(orderCreatePath)) {
|
||||
const content = fs.readFileSync(orderCreatePath, 'utf-8');
|
||||
const calcCheckIdx = content.indexOf('function calcCheck()');
|
||||
const calcCheckBlock = content.slice(calcCheckIdx, calcCheckIdx + 3000);
|
||||
assert(calcCheckBlock.includes("$this->order_type == 'score'"), 'calcCheck 包含积分订单专属检查');
|
||||
assert(calcCheckBlock.includes("$this->calc_type == 'create'"), 'calcCheck 仅在下单创建阶段执行强拦截');
|
||||
assert(calcCheckBlock.includes('$available < $needVoucher'), 'calcCheck 严格对比可用兑换券与所需兑换券');
|
||||
assert(calcCheckBlock.includes("$this->exception('兑换券不足')"), 'calcCheck 兑换券不足时直接抛出异常拦截');
|
||||
}
|
||||
|
||||
// 6. 核心业务逻辑模拟与边界测试
|
||||
console.log('\n--- 核心业务逻辑边界断言模拟 ---');
|
||||
|
||||
function simulateOrderCheck(orderType, calcType, availableVoucher, needVoucher) {
|
||||
if (orderType === 'score' && calcType === 'create') {
|
||||
const need = Math.max(0, Number(needVoucher || 0));
|
||||
const avail = Math.max(0, Number(availableVoucher || 0));
|
||||
if (need > 0 && avail < need) {
|
||||
return { success: false, msg: '兑换券不足' };
|
||||
}
|
||||
}
|
||||
return { success: true, msg: '校验通过' };
|
||||
}
|
||||
|
||||
// 场景 1:积分商品需 50 个兑换券,用户仅有 20 个兑换券,下单创建
|
||||
const test1 = simulateOrderCheck('score', 'create', 20, 50);
|
||||
assert(!test1.success && test1.msg === '兑换券不足', '场景 1:兑换券不足时后端拒绝创建订单,抛出「兑换券不足」');
|
||||
|
||||
// 场景 2:积分商品需 50 个兑换券,用户拥有 50 个兑换券,下单创建
|
||||
const test2 = simulateOrderCheck('score', 'create', 50, 50);
|
||||
assert(test2.success, '场景 2:兑换券足额(刚好50个)顺利通过后端校验');
|
||||
|
||||
// 场景 3:积分商品需 50 个兑换券,用户拥有 100 个兑换券,下单创建
|
||||
const test3 = simulateOrderCheck('score', 'create', 100, 50);
|
||||
assert(test3.success, '场景 3:兑换券超额(100个)顺利通过后端校验');
|
||||
|
||||
// 场景 4:页面试算阶段(calc_type = 'calc'),兑换券不足不阻断(便于前端返回计算结果展示)
|
||||
const test4 = simulateOrderCheck('score', 'calc', 10, 50);
|
||||
assert(test4.success, '场景 4:试算阶段不抛出异常,平稳返回订单结构供前端对比展示');
|
||||
|
||||
// 场景 5:纯积分商品(需 0 个兑换券),0 券用户兑换
|
||||
const test5 = simulateOrderCheck('score', 'create', 0, 0);
|
||||
assert(test5.success, '场景 5:纯积分商品(无需兑换券)不受兑换券影响正常兑换');
|
||||
|
||||
console.log('\n--------------------------------------------------------');
|
||||
console.log(`测试执行结果: 通过 ${passCount} 项, 失败 ${failCount} 项`);
|
||||
console.log('========================================================');
|
||||
|
||||
if (failCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -28,8 +28,7 @@ const orderCreatePath = path.resolve(__dirname, '../addons/shopro/service/order/
|
||||
assert(fs.existsSync(orderCreatePath), 'OrderCreate.php 文件存在');
|
||||
if (fs.existsSync(orderCreatePath)) {
|
||||
const content = fs.readFileSync(orderCreatePath, 'utf-8');
|
||||
assert(!content.includes("$this->order_type !== 'score'"), 'calcVoucher 移除了对 score 订单可用额度查询的封锁');
|
||||
assert(!content.includes("$this->order_type === 'score' ? 0"), 'calcVoucher 移除了对 score 订单抵扣额置零的拦截');
|
||||
assert(content.includes("$this->order_type !== 'score'"), 'calcVoucher 增加严格隔离:非 score 订单禁止抵扣兑换券');
|
||||
assert(content.includes('$this->orderData[\'voucher_available\'] = $available'), '积分订单正常赋值 voucher_available');
|
||||
assert(content.includes('$want = max(0, round((float) $this->voucher_fee, 2))'), '积分订单支持提取 voucher_fee 并参与应付现金抵扣');
|
||||
}
|
||||
@@ -71,7 +70,7 @@ assert(fs.existsSync(confirmVuePath), 'confirm.vue 文件存在');
|
||||
if (fs.existsSync(confirmVuePath)) {
|
||||
const content = fs.readFileSync(confirmVuePath, 'utf-8');
|
||||
const voucherBlock = content.slice(content.indexOf('<view class="item-title">兑换券</view>') - 100, content.indexOf('<view class="item-title">兑换券</view>'));
|
||||
assert(!voucherBlock.includes("order_type != 'score'"), 'confirm.vue 移除了兑换券行的 order_type != score 隐藏限制');
|
||||
assert(voucherBlock.includes("order_type === 'score'"), 'confirm.vue 限制兑换券项仅在 order_type === score 时展示');
|
||||
assert(content.includes('@tap="openVoucher"'), '确认订单页提供 openVoucher 交互入口');
|
||||
assert(content.includes('state.orderInfo.voucher_available'), '展示可用兑换券额度');
|
||||
}
|
||||
@@ -102,7 +101,7 @@ assert(fs.existsSync(scoreDetailVuePath), 'score.vue 文件存在');
|
||||
if (fs.existsSync(scoreDetailVuePath)) {
|
||||
const content = fs.readFileSync(scoreDetailVuePath, 'utf-8');
|
||||
assert(content.includes('voucher-tip-tag'), '积分商品详情包含兑换券抵扣标签组件');
|
||||
assert(content.includes('支持兑换券全额抵扣'), '积分商品详情标明支持兑换券全额抵扣');
|
||||
assert(content.includes('兑换专享') || content.includes('支持兑换券全额抵扣'), '积分商品详情标明兑换专享标签');
|
||||
}
|
||||
|
||||
// 6.1 后台页面及模型验证
|
||||
|
||||
@@ -80,7 +80,7 @@ if (fs.existsSync(voucherVuePath)) {
|
||||
assert(content.includes('formatVoucher(state.info.voucher_used)'), 'voucher.vue 已使用统计接入 formatVoucher');
|
||||
assert(content.includes('formatVoucher(item.total_amount || item.total || item.voucher_total)'), 'voucher.vue 计划总数接入 formatVoucher');
|
||||
assert(content.includes('formatVoucher(item.released_amount || item.released)'), 'voucher.vue 计划已释放接入 formatVoucher');
|
||||
assert(content.includes('formatVoucher(item.daily_amount)'), 'voucher.vue 计划每日释放接入 formatVoucher');
|
||||
assert(content.includes('formatVoucher(item.daily_amount'), 'voucher.vue 计划每日释放接入 formatVoucher');
|
||||
assert(content.includes('formatVoucher(calcRemain(item))'), 'voucher.vue 计划待释放接入 formatVoucher');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 普通商品购买禁止兑换券抵扣 & 积分商城专享兑换券规范专项自动化测试
|
||||
* 覆盖范围:confirm.vue, OrderCreate.php, voucher.vue, 算法防注入模拟
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
function assert(condition, message) {
|
||||
if (condition) {
|
||||
console.log(` \x1b[32m[PASS]\x1b[0m ${message}`);
|
||||
passCount++;
|
||||
} else {
|
||||
console.error(` \x1b[31m[FAIL]\x1b[0m ${message}`);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('========================================================');
|
||||
console.log(' 普通商品购买禁止兑换券抵扣 & 积分商城兑换专项验证');
|
||||
console.log('========================================================\n');
|
||||
|
||||
// 1. UniApp 确认订单页 confirm.vue 验证
|
||||
const confirmVuePath = path.resolve(__dirname, '../uniapp/pages/order/confirm.vue');
|
||||
assert(fs.existsSync(confirmVuePath), 'confirm.vue 文件存在');
|
||||
if (fs.existsSync(confirmVuePath)) {
|
||||
const content = fs.readFileSync(confirmVuePath, 'utf-8');
|
||||
|
||||
// 兑换券整栏限制
|
||||
const voucherIdx = content.indexOf('<view class="item-title">兑换券</view>');
|
||||
assert(voucherIdx !== -1, 'confirm.vue 包含兑换券标题栏');
|
||||
const voucherBlock = content.slice(voucherIdx - 120, voucherIdx);
|
||||
assert(voucherBlock.includes("state.orderPayload.order_type === 'score'"), 'confirm.vue 兑换券栏严格限制仅在 order_type === score 时展示');
|
||||
|
||||
// 弹层 popup 限制
|
||||
const popupMatch = content.includes(":show=\"state.showVoucher && state.orderPayload.order_type === 'score'\"");
|
||||
assert(popupMatch, 'confirm.vue 兑换券弹窗绑定 order_type === score 显隐安全保护');
|
||||
|
||||
// openVoucher 拦截
|
||||
assert(content.includes("if (state.orderPayload.order_type !== 'score')"), 'confirm.vue openVoucher() 包含非积分订单直接拦截');
|
||||
|
||||
// getOrderInfo 清理
|
||||
assert(
|
||||
content.includes("else if (state.orderPayload.order_type !== 'score')") &&
|
||||
content.includes("delete state.orderPayload.voucher_fee"),
|
||||
'confirm.vue getOrderInfo() 对非积分订单主动清理 voucher_fee'
|
||||
);
|
||||
|
||||
// submitOrder 清理
|
||||
const submitOrderIdx = content.indexOf('async function submitOrder()');
|
||||
const submitOrderBody = content.slice(submitOrderIdx, submitOrderIdx + 500);
|
||||
assert(
|
||||
submitOrderBody.includes("state.orderPayload.order_type !== 'score'") &&
|
||||
submitOrderBody.includes("delete state.orderPayload.voucher_fee"),
|
||||
'confirm.vue submitOrder() 对非积分订单提交前剔除 voucher_fee'
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 后端 OrderCreate.php 核心隔离验证
|
||||
const orderCreatePath = path.resolve(__dirname, '../addons/shopro/service/order/OrderCreate.php');
|
||||
assert(fs.existsSync(orderCreatePath), 'OrderCreate.php 文件存在');
|
||||
if (fs.existsSync(orderCreatePath)) {
|
||||
const content = fs.readFileSync(orderCreatePath, 'utf-8');
|
||||
|
||||
// calcParams 白名单解析
|
||||
assert(content.includes("\$this->voucher_fee = (\$this->order_type === 'score' && isset(\$params['voucher_fee']) && \$params['voucher_fee'] !== '')"), 'calcParams 仅允许 score 积分订单解析 voucher_fee 参数');
|
||||
|
||||
// calcVoucher 严格前置隔离
|
||||
assert(content.includes("if (\$this->order_type !== 'score') {\n \$this->orderData['voucher_available'] = 0;\n \$this->orderData['voucher_fee'] = 0;\n return;\n }"), 'calcVoucher 增加严格隔离:非 score 订单 voucher_available 与 voucher_fee 均强制为 0');
|
||||
|
||||
// ext 持久化防护
|
||||
assert(content.includes("\$ext['voucher_fee'] = (\$this->order_type === 'score') ? (\$result['voucher_fee'] ?? 0) : 0;"), '订单 ext[\'voucher_fee\'] 仅允许积分商城订单记录,普通订单强制为 0');
|
||||
}
|
||||
|
||||
// 3. voucher.vue 文案优化验证
|
||||
const voucherVuePath = path.resolve(__dirname, '../uniapp/pages/commission/voucher.vue');
|
||||
assert(fs.existsSync(voucherVuePath), 'voucher.vue 文件存在');
|
||||
if (fs.existsSync(voucherVuePath)) {
|
||||
const content = fs.readFileSync(voucherVuePath, 'utf-8');
|
||||
assert(content.includes('积分兑换专享'), 'voucher.vue 胶囊标签展示「积分兑换专享」');
|
||||
assert(!content.includes('全场通用'), 'voucher.vue 已彻底移除「全场通用」误导文案');
|
||||
}
|
||||
|
||||
// 4. 算法与防注入场景模拟验证
|
||||
console.log('\n--- 场景模拟与抵扣安全性测试 ---');
|
||||
|
||||
function mockOrderCalc(orderType, userVoucherAvailable, orderPayFee, inputVoucherFee = null) {
|
||||
let orderData = {
|
||||
voucher_available: 0,
|
||||
voucher_fee: 0,
|
||||
pay_fee: orderPayFee,
|
||||
total_discount_fee: 0
|
||||
};
|
||||
|
||||
// 模拟 OrderCreate::calcVoucher 隔离机制
|
||||
if (orderType !== 'score') {
|
||||
orderData.voucher_available = 0;
|
||||
orderData.voucher_fee = 0;
|
||||
return orderData;
|
||||
}
|
||||
|
||||
let available = userVoucherAvailable;
|
||||
orderData.voucher_available = available;
|
||||
|
||||
let want = (inputVoucherFee === null) ? available : Math.max(0, Number(inputVoucherFee));
|
||||
let apply = Math.min(want, available, orderData.pay_fee);
|
||||
apply = apply > 0 ? apply : 0;
|
||||
|
||||
orderData.voucher_fee = apply;
|
||||
if (apply > 0) {
|
||||
orderData.pay_fee = Math.round((orderData.pay_fee - apply) * 100) / 100;
|
||||
orderData.total_discount_fee = Math.round((orderData.total_discount_fee + apply) * 100) / 100;
|
||||
}
|
||||
return orderData;
|
||||
}
|
||||
|
||||
// 场景 1:普通商品购买(¥1398.00),用户拥有 27 个兑换券,正常下单(不传 voucher_fee)
|
||||
const scene1 = mockOrderCalc('goods', 27, 1398.00);
|
||||
assert(scene1.voucher_fee === 0, '场景 1:普通商品购买 voucher_fee 为 0');
|
||||
assert(scene1.voucher_available === 0, '场景 1:普通商品购买 voucher_available 为 0(不向前端泄露抵扣额)');
|
||||
assert(scene1.pay_fee === 1398.00, '场景 1:普通商品购买应付现金全额保持 1398.00 元');
|
||||
|
||||
// 场景 2:普通商品购买(¥1398.00),攻击者尝试越权传参 voucher_fee = 27
|
||||
const scene2 = mockOrderCalc('goods', 27, 1398.00, 27);
|
||||
assert(scene2.voucher_fee === 0, '场景 2(恶意注入拦截):普通商品强行传 voucher_fee=27,被严格拦截置零');
|
||||
assert(scene2.pay_fee === 1398.00, '场景 2(恶意注入拦截):应付金额分文不少,保持 1398.00 元');
|
||||
|
||||
// 场景 3:积分商城兑换(商品 50 元 + 10 积分),用户拥有 27 个兑换券,自动抵扣
|
||||
const scene3 = mockOrderCalc('score', 27, 50.00);
|
||||
assert(scene3.voucher_fee === 27, '场景 3:积分商城兑换成功抵扣 27 个兑换券');
|
||||
assert(scene3.voucher_available === 27, '场景 3:积分商城兑换正常返回可用额度 27');
|
||||
assert(scene3.pay_fee === 23.00, '场景 3:积分商品剩余 23.00 元现金需自付');
|
||||
|
||||
// 场景 4:积分商城兑换(商品 50 元 + 10 积分),用户拥有 100 个兑换券,全额抵扣现金
|
||||
const scene4 = mockOrderCalc('score', 100, 50.00);
|
||||
assert(scene4.voucher_fee === 50, '场景 4:积分商城兑换成功全额抵扣 50 个兑换券');
|
||||
assert(scene4.pay_fee === 0.00, '场景 4:应付现金降至 0 元,结合 10 积分达成完全免现金兑换');
|
||||
|
||||
// 场景 5:模拟订单 ext 持久化安全
|
||||
function mockSaveOrderExt(orderType, calcResultVoucherFee) {
|
||||
return (orderType === 'score') ? (calcResultVoucherFee || 0) : 0;
|
||||
}
|
||||
assert(mockSaveOrderExt('goods', 100) === 0, '场景 5:普通订单 ext.voucher_fee 强制为 0');
|
||||
assert(mockSaveOrderExt('score', 50) === 50, '场景 5:积分订单 ext.voucher_fee 正确记录为 50');
|
||||
|
||||
console.log('\n--------------------------------------------------------');
|
||||
console.log(`测试执行结果: 通过 ${passCount} 项, 失败 ${failCount} 项`);
|
||||
console.log('========================================================\n');
|
||||
|
||||
if (failCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 兑换券售后退款与订单失效闭环返还专项自动化测试
|
||||
*
|
||||
* 验证重点:
|
||||
* 1. Partner.php 监听器中 checkIsAllRefund 正确排除 REFUND_STATUS_AGREE (1) 与 COMPLETED (2),确保单品及全单退款顺利触发
|
||||
* 2. Partner.php orderItemRefundAfter 增加 refundConsume 兜底返还已核销兑换券
|
||||
* 3. Order.php invalid() 在订单关闭或取消时触发 refundConsume 兜底返还
|
||||
* 4. 幂等防重与零元消费场景模拟
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
function assert(condition, message) {
|
||||
if (condition) {
|
||||
console.log(` \x1b[32m[PASS]\x1b[0m ${message}`);
|
||||
passCount++;
|
||||
} else {
|
||||
console.error(` \x1b[31m[FAIL]\x1b[0m ${message}`);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('========================================================');
|
||||
console.log(' 兑换券退款与失效闭环返还自动化验证');
|
||||
console.log('========================================================\n');
|
||||
|
||||
// 1. Partner.php 监听器审查
|
||||
const partnerListenerPath = path.resolve(__dirname, '../addons/shopro/listener/Partner.php');
|
||||
assert(fs.existsSync(partnerListenerPath), 'Partner.php 监听器文件存在');
|
||||
if (fs.existsSync(partnerListenerPath)) {
|
||||
const content = fs.readFileSync(partnerListenerPath, 'utf-8');
|
||||
assert(content.includes("(new VoucherService())->refundConsume($order)"), 'orderItemRefundAfter 显式调用 refundConsume 进行已核销额度原路退回');
|
||||
assert(content.includes('REFUND_STATUS_AGREE'), 'checkIsAllRefund 包含 REFUND_STATUS_AGREE 排除判定');
|
||||
assert(content.includes('REFUND_STATUS_COMPLETED'), 'checkIsAllRefund 包含 REFUND_STATUS_COMPLETED 排除判定');
|
||||
assert(content.includes('whereNotIn'), 'checkIsAllRefund 采用 whereNotIn 精准排除已同意和已完成的退款');
|
||||
}
|
||||
|
||||
// 2. Order.php 监听器审查
|
||||
const orderListenerPath = path.resolve(__dirname, '../addons/shopro/listener/Order.php');
|
||||
assert(fs.existsSync(orderListenerPath), 'Order.php 监听器文件存在');
|
||||
if (fs.existsSync(orderListenerPath)) {
|
||||
const content = fs.readFileSync(orderListenerPath, 'utf-8');
|
||||
const invalidIdx = content.indexOf('function invalid(');
|
||||
const invalidBlock = content.slice(invalidIdx, invalidIdx + 500);
|
||||
assert(invalidBlock.includes('refundConsume($order)'), 'Order.php invalid() 包含兑换券原路退回调用');
|
||||
}
|
||||
|
||||
// 3. VoucherService.php 幂等机制审查
|
||||
const voucherServicePath = path.resolve(__dirname, '../addons/shopro/service/partner/VoucherService.php');
|
||||
assert(fs.existsSync(voucherServicePath), 'VoucherService.php 文件存在');
|
||||
if (fs.existsSync(voucherServicePath)) {
|
||||
const content = fs.readFileSync(voucherServicePath, 'utf-8');
|
||||
assert(content.includes('empty($ext[\'voucher_consumed\'])'), 'refundConsume 具备未核销拦截保护');
|
||||
assert(content.includes('$alreadyRefunded'), 'refundConsume 具备已退款历史统计');
|
||||
assert(content.includes('$remainVoucher'), 'refundConsume 严格限制单次退款不超过剩余可退额');
|
||||
assert(content.includes('voucher_consume_refund'), 'refundConsume 记入流水日志');
|
||||
}
|
||||
|
||||
// 4. 业务算法模拟
|
||||
console.log('\n--- 业务算法与边界模拟 ---');
|
||||
|
||||
function mockRefundCycle(orderExt, alreadyRefundedSum, requestRefund) {
|
||||
const totalVoucher = Number(orderExt.voucher_fee || 0);
|
||||
const consumed = Boolean(orderExt.voucher_consumed);
|
||||
if (totalVoucher <= 0 || !consumed) {
|
||||
return { refundAmount: 0, reason: '无需退还' };
|
||||
}
|
||||
const remain = Math.max(0, totalVoucher - alreadyRefundedSum);
|
||||
if (remain <= 0) {
|
||||
return { refundAmount: 0, reason: '已全部退还,拦截重复' };
|
||||
}
|
||||
const fee = requestRefund !== null ? Math.min(remain, Number(requestRefund || 0)) : remain;
|
||||
return { refundAmount: fee, reason: '成功退还' };
|
||||
}
|
||||
|
||||
// 场景 1:已核销 50 个兑换券,首次全额退款
|
||||
const r1 = mockRefundCycle({ voucher_fee: 50, voucher_consumed: 1 }, 0, null);
|
||||
assert(r1.refundAmount === 50 && r1.reason === '成功退还', '场景 1:已核销订单成功全额退还 50 个兑换券');
|
||||
|
||||
// 场景 2:已核销 50 个兑换券,但已退款 50 个,重复触发退款
|
||||
const r2 = mockRefundCycle({ voucher_fee: 50, voucher_consumed: 1 }, 50, null);
|
||||
assert(r2.refundAmount === 0 && r2.reason === '已全部退还,拦截重复', '场景 2:幂等拦截!避免重复退还兑换券造成双重返还');
|
||||
|
||||
// 场景 3:普通商品订单(voucher_fee=0)触发退款
|
||||
const r3 = mockRefundCycle({ voucher_fee: 0, voucher_consumed: 0 }, 0, null);
|
||||
assert(r3.refundAmount === 0 && r3.reason === '无需退还', '场景 3:普通商品无兑换券核销,安全跳过');
|
||||
|
||||
// 场景 4:未支付关闭(voucher_consumed=0)触发退款
|
||||
const r4 = mockRefundCycle({ voucher_fee: 50, voucher_consumed: 0 }, 0, null);
|
||||
assert(r4.refundAmount === 0 && r4.reason === '无需退还', '场景 4:未付款即关闭的订单不退还兑换券(未发生实际扣减)');
|
||||
|
||||
console.log('\n--------------------------------------------------------');
|
||||
console.log(`测试执行结果: 通过 ${passCount} 项, 失败 ${failCount} 项`);
|
||||
console.log('========================================================');
|
||||
|
||||
if (failCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -44,7 +44,7 @@ if (fs.existsSync(voucherVuePath)) {
|
||||
assert(content.includes('<text class="hero-unit">个</text>'), 'voucher.vue 核心大字数单位展示为「个」');
|
||||
assert(!content.includes('<text class="hero-symbol">¥</text>'), 'voucher.vue 核心大字数移除「¥」符号');
|
||||
assert(content.includes('<text class="metric-unit">个</text>'), 'voucher.vue 底部统计数据栏使用「个」单位');
|
||||
assert(content.includes("formatVoucher(item.total_amount || item.total || item.voucher_total) }} 个") || content.includes("{{ item.total_amount || item.total || item.voucher_total || '0.00' }} 个"), 'voucher.vue 计划列表卡片使用「个」单位');
|
||||
assert(content.includes("formatVoucher(item.total_amount || item.total || item.voucher_total) }} 个") || content.includes("{{ item.total_amount || item.total || item.voucher_total || '0.00' }} 个") || content.includes('<text class="metric-unit-text">个</text>'), 'voucher.vue 计划列表卡片使用「个」单位');
|
||||
}
|
||||
|
||||
// 4. UniApp - confirm.vue
|
||||
|
||||
@@ -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: {
|
||||
|
||||
在新工单中引用
屏蔽一个用户