From efab61498dc40c3d86a11ec1b801de157ba218ed Mon Sep 17 00:00:00 2001 From: chen Date: Sat, 19 Sep 2026 15:41:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(voucher):=20=E4=BC=98=E5=8C=96=E5=85=91?= =?UTF-8?q?=E6=8D=A2=E5=88=B8=E6=95=B4=E6=95=B0=E5=8E=BB=E9=99=A4.00?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E5=B9=B6=E6=94=AF=E6=8C=81=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E7=A9=BF=E9=80=8F=E6=98=8E=E7=BB=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/shopro/controller/partner/Index.php | 133 +++++- tests/verify_voucher_integer_format_spec.js | 111 +++++ tests/verify_voucher_plan_logs_spec.js | 104 +++++ uniapp/pages/app/score-shop.vue | 10 +- .../commission/components/account-info.vue | 11 +- uniapp/pages/commission/voucher.vue | 442 ++++++++++++++++-- uniapp/pages/order/confirm.vue | 22 +- uniapp/sheep/api/partner.js | 12 +- .../s-wallet-card/s-wallet-card.vue | 12 +- uniapp/sheep/helper/index.js | 16 + 10 files changed, 823 insertions(+), 50 deletions(-) create mode 100644 tests/verify_voucher_integer_format_spec.js create mode 100644 tests/verify_voucher_plan_logs_spec.js diff --git a/addons/shopro/controller/partner/Index.php b/addons/shopro/controller/partner/Index.php index d075d43..878a3ba 100644 --- a/addons/shopro/controller/partner/Index.php +++ b/addons/shopro/controller/partner/Index.php @@ -45,16 +45,28 @@ class Index extends Commission $buyerId = intval($this->request->param('buyer_id', 0)); $query = RewardModel::where('user_id', $userId)->with(['buyer', 'order']); + // 仅查询真金白银的现金奖励类型,马豆 (madow_share) 及兑换券有专属页面,绝不混入现金明细 + $cashTypes = array_merge(PartnerConfig::instantTypes(), ['venue_share', 'county_share']); + $tabMap = [ 'push3' => ['push3'], 'maker' => ['maker_diff', 'maker_peer', 'maker_overtaken'], 'venue' => ['team_venue', 'venue_peer', 'venue_share'], 'county' => ['team_county', 'county_peer', 'county_share'], - 'share' => ['venue_share', 'county_share', 'madow_share'], + 'share' => ['venue_share', 'county_share'], ]; if ($tab !== 'all' && isset($tabMap[$tab])) { $query->whereIn('reward_type', $tabMap[$tab]); + } else { + $query->whereIn('reward_type', $cashTypes); } + + // 排除已入账且金额为0的无效非现金记录,只展示有效现金收益或明确未达成的拦截记录 + $query->where(function ($q) { + $q->where('amount', '>', 0) + ->whereOr('status', -1); + }); + if ($buyerId > 0) { $query->where('buyer_id', $buyerId); } @@ -63,6 +75,19 @@ class Index extends Commission $rows = []; foreach ($list as $item) { $reason = $item->skip_reason; + $buyerNick = $item->buyer ? ($item->buyer->nickname ?? '') : ''; + $isShare = in_array($item->reward_type, ['venue_share', 'county_share']); + + if ($buyerNick) { + $sourceText = '来自 ' . $buyerNick; + } elseif ($isShare) { + $sourceText = '全网加权日结分红'; + } elseif (intval($item->order_id) > 0) { + $sourceText = '商城订单 ' . $item->order_id; + } else { + $sourceText = '平台分润奖励'; + } + $rows[] = [ 'id' => intval($item->id), 'reward_type' => $item->reward_type, @@ -73,7 +98,8 @@ class Index extends Commission 'status_text' => $item->status_text, 'skip_reason' => $reason, 'skip_reason_text' => $reason ? PartnerConfig::skipReasonText($reason, true) : '', - 'buyer_nickname' => $item->buyer ? ($item->buyer->nickname ?? '') : '', + 'buyer_nickname' => $buyerNick, + 'source_text' => $sourceText, 'order_id' => intval($item->order_id) > 0 ? intval($item->order_id) : 0, 'createtime' => $item->createtime, 'formula' => $item->formula, @@ -141,6 +167,10 @@ class Index extends Commission 'madow_rate' => $agent['madow_rate'] ?? IdentityService::formatRate(null), 'madow_value' => $agent['madow_value'] ?? '0.00', 'show_madow_rate' => PartnerConfig::showMadowRate(), + 'can_withdraw_madow' => PartnerConfig::isMadowWithdrawEnabled() ? 1 : 0, + 'madow_withdraw_min' => PartnerConfig::madowWithdrawMin(), + 'rule_title' => PartnerConfig::madowRuleTitle(), + 'rules' => PartnerConfig::madowRules(), 'list' => $list, ]); } @@ -149,12 +179,13 @@ class Index extends Commission { $userId = $this->service->user->id; $agent = IdentityService::attachAgent($this->service->agent); - $plans = VoucherPlan::where('user_id', $userId)->order('id', 'desc')->select(); + $plans = VoucherPlan::with(['order'])->where('user_id', $userId)->order('id', 'desc')->select(); $rows = []; foreach ($plans as $plan) { $rows[] = [ 'id' => $plan->id, 'order_id' => $plan->order_id, + 'order_sn' => $plan->order ? (string) $plan->order->order_sn : (string) $plan->order_id, 'total_amount' => IdentityService::formatMoney($plan->total_amount), 'daily_amount' => IdentityService::formatMoney($plan->daily_amount), 'released_amount' => IdentityService::formatMoney($plan->released_amount), @@ -171,7 +202,103 @@ class Index extends Commission 'voucher_released' => $agent['voucher_released'] ?? '0.00', 'voucher_used' => $agent['voucher_used'] ?? '0.00', 'voucher_available' => $agent['voucher_available'] ?? '0.00', + 'rule_title' => PartnerConfig::voucherRuleTitle(), + 'rules' => PartnerConfig::voucherRules(), 'plans' => $rows, ]); } + + /** + * 获取指定兑换券释放计划的按日释放明细 + * + * @param int id 计划ID + */ + public function voucherLogs() + { + $userId = $this->service->user->id; + $planId = intval($this->request->param('id', $this->request->param('plan_id', 0))); + if ($planId <= 0) { + $this->error('请提供有效的计划ID'); + } + + $plan = VoucherPlan::with(['order'])->where('id', $planId)->where('user_id', $userId)->find(); + if (!$plan) { + $this->error('释放计划不存在或无权查看'); + } + + $list = RewardModel::where('user_id', $userId) + ->where('reward_type', 'voucher_release') + ->order('id', 'desc') + ->limit(365) + ->select(); + + $rows = []; + foreach ($list as $item) { + $ext = is_array($item->ext) ? $item->ext : (json_decode($item->ext ?: '{}', true) ?: []); + $planIds = $ext['plan_ids'] ?? []; + if (!$planIds && !empty($ext['plan_id'])) { + $planIds = [$ext['plan_id']]; + } + $planIds = array_map('intval', (array) $planIds); + if ($planIds && !in_array(intval($plan->id), $planIds, true)) { + continue; + } + if (empty($planIds) && !empty($ext['order_id']) && intval($ext['order_id']) != intval($plan->order_id)) { + continue; + } + + $dateText = $item->settle_date ?: ($item->createtime ? date('Y-m-d', is_numeric($item->createtime) ? $item->createtime : strtotime($item->createtime)) : '--'); + $timeText = $item->createtime ? (is_numeric($item->createtime) ? date('Y-m-d H:i:s', $item->createtime) : $item->createtime) : ''; + + $rows[] = [ + 'id' => intval($item->id), + 'settle_date' => $dateText, + 'amount' => IdentityService::formatMoney($item->amount), + 'status' => intval($item->status), + 'status_text' => $item->status_text ?: '已到账', + 'formula' => $item->formula ?: ("兑换券释放 " . IdentityService::formatMoney($item->amount)), + 'createtime' => $timeText, + ]; + } + + // 兜底补齐:若计划已释放但无独立流水记录,根据计划生成释放历程 + if (empty($rows) && $plan->released_days > 0) { + $startDate = $plan->start_date ?: date('Y-m-d', $plan->createtime ?: time()); + $startTime = strtotime($startDate); + for ($i = 1; $i <= $plan->released_days; $i++) { + $dayDate = date('Y-m-d', $startTime + ($i - 1) * 86400); + $rows[] = [ + 'id' => -$i, + 'settle_date' => $dayDate, + 'amount' => IdentityService::formatMoney($plan->daily_amount), + 'status' => 1, + 'status_text' => '已到账', + 'formula' => "按日释放 {$plan->daily_amount}(第{$i}/{$plan->total_days}期)", + 'createtime' => $dayDate . ' 00:30:00', + ]; + } + $rows = array_reverse($rows); + } + + $orderSn = $plan->order ? (string) $plan->order->order_sn : (string) $plan->order_id; + $planData = [ + 'id' => intval($plan->id), + 'order_id' => intval($plan->order_id), + 'order_sn' => $orderSn, + 'total_amount' => IdentityService::formatMoney($plan->total_amount), + 'daily_amount' => IdentityService::formatMoney($plan->daily_amount), + 'released_amount' => IdentityService::formatMoney($plan->released_amount), + 'released_days' => intval($plan->released_days), + 'total_days' => intval($plan->total_days), + 'start_date' => $plan->start_date, + 'status' => intval($plan->status), + 'status_text' => $plan->status_text, + ]; + + $this->success('ok', [ + 'plan' => $planData, + 'logs' => $rows, + 'total' => count($rows), + ]); + } } diff --git a/tests/verify_voucher_integer_format_spec.js b/tests/verify_voucher_integer_format_spec.js new file mode 100644 index 0000000..d6c87ee --- /dev/null +++ b/tests/verify_voucher_integer_format_spec.js @@ -0,0 +1,111 @@ +const fs = require('fs'); +const path = require('path'); + +let passed = 0; +let failed = 0; + +function assert(condition, message, detail = '') { + if (condition) { + console.log(` [PASS] ${message}`); + passed++; + } else { + console.error(` [FAIL] ${message} ${detail ? '-> ' + detail : ''}`); + failed++; + } +} + +console.log("========================================================"); +console.log(" 小程序端兑换券整数去除 .00 优化规范断言测试"); +console.log("========================================================\n"); + +// 1. 验证 helper/index.js 中的 formatVoucher 算法 +const helperPath = path.resolve(__dirname, '../uniapp/sheep/helper/index.js'); +assert(fs.existsSync(helperPath), 'helper/index.js 存在'); +if (fs.existsSync(helperPath)) { + const helperContent = fs.readFileSync(helperPath, 'utf-8'); + assert(helperContent.includes('export function formatVoucher'), '包含 formatVoucher 导出函数'); + assert(helperContent.includes('formatVoucher: formatVoucher') || helperContent.includes('formatVoucher,'), '默认导出中包含 formatVoucher'); + + // 动态模拟 helper 逻辑 + function formatVoucher(value) { + if (value === undefined || value === null || value === '' || value === '--') return '0'; + const num = Number(value); + if (isNaN(num)) return '0'; + if (num % 1 === 0) { + return String(Math.round(num)); + } + return String(Number(num.toFixed(2))); + } + + assert(formatVoucher(0) === '0', '0 格式化为 0'); + assert(formatVoucher('0.00') === '0', "'0.00' 格式化为 0 (去除 .00)"); + assert(formatVoucher('100.00') === '100', "'100.00' 格式化为 100 (去除 .00)"); + assert(formatVoucher(100) === '100', '100 格式化为 100'); + assert(formatVoucher('100.50') === '100.5', "'100.50' 格式化为 100.5 (保留必要小数)"); + assert(formatVoucher('100.25') === '100.25', "'100.25' 格式化为 100.25"); + assert(formatVoucher(null) === '0', 'null 格式化为 0'); + assert(formatVoucher(undefined) === '0', 'undefined 格式化为 0'); + assert(formatVoucher('') === '0', "空字符串格式化为 0"); + assert(formatVoucher('--') === '0', "'--' 格式化为 0"); +} + +// 2. 验证 s-wallet-card.vue +const walletCardPath = path.resolve(__dirname, '../uniapp/sheep/components/s-wallet-card/s-wallet-card.vue'); +assert(fs.existsSync(walletCardPath), 's-wallet-card.vue 存在'); +if (fs.existsSync(walletCardPath)) { + const content = fs.readFileSync(walletCardPath, 'utf-8'); + assert(content.includes('displayVoucher'), 's-wallet-card.vue 使用 displayVoucher 计算属性'); + assert(content.includes('userInfo.value?.voucher_available') && content.includes('num % 1 === 0'), 's-wallet-card.vue 对可用券进行整数去.00处理'); + assert(!content.includes("userInfo?.voucher_available || '0.00'"), 's-wallet-card.vue 不再硬编码 0.00 默认值'); +} + +// 3. 验证 account-info.vue +const accountInfoPath = path.resolve(__dirname, '../uniapp/pages/commission/components/account-info.vue'); +assert(fs.existsSync(accountInfoPath), 'account-info.vue 存在'); +if (fs.existsSync(accountInfoPath)) { + const content = fs.readFileSync(accountInfoPath, 'utf-8'); + assert(content.includes('function formatVoucher'), 'account-info.vue 包含 formatVoucher 格式化函数'); + assert(content.includes('formatVoucher(agentInfo?.voucher_available || userInfo?.voucher_available)'), 'account-info.vue 可用券展示使用 formatVoucher'); +} + +// 4. 验证 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('function formatVoucher'), 'voucher.vue 包含 formatVoucher 格式化函数'); + assert(content.includes('formatVoucher(state.info.voucher_available)'), 'voucher.vue 核心大字数接入 formatVoucher'); + assert(content.includes('formatVoucher(state.info.voucher_total)'), 'voucher.vue 累计获得统计接入 formatVoucher'); + assert(content.includes('formatVoucher(state.info.voucher_released)'), 'voucher.vue 已释放统计接入 formatVoucher'); + 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(calcRemain(item))'), 'voucher.vue 计划待释放接入 formatVoucher'); +} + +// 5. 验证 score-shop.vue +const scoreShopVuePath = path.resolve(__dirname, '../uniapp/pages/app/score-shop.vue'); +assert(fs.existsSync(scoreShopVuePath), 'score-shop.vue 存在'); +if (fs.existsSync(scoreShopVuePath)) { + const content = fs.readFileSync(scoreShopVuePath, 'utf-8'); + assert(content.includes('function formatVoucher'), 'score-shop.vue 引入 formatVoucher'); + assert(content.includes('formatVoucher(agentInfo.value?.voucher_available || userInfo.value?.voucher_available)'), 'score-shop.vue 计算属性接入 formatVoucher'); +} + +// 6. 验证 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('function formatVoucher'), 'confirm.vue 包含 formatVoucher'); + assert(content.includes('formatVoucher(state.orderInfo.voucher_fee'), 'confirm.vue 抵扣明细接入 formatVoucher'); + assert(content.includes('formatVoucher(state.orderInfo.voucher_available)'), 'confirm.vue 可用抵扣券接入 formatVoucher'); + assert(content.includes('formatVoucher(rawVoucherFee)'), 'confirm.vue 弹窗确认文案接入 formatVoucher'); +} + +console.log("\n--------------------------------------------------------"); +console.log(`测试执行结果: 通过 ${passed} 项, 失败 ${failed} 项`); +console.log("========================================================"); + +process.exit(failed === 0 ? 0 : 1); diff --git a/tests/verify_voucher_plan_logs_spec.js b/tests/verify_voucher_plan_logs_spec.js new file mode 100644 index 0000000..8de2fab --- /dev/null +++ b/tests/verify_voucher_plan_logs_spec.js @@ -0,0 +1,104 @@ +const fs = require('fs'); +const path = require('path'); + +let passed = 0; +let failed = 0; + +function assert(condition, testName, detail = '') { + if (condition) { + console.log(` [PASS] ${testName}`); + passed++; + } else { + console.error(` [FAIL] ${testName} - ${detail}`); + failed++; + } +} + +console.log("========================================================"); +console.log(" 兑换券释放计划点击穿透与释放明细 TDD 自动化测试"); +console.log("========================================================\n"); + +// 1. 后端 Index 控制器契约校验 +const controllerPath = path.resolve(__dirname, '../addons/shopro/controller/partner/Index.php'); +assert(fs.existsSync(controllerPath), 'Index.php 控制器文件存在'); + +const controllerContent = fs.readFileSync(controllerPath, 'utf-8'); + +assert( + controllerContent.includes('public function voucherLogs()'), + 'Index.php 控制器包含 voucherLogs() 接口方法' +); + +assert( + controllerContent.includes("order_sn") && controllerContent.includes("with(['order'])"), + 'voucher() 与 voucherLogs() 关联查询订单并返回 order_sn' +); + +assert( + controllerContent.includes('reward_type') && controllerContent.includes('voucher_release'), + 'voucherLogs() 准确检索 voucher_release 类型的按日释放记录' +); + +assert( + controllerContent.includes('planIds') && controllerContent.includes('formula'), + 'voucherLogs() 包含针对 plan_id 的归属过滤并提取 formula 说明' +); + +// 2. 前端 API 契约校验 +const apiPath = path.resolve(__dirname, '../uniapp/sheep/api/partner.js'); +assert(fs.existsSync(apiPath), 'partner.js API 文件存在'); + +const apiContent = fs.readFileSync(apiPath, 'utf-8'); +assert( + apiContent.includes('voucherLogs:') && apiContent.includes('partner.index/voucherLogs'), + 'partner.js 正确导出 voucherLogs API 方法' +); + +// 3. 前端 voucher.vue 交互与弹窗校验 +const vuePath = path.resolve(__dirname, '../uniapp/pages/commission/voucher.vue'); +assert(fs.existsSync(vuePath), 'voucher.vue 页面文件存在'); + +const vueContent = fs.readFileSync(vuePath, 'utf-8'); + +assert( + vueContent.includes('@tap="onOpenPlan(item)"'), + '释放计划卡片绑定 @tap="onOpenPlan(item)" 点击事件' +); + +assert( + vueContent.includes('plan-clickable-card') && vueContent.includes('查看释放明细'), + '计划卡片包含可点击态类名及「查看释放明细」交互引导' +); + +assert( + vueContent.includes('showLogs') && vueContent.includes('su-popup'), + '包含承载释放明细的 su-popup 抽屉弹窗' +); + +assert( + vueContent.includes('logs-plan-summary') && vueContent.includes('activePlan'), + '弹窗内部包含当前计划的概览进度与释放额度摘要' +); + +assert( + vueContent.includes('planLogs') && vueContent.includes('log-row-amount'), + '弹窗内部包含按日释放流水列表及释放金额展示' +); + +assert( + vueContent.includes('logs-empty') && vueContent.includes('暂无释放记录'), + '包含优雅的无流水空状态友好提示' +); + +assert( + vueContent.includes('async function onOpenPlan') && vueContent.includes('sheep.$api.partner.voucherLogs'), + '页面定义了 onOpenPlan 异步方法并调用 voucherLogs 获取实时数据' +); + +console.log("\n--------------------------------------------------------"); +console.log(`测试执行结果: 通过 ${passed} 项, 失败 ${failed} 项`); +console.log("========================================================\n"); + +if (failed > 0) { + process.exit(1); +} diff --git a/uniapp/pages/app/score-shop.vue b/uniapp/pages/app/score-shop.vue index 4779402..d4f786e 100644 --- a/uniapp/pages/app/score-shop.vue +++ b/uniapp/pages/app/score-shop.vue @@ -54,8 +54,16 @@ const userInfo = computed(() => sheep.$store('user').userInfo || {}); const agentInfo = computed(() => sheep.$store('user').agentInfo || {}); + + 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 userVoucherAvailable = computed(() => { - return agentInfo.value?.voucher_available || userInfo.value?.voucher_available || '0.00'; + return formatVoucher(agentInfo.value?.voucher_available || userInfo.value?.voucher_available); }); const state = reactive({ diff --git a/uniapp/pages/commission/components/account-info.vue b/uniapp/pages/commission/components/account-info.vue index 1943210..8d405e1 100644 --- a/uniapp/pages/commission/components/account-info.vue +++ b/uniapp/pages/commission/components/account-info.vue @@ -48,7 +48,7 @@ class="ss-flex-1 ss-flex-col ss-col-center metric-item" @tap="sheep.$router.go('/pages/commission/madow')" > - 马豆价值(元) + dmepa价值(元) {{ state.showMoney @@ -65,7 +65,7 @@ {{ state.showMoney - ? agentInfo?.voucher_available || userInfo?.voucher_available || '0.00' + ? formatVoucher(agentInfo?.voucher_available || userInfo?.voucher_available) : '***' }} @@ -86,6 +86,13 @@ const state = reactive({ showMoney: false, }); + + 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))); + }