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)));
+ }