feat(split): 新增分账订单线下结案功能

- 添加 SplitCloseService 类用于处理订单结案逻辑
- 实现订单结案状态检查和标记功能
- 在汇付分账重试逻辑中加入结案状态验证,防止已结案订单继续重试
- 更新分账手动服务,添加结案操作类型
- 扩展钱包结算类,增加结案相关诊断方法
- 修改测试脚本,支持批量标记订单为已结案状态
- 添加数据库升级脚本,增加结案相关字段和菜单权限
```
这个提交包含在:
可乐加冰
2026-10-24 21:18:48 +08:00
父节点 557d3e69ba
当前提交 6a654070ed
共有 16 个文件被更改,包括 1314 次插入 和 96 次删除

查看文件

@@ -0,0 +1,202 @@
<?php
namespace addons\shopro\library\split;
use addons\shopro\model\Order;
use addons\shopro\model\split\SplitManualLog;
use addons\shopro\model\split\SplitOrder;
use think\Db;
class SplitCloseService
{
/** 已结案(线下处理),不再重试汇付/微信分账 */
const STATUS_CLOSED = 5;
/**
* 订单是否已标记结案(分账 status=5 或仅有 close 日志)
*/
public function isClosed($orderId)
{
$orderId = intval($orderId);
if ($orderId <= 0) {
return false;
}
if ($this->hasCloseLog($orderId)) {
return true;
}
$row = SplitOrder::where('order_id', $orderId)
->where('channel', 'huifu')
->order('id', 'desc')
->find();
return $row && intval($row['status']) === self::STATUS_CLOSED;
}
public function hasCloseLog($orderId)
{
$orderId = intval($orderId);
if ($orderId <= 0) {
return false;
}
if (!$this->manualLogSupportsClose()) {
return false;
}
return (bool) SplitManualLog::where('order_id', $orderId)->where('action', 'close')->find();
}
/**
* @return int[] order_id 列表(仅有 close 日志、无 status=5 分账单时也视为结案)
*/
public function orderIdsWithCloseLog(array $orderIds)
{
$orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds))));
if (!$orderIds || !$this->manualLogSupportsClose()) {
return [];
}
return array_map('intval', SplitManualLog::where('order_id', 'in', $orderIds)
->where('action', 'close')
->group('order_id')
->column('order_id'));
}
/**
* 按订单号结案
*
* @return array{order_id:int, split_order_id:int|null, already:bool}
*/
public function closeByOrderSn($orderSn, $reason, $adminId = 0, $adminName = '')
{
$orderSn = trim((string) $orderSn);
if ($orderSn === '') {
throw new \InvalidArgumentException('订单号不能为空');
}
$reason = trim((string) $reason);
if ($reason === '') {
throw new \InvalidArgumentException('请填写结案原因');
}
$order = Order::where('order_sn', $orderSn)->find();
if (!$order && strlen($orderSn) < 24) {
$order = Order::where('order_sn', 'like', $orderSn . '%')->order('id', 'desc')->find();
}
if (!$order) {
throw new \RuntimeException('订单不存在: ' . $orderSn);
}
return $this->closeByOrderId(intval($order['id']), $reason, $adminId, $adminName);
}
/**
* @return array{order_id:int, split_order_id:int|null, already:bool}
*/
public function closeByOrderId($orderId, $reason, $adminId = 0, $adminName = '')
{
$orderId = intval($orderId);
$reason = trim((string) $reason);
if ($reason === '') {
throw new \InvalidArgumentException('请填写结案原因');
}
if ($this->isClosed($orderId)) {
return ['order_id' => $orderId, 'split_order_id' => null, 'already' => true];
}
$order = Order::get($orderId);
if (!$order) {
throw new \RuntimeException('订单不存在');
}
$split = SplitOrder::where('order_id', $orderId)
->where('channel', 'huifu')
->order('id', 'desc')
->find();
$config = new Config();
$retryMax = max(5, $config->getRetryMax());
Db::startTrans();
try {
$splitOrderId = null;
if ($split) {
$status = intval($split['status']);
if (in_array($status, [0, 1], true)) {
throw new \RuntimeException('分账进行中,请稍后再结案或先同步状态');
}
$splitOrderId = intval($split['id']);
$split->status = self::STATUS_CLOSED;
$split->retry_count = max(intval($split['retry_count']), $retryMax + 1);
$split->next_retry_time = null;
if ($this->splitOrderHasCloseColumns()) {
$split->close_reason = mb_substr($reason, 0, 255);
$split->close_admin_id = intval($adminId);
$split->closetime = time();
}
$note = "\n[已结案 " . date('Y-m-d H:i:s') . '] ' . $reason;
$split->wx_result = trim((string) ($split['wx_result'] ?? '')) . $note;
$split->save();
}
$this->writeCloseLog($orderId, (string) $order['order_sn'], $reason, $adminId, $adminName, $splitOrderId);
Db::commit();
return ['order_id' => $orderId, 'split_order_id' => $splitOrderId, 'already' => false];
} catch (\Exception $e) {
Db::rollback();
throw $e;
}
}
protected function writeCloseLog($orderId, $orderSn, $reason, $adminId, $adminName, $splitOrderId = null)
{
if (!$this->manualLogSupportsClose()) {
return;
}
SplitManualLog::create([
'order_id' => intval($orderId),
'order_sn' => $orderSn,
'action' => 'close',
'before_items' => [],
'after_items' => [],
'admin_id' => intval($adminId),
'admin_name' => $adminName ?: 'system',
'remark' => mb_substr($reason, 0, 255),
'retry_split_order_id'=> $splitOrderId ? intval($splitOrderId) : null,
'createtime' => time(),
]);
}
protected function splitOrderHasCloseColumns()
{
static $ok = null;
if ($ok !== null) {
return $ok;
}
$prefix = config('database.prefix');
try {
$rows = Db::query("SHOW COLUMNS FROM `{$prefix}shopro_split_order` LIKE 'close_reason'");
$ok = !empty($rows);
} catch (\Exception $e) {
$ok = false;
}
return $ok;
}
protected function manualLogSupportsClose()
{
static $ok = null;
if ($ok !== null) {
return $ok;
}
$prefix = config('database.prefix');
try {
$row = Db::query("SHOW COLUMNS FROM `{$prefix}shopro_split_manual_log` WHERE Field = 'action'");
if (empty($row[0]['Type'])) {
$ok = false;
return $ok;
}
$ok = strpos($row[0]['Type'], 'close') !== false;
} catch (\Exception $e) {
$ok = false;
}
return $ok;
}
}

查看文件

@@ -133,6 +133,13 @@ class SplitExecutor
return false;
}
if (intval($splitOrder['status']) === SplitCloseService::STATUS_CLOSED) {
throw new \RuntimeException('该订单已标记线下结案,不可重试');
}
if ((new SplitCloseService())->isClosed((int) $splitOrder['order_id'])) {
throw new \RuntimeException('该订单已标记线下结案,不可重试');
}
if (($splitOrder['channel'] ?? 'wechat') === 'huifu' && class_exists('\\addons\\shopro_huifu\\library\\HuifuSplitService')) {
return (new \addons\shopro_huifu\library\HuifuSplitService())->retry($splitOrderId);
}

查看文件

@@ -572,6 +572,7 @@ class SplitManualService
'update' => '修改',
'clear' => '清除',
'retry' => '保存并重试',
'close' => '线下结案',
];
return $map[$action] ?? $action;
}

查看文件

@@ -14,6 +14,7 @@ use addons\shopro\model\regional_agent\Reward as RegionalRewardModel;
use addons\shopro\model\split\Receiver;
use addons\shopro\model\split\SplitOrder;
use addons\shopro\model\split\SplitOrderReceiver;
use think\Db;
/**
* 支付通道分账与钱包佣金:汇付可按人混合(已进件走通道、未进件进钱包);其它通道整单成功则不入余额
@@ -174,18 +175,16 @@ class SplitWalletSettlement
->where('commission_reward_status', CommissionRewardLibrary::COMMISSION_REWARD_STATUS_WAITING)
->select();
foreach ($commissionOrders as $commissionOrder) {
if ($commissionOrder['commission_event'] !== $event && $event !== 'admin') {
continue;
}
$settleEvent = (string) ($commissionOrder['commission_event'] ?? $event);
$rewards = CommissionRewardModel::where('commission_order_id', $commissionOrder['id'])
->where('status', CommissionRewardLibrary::COMMISSION_REWARD_STATUS_WAITING)
->select();
foreach ($rewards as $reward) {
$userId = (int) $reward['agent_id'];
if ($userId > 0 && in_array($userId, $gateway['promoter'], true)) {
$commissionLib->runCommissionRewardWithoutWallet($event, $reward);
$commissionLib->runCommissionRewardWithoutWallet($settleEvent, $reward);
} else {
$commissionLib->runCommissionReward($event, $reward);
$commissionLib->runCommissionReward($settleEvent, $reward);
}
}
self::markCommissionOrderSettledIfDone($commissionOrder);
@@ -196,18 +195,16 @@ class SplitWalletSettlement
->where('commission_reward_status', RegionalRewardLibrary::STATUS_WAITING)
->select();
foreach ($regionalOrders as $regionalOrder) {
if ($regionalOrder['commission_event'] !== $event) {
continue;
}
$settleEvent = (string) ($regionalOrder['commission_event'] ?? $event);
$rewards = RegionalRewardModel::where('regional_order_id', $regionalOrder['id'])
->where('status', RegionalRewardLibrary::STATUS_WAITING)
->select();
foreach ($rewards as $reward) {
$userId = (int) $reward['agent_user_id'];
if ($userId > 0 && in_array($userId, $gateway['regional_agent'], true)) {
$regionalLib->settleRewardWithoutWallet($reward, $event);
$regionalLib->settleRewardWithoutWallet($reward, $settleEvent);
} else {
$regionalLib->settleReward($reward, $event);
$regionalLib->settleReward($reward, $settleEvent);
}
}
self::markRegionalOrderSettledIfDone($regionalOrder);
@@ -310,9 +307,8 @@ class SplitWalletSettlement
->where('commission_reward_status', CommissionRewardLibrary::COMMISSION_REWARD_STATUS_WAITING)
->select();
foreach ($commissionOrders as $commissionOrder) {
if ($commissionOrder['commission_event'] === $event || $event === 'admin') {
$commissionLib->runCommissionRewardByOrderWithoutWallet($event, $commissionOrder);
}
$settleEvent = (string) ($commissionOrder['commission_event'] ?? $event);
$commissionLib->runCommissionRewardByOrderWithoutWallet($settleEvent, $commissionOrder);
}
$regionalLib = new RegionalRewardLibrary();
@@ -320,9 +316,8 @@ class SplitWalletSettlement
->where('commission_reward_status', RegionalRewardLibrary::STATUS_WAITING)
->select();
foreach ($regionalOrders as $regionalOrder) {
if ($regionalOrder['commission_event'] === $event) {
$regionalLib->settleByRegionalOrderWithoutWallet($regionalOrder->id, $event);
}
$settleEvent = (string) ($regionalOrder['commission_event'] ?? $event);
$regionalLib->settleByRegionalOrderWithoutWallet($regionalOrder->id, $settleEvent);
}
}
@@ -333,9 +328,8 @@ class SplitWalletSettlement
->where('commission_reward_status', CommissionRewardLibrary::COMMISSION_REWARD_STATUS_WAITING)
->select();
foreach ($commissionOrders as $commissionOrder) {
if ($commissionOrder['commission_event'] === $event || $event === 'admin') {
$commissionLib->runCommissionRewardByOrder($event, $commissionOrder);
}
$settleEvent = (string) ($commissionOrder['commission_event'] ?? $event);
$commissionLib->runCommissionRewardByOrder($settleEvent, $commissionOrder);
}
$regionalLib = new RegionalRewardLibrary();
@@ -343,9 +337,106 @@ class SplitWalletSettlement
->where('commission_reward_status', RegionalRewardLibrary::STATUS_WAITING)
->select();
foreach ($regionalOrders as $regionalOrder) {
if ($regionalOrder['commission_event'] === $event) {
$regionalLib->settleByRegionalOrder($regionalOrder->id, $event);
}
$settleEvent = (string) ($regionalOrder['commission_event'] ?? $event);
$regionalLib->settleByRegionalOrder($regionalOrder->id, $settleEvent);
}
}
/**
* 汇付 confirm 重试前:佣金已入平台钱包则禁止再次分账(防双发)
*/
public static function getHuifuRetryBlockReason($orderId)
{
$orderId = intval($orderId);
if ($orderId <= 0) {
return null;
}
if (class_exists('\\addons\\shopro\\library\\split\\SplitCloseService')
&& (new SplitCloseService())->isClosed($orderId)) {
return '该订单已标记线下结案,不可重试汇付分账';
}
$wallet = self::sumOrderWalletCommission($orderId);
if (bccomp($wallet, '0.00', 2) <= 0) {
return null;
}
$splitOk = self::isSplitSuccessful($orderId);
if ($splitOk) {
return '网关分账已成功且存在钱包佣金流水,禁止重试(疑似双轨),请标记结案或人工对账';
}
return '佣金已入平台钱包兜底,禁止再次汇付 confirm(防双发);若需通道分账请先对账并标记结案';
}
/**
* @return array{settlement_source:string,settlement_source_text:string,wallet_sum:string,split_success:bool,retry_blocked:bool,retry_block_reason:string}
*/
public static function diagnoseOrderSettlement($orderId)
{
$orderId = intval($orderId);
$walletSum = self::sumOrderWalletCommission($orderId);
$splitOk = self::isSplitSuccessful($orderId);
$retryReason = self::getHuifuRetryBlockReason($orderId);
$source = 'pending';
if ($splitOk && bccomp($walletSum, '0.00', 2) > 0) {
$source = 'hybrid';
} elseif ($splitOk) {
$source = 'gateway';
} elseif (bccomp($walletSum, '0.00', 2) > 0) {
$source = 'wallet';
}
$textMap = [
'pending' => '待结算/无钱包流水',
'gateway' => '汇付通道(未入平台钱包)',
'wallet' => '平台钱包兜底',
'hybrid' => '通道+钱包混合(请核对)',
];
return [
'settlement_source' => $source,
'settlement_source_text' => $textMap[$source] ?? $source,
'wallet_sum' => $walletSum,
'split_success' => $splitOk,
'retry_blocked' => $retryReason !== null,
'retry_block_reason' => $retryReason ?: '',
];
}
protected static function sumOrderWalletCommission($orderId)
{
$orderId = intval($orderId);
if ($orderId <= 0) {
return '0.00';
}
$sum = '0.00';
$crIds = CommissionRewardModel::where('order_id', $orderId)
->where('status', CommissionRewardLibrary::COMMISSION_REWARD_STATUS_ACCOUNTED)
->column('id');
if ($crIds) {
$part = Db::name('shopro_user_wallet_log')
->where('item_id', 'in', $crIds)
->where('wallet_type', 'money')
->where('type', 'commission_income')
->sum('wallet');
$sum = bcadd($sum, number_format((float) $part, 2, '.', ''), 2);
}
$regionalOrderIds = RegionalOrderModel::where('order_id', $orderId)->column('id');
if ($regionalOrderIds) {
$rrIds = RegionalRewardModel::where('regional_order_id', 'in', $regionalOrderIds)
->where('status', RegionalRewardLibrary::STATUS_ACCOUNTED)
->column('id');
if ($rrIds) {
$part = Db::name('shopro_user_wallet_log')
->where('item_id', 'in', $rrIds)
->where('wallet_type', 'money')
->where('type', 'regional_agent_income')
->sum('wallet');
$sum = bcadd($sum, number_format((float) $part, 2, '.', ''), 2);
}
}
return $sum;
}
}

查看文件

@@ -11,6 +11,9 @@
* php addons/shopro/tests/split_commission_verify.php try-split {order_sn} --execute # 补跑汇付分账(慎用)
* php addons/shopro/tests/split_commission_verify.php try-split {order_sn} --confirm-only # 仅延时分账确认、不分账(排查22000002)
* php addons/shopro/tests/split_commission_verify.php audit-delay [limit=30] [--live] # 延迟户对账:本地+可选汇付查单 unconfirm_amt
* php addons/shopro/tests/split_commission_verify.php mark-closed {order_sn} --reason="财务线下处理"
* php addons/shopro/tests/split_commission_verify.php mark-closed-batch --file=orders.txt --reason="..."
* php addons/shopro/tests/split_commission_verify.php mark-closed-batch --from-anomalies --reason="..." [--yes]
*/
if (PHP_SAPI !== 'cli') {
@@ -68,12 +71,209 @@ switch ($command) {
$live = in_array('--live', $argv, true);
auditDelayUnconfirm($limit, $live);
break;
case 'mark-closed':
$sn = $argv[2] ?? '';
$reason = parseCliOptionValue($argv, '--reason') ?: '财务已线下处理,停止汇付重试';
runMarkClosedSingle($sn, $reason);
break;
case 'mark-closed-batch':
$reason = parseCliOptionValue($argv, '--reason') ?: '财务已线下处理,停止汇付重试';
$yes = in_array('--yes', $argv, true);
$file = parseCliOptionValue($argv, '--file');
$fromAnomalies = in_array('--from-anomalies', $argv, true);
runMarkClosedBatch($reason, $file, $fromAnomalies, $yes);
break;
default:
echo "未知命令: {$command}\n";
echo "可用: summary | anomalies | order {order_sn} | recent-success [limit] | diagnose-missing [limit] | try-split {order_sn} [--execute|--confirm-only] | audit-delay [limit] [--live]\n";
echo "可用: summary | anomalies | order {order_sn} | recent-success [limit] | diagnose-missing [limit]\n";
echo " try-split {order_sn} [--execute|--confirm-only] | audit-delay [limit] [--live]\n";
echo " mark-closed {order_sn} [--reason=...] | mark-closed-batch [--file=...|--from-anomalies] [--reason=...] [--yes]\n";
exit(1);
}
function parseCliOptionValue(array $argv, $flag)
{
$prefix = $flag . '=';
foreach ($argv as $arg) {
if (strpos($arg, $prefix) === 0) {
return substr($arg, strlen($prefix));
}
}
return '';
}
function splitCloseService()
{
if (!class_exists('\\addons\\shopro\\library\\split\\SplitCloseService')) {
fwrite(STDERR, "SplitCloseService 未部署\n");
exit(1);
}
return new \addons\shopro\library\split\SplitCloseService();
}
/** @return int[] */
function fetchOrderIdsWithCloseLog(array $orderIds)
{
if (!$orderIds) {
return [];
}
return splitCloseService()->orderIdsWithCloseLog($orderIds);
}
function sqlExcludeClosedOrdersSubquery($orderAlias = 'o')
{
$closedStatus = \addons\shopro\library\split\SplitCloseService::STATUS_CLOSED;
$pfx = config('database.prefix');
if (!tableHasColumn('shopro_split_manual_log', 'action')) {
return "NOT EXISTS (
SELECT 1 FROM {$pfx}shopro_split_order so_c
WHERE so_c.order_id = {$orderAlias}.id AND so_c.channel = 'huifu' AND so_c.status = {$closedStatus}
)";
}
return "NOT EXISTS (
SELECT 1 FROM {$pfx}shopro_split_order so_c
WHERE so_c.order_id = {$orderAlias}.id AND so_c.channel = 'huifu' AND so_c.status = {$closedStatus}
) AND NOT EXISTS (
SELECT 1 FROM {$pfx}shopro_split_manual_log ml
WHERE ml.order_id = {$orderAlias}.id AND ml.action = 'close'
)";
}
function runMarkClosedSingle($orderSn, $reason)
{
if ($orderSn === '') {
fwrite(STDERR, "用法: mark-closed {order_sn} [--reason=...]\n");
exit(1);
}
try {
$result = splitCloseService()->closeByOrderSn($orderSn, $reason, 0, 'CLI');
} catch (\Throwable $e) {
fwrite(STDERR, '结案失败: ' . $e->getMessage() . "\n");
exit(1);
}
if (!empty($result['already'])) {
echo "订单 id={$result['order_id']} 此前已结案\n";
return;
}
echo "已结案 order_id={$result['order_id']}";
if (!empty($result['split_order_id'])) {
echo " split_order_id={$result['split_order_id']}";
}
echo "\n";
}
function runMarkClosedBatch($reason, $file, $fromAnomalies, $yes)
{
$sns = [];
if ($file !== '') {
if (!is_readable($file)) {
fwrite(STDERR, "无法读取文件: {$file}\n");
exit(1);
}
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') {
continue;
}
$sns[] = preg_split('/[\s,,]+/', $line)[0];
}
} elseif ($fromAnomalies) {
$sns = collectAnomalyOrderSns(90);
} else {
fwrite(STDERR, "请指定 --file=orders.txt 或 --from-anomalies\n");
exit(1);
}
$sns = array_values(array_unique(array_filter(array_map('trim', $sns))));
if (!$sns) {
echo "无订单号可处理\n";
return;
}
echo "将结案 " . count($sns) . " 笔订单,原因: {$reason}\n";
foreach ($sns as $sn) {
echo " · {$sn}\n";
}
if (!$yes) {
echo "\n加 --yes 确认执行\n";
return;
}
$ok = 0;
$skip = 0;
$fail = 0;
$svc = splitCloseService();
foreach ($sns as $sn) {
try {
$result = $svc->closeByOrderSn($sn, $reason, 0, 'CLI-batch');
if (!empty($result['already'])) {
$skip++;
} else {
$ok++;
}
} catch (\Throwable $e) {
$fail++;
echo "[FAIL] {$sn}: " . $e->getMessage() . "\n";
}
}
echo "完成:新结案 {$ok},已结案跳过 {$skip},失败 {$fail}\n";
}
/** @return string[] */
function collectAnomalyOrderSns($days = 90)
{
$since = time() - intval($days) * 86400;
$pfx = config('database.prefix');
$closed = \addons\shopro\library\split\SplitCloseService::STATUS_CLOSED;
$sns = [];
$double = Db::query("
SELECT so.order_sn
FROM {$pfx}shopro_split_order so
INNER JOIN {$pfx}shopro_order o ON o.id = so.order_id
INNER JOIN (
SELECT order_id, SUM(wallet_sum) AS wallet_sum FROM (
SELECT cr.order_id, SUM(wl.wallet) AS wallet_sum
FROM {$pfx}shopro_commission_reward cr
INNER JOIN {$pfx}shopro_user_wallet_log wl
ON wl.item_id = cr.id AND wl.type = 'commission_income' AND wl.wallet_type = 'money'
WHERE cr.status = 1
GROUP BY cr.order_id
UNION ALL
SELECT rr.order_id, SUM(wl.wallet)
FROM {$pfx}shopro_regional_agent_reward rr
INNER JOIN {$pfx}shopro_user_wallet_log wl
ON wl.item_id = rr.id AND wl.type = 'regional_agent_income' AND wl.wallet_type = 'money'
WHERE rr.status = 1
GROUP BY rr.order_id
) t GROUP BY order_id
) wl ON wl.order_id = so.order_id
WHERE so.status IN (2,3) AND so.split_amount > 0 AND wl.wallet_sum > 0
AND so.status <> {$closed}
AND o.paytime >= ?
ORDER BY so.id DESC
LIMIT 100
", [$since]);
foreach ($double as $r) {
$sns[] = $r['order_sn'];
}
$failed = Db::name('shopro_split_order')->alias('so')
->join('shopro_order o', 'o.id = so.order_id')
->where('so.channel', 'huifu')
->where('so.status', 4)
->where('o.paytime', '>=', $since)
->column('so.order_sn');
foreach ($failed as $sn) {
$sns[] = $sn;
}
foreach (fetchMissingSplitOrders($since, 100) as $row) {
$sns[] = $row['order_sn'];
}
return array_values(array_unique(array_filter($sns)));
}
function tableHasColumn($table, $column)
{
static $cache = [];
@@ -255,6 +455,10 @@ function printAnomalies()
) wl ON wl.order_id = so.order_id
WHERE so.status IN (2,3) AND so.split_amount > 0 AND wl.wallet_sum > 0
AND o.paytime >= ?
AND NOT EXISTS (
SELECT 1 FROM {$pfx}shopro_split_manual_log ml
WHERE ml.order_id = so.order_id AND ml.action = 'close'
)
ORDER BY so.id DESC
LIMIT 30
", [$since]);
@@ -290,6 +494,7 @@ function printAnomalies()
->where('so.channel', 'huifu')
->where('so.status', 'in', [1, 2, 3]);
})
->whereRaw(sqlExcludeClosedOrdersSubquery('o'))
->whereExists(function ($q) {
$q->name('shopro_commission_reward')->alias('cr')
->whereRaw('cr.order_id = o.id')
@@ -315,6 +520,7 @@ function printAnomalies()
*/
function fetchMissingSplitOrders($since, $limit = 20)
{
$closedStatus = \addons\shopro\library\split\SplitCloseService::STATUS_CLOSED;
return Db::name('shopro_order')->alias('o')
->where('o.pay_type', 'huifu_wechat')
->where('o.paytime', '>=', $since)
@@ -324,6 +530,13 @@ function fetchMissingSplitOrders($since, $limit = 20)
->where('so.channel', 'huifu')
->where('so.status', 'in', [1, 2, 3]);
})
->whereNotExists(function ($q) use ($closedStatus) {
$q->name('shopro_split_order')->alias('so5')
->whereRaw('so5.order_id = o.id')
->where('so5.channel', 'huifu')
->where('so5.status', $closedStatus);
})
->whereRaw(sqlExcludeClosedOrdersSubquery('o'))
->whereRaw(sqlOrderHasAnyReward('o.id'))
->field('o.id, o.order_sn, o.pay_fee, o.pay_type, o.paytime, o.transaction_id')
->order('o.id desc')
@@ -338,6 +551,10 @@ function diagnoseOrderSplitBlockers($orderId)
if (!$order) {
return ['订单不存在'];
}
if (class_exists('\\addons\\shopro\\library\\split\\SplitCloseService')
&& (new \addons\shopro\library\split\SplitCloseService())->isClosed($orderId)) {
$reasons[] = '已标记线下结案,不再重试汇付分账';
}
if ($order['pay_type'] !== 'huifu_wechat') {
$reasons[] = "pay_type={$order['pay_type']},非 huifu_wechat,汇付分账不会执行";
}
@@ -586,6 +803,11 @@ function trySplitOrder($orderSn, $execute, $confirmOnly = false)
fwrite(STDERR, "订单不存在\n");
exit(1);
}
if (class_exists('\\addons\\shopro\\library\\split\\SplitCloseService')
&& (new \addons\shopro\library\split\SplitCloseService())->isClosed($order['id'])) {
echo "该订单已标记线下结案,禁止 try-split --execute / --confirm-only\n";
exit($execute || $confirmOnly ? 1 : 0);
}
echo "诊断:\n";
foreach (diagnoseOrderSplitBlockers($order['id']) as $line) {
echo " · {$line}\n";

查看文件

@@ -0,0 +1,14 @@
-- 分账订单「已结案(线下处理)」:停止重试、后台与核验脚本可识别
-- 执行前请将 fa_ 替换为实际表前缀
ALTER TABLE `fa_shopro_split_order`
MODIFY COLUMN `status` tinyint(1) NOT NULL DEFAULT 0
COMMENT '0=待分账,1=分账中,2=成功,3=部分成功,4=失败,5=已结案(线下处理)';
ALTER TABLE `fa_shopro_split_order`
ADD COLUMN `close_reason` varchar(255) DEFAULT NULL COMMENT '结案原因' AFTER `wx_result`,
ADD COLUMN `close_admin_id` int(11) NOT NULL DEFAULT 0 COMMENT '结案操作管理员' AFTER `close_reason`,
ADD COLUMN `closetime` int(10) DEFAULT NULL COMMENT '结案时间' AFTER `close_admin_id`;
ALTER TABLE `fa_shopro_split_manual_log`
MODIFY COLUMN `action` enum('create','update','clear','retry','close') NOT NULL DEFAULT 'update' COMMENT '操作类型';

查看文件

@@ -0,0 +1,6 @@
-- 分账记录:标记已线下处理
SET @now = UNIX_TIMESTAMP();
SET @ord = (SELECT id FROM `fa_auth_rule` WHERE `name` = 'shopro/split/order' LIMIT 1);
INSERT IGNORE INTO `fa_auth_rule` (`type`,`pid`,`name`,`title`,`icon`,`url`,`condition`,`remark`,`ismenu`,`extend`,`createtime`,`updatetime`,`weigh`,`status`) VALUES
('file', @ord, 'shopro/split/order/markClosed', '标记已线下处理', 'fa fa-circle-o', '', '', '', 0, '', @now, @now, 0, 'normal');

查看文件

@@ -3,6 +3,7 @@
namespace addons\shopro_huifu\command;
use addons\shopro\library\split\Config;
use addons\shopro\library\split\SplitCloseService;
use addons\shopro\model\split\SplitOrder;
use addons\shopro_huifu\library\HuifuSplitService;
use think\console\Command;
@@ -60,6 +61,11 @@ class SplitRetry extends Command
$output->writeln("[OK] 已分账成功: {$sn}");
continue;
}
if ((int) $split['status'] === SplitCloseService::STATUS_CLOSED
|| (new SplitCloseService())->isClosed((int) $order['id'])) {
$output->writeln("[SKIP] 已线下结案: {$sn}");
continue;
}
$rows[] = $split;
}
} else {
@@ -79,7 +85,13 @@ class SplitRetry extends Command
$ok = 0;
$fail = 0;
$closeService = new SplitCloseService();
foreach ($rows as $row) {
if ((int) $row['status'] === SplitCloseService::STATUS_CLOSED
|| $closeService->isClosed((int) $row['order_id'])) {
$output->writeln('[SKIP] 已线下结案 order_sn=' . ($row['order_sn'] ?? ''));
continue;
}
$output->writeln('retry split#' . $row['id'] . ' order_sn=' . ($row['order_sn'] ?? ''));
$result = $service->retry($row['id']);
if ($result) {

查看文件

@@ -3,7 +3,9 @@
namespace addons\shopro_huifu\library;
use addons\shopro\library\split\SplitCalculator;
use addons\shopro\library\split\SplitCloseService;
use addons\shopro\library\split\SplitManualService;
use addons\shopro\library\split\SplitWalletSettlement;
use addons\shopro\library\split\SplitOrderHelper;
use addons\shopro\library\split\Config as SplitConfig;
use addons\shopro\model\Order;
@@ -29,6 +31,16 @@ class HuifuSplitService
return false;
}
if ((new SplitCloseService())->isClosed($orderId)) {
\think\Log::info('[shopro_huifu] split skipped order_id=' . $orderId . ' reason=closed_offline');
return false;
}
$retryBlock = SplitWalletSettlement::getHuifuRetryBlockReason($orderId);
if ($retryBlock !== null) {
throw new \RuntimeException($retryBlock);
}
$done = SplitOrder::where('order_id', $orderId)->where('channel', 'huifu')->where('status', 2)->find();
if ($done) {
return true;
@@ -150,7 +162,19 @@ class HuifuSplitService
if (!$splitOrder || $splitOrder['channel'] !== 'huifu') {
return false;
}
return $this->executeByOrderId((int) $splitOrder['order_id']);
if (intval($splitOrder['status']) === SplitCloseService::STATUS_CLOSED) {
throw new \RuntimeException('该订单已标记线下结案,不可重试');
}
if ((new SplitCloseService())->isClosed((int) $splitOrder['order_id'])) {
throw new \RuntimeException('该订单已标记线下结案,不可重试');
}
$orderId = (int) $splitOrder['order_id'];
$ok = $this->executeByOrderId($orderId);
if ($ok) {
$event = (new SplitConfig())->getTiming();
SplitWalletSettlement::finalizeAfterSplitAttempt($orderId, $event, true);
}
return $ok;
}
public function retryByOrderId($orderId)
@@ -159,7 +183,11 @@ class HuifuSplitService
if ($orderId <= 0) {
return false;
}
return $this->executeByOrderId($orderId);
$latest = SplitOrder::where('order_id', $orderId)->where('channel', 'huifu')->order('id', 'desc')->find();
if (!$latest) {
return $this->executeByOrderId($orderId);
}
return $this->retry((int) $latest['id']);
}
/**

查看文件

@@ -5,8 +5,10 @@ namespace app\admin\controller\shopro\split;
use app\admin\controller\shopro\Base;
use addons\shopro\library\split\Config as SplitConfig;
use addons\shopro\library\split\SplitCalculator;
use addons\shopro\library\split\SplitCloseService;
use addons\shopro\library\split\SplitExecutor;
use addons\shopro\library\split\SplitManualService;
use addons\shopro\library\split\SplitWalletSettlement;
use addons\shopro\library\split\ProfitSharingService;
use app\admin\model\shopro\order\Order as ShopOrder;
use app\admin\model\shopro\user\User as ShopUser;
@@ -22,6 +24,7 @@ class Order extends Base
2 => '成功',
3 => '部分成功',
4 => '失败',
5 => '已结案',
];
public function _initialize()
@@ -136,6 +139,7 @@ class Order extends Base
{
$rows = [];
$manualService = new SplitManualService();
$closeService = new SplitCloseService();
$orderIds = [];
foreach ($list as $item) {
$data = is_array($item) ? $item : $item->toArray();
@@ -146,6 +150,7 @@ class Order extends Base
}
$overrideOrderIds = array_flip($manualService->orderIdsWithOverride($orderIds));
$logOrderIds = array_flip($manualService->orderIdsWithLog($orderIds));
$closedLogOrderIds = array_flip($closeService->orderIdsWithCloseLog($orderIds));
foreach ($list as $item) {
$data = is_array($item) ? $item : $item->toArray();
@@ -209,10 +214,42 @@ class Order extends Base
$hasManualLog = isset($logOrderIds[$orderId]);
$data['has_manual_override'] = $hasOverride;
$data['has_manual_log'] = $hasManualLog;
$data['can_edit_manual'] = ($status === 4 && $channel === 'huifu');
$settleDiag = SplitWalletSettlement::diagnoseOrderSettlement($orderId);
$data['settlement_source'] = $settleDiag['settlement_source'];
$data['settlement_source_text'] = $settleDiag['settlement_source_text'];
$data['wallet_settlement_sum'] = $settleDiag['wallet_sum'];
$data['retry_blocked'] = $settleDiag['retry_blocked'];
$data['retry_block_reason'] = $settleDiag['retry_block_reason'];
$data['can_edit_manual'] = ($status === 4 && $channel === 'huifu')
&& !$settleDiag['retry_blocked'];
$data['can_retry_split'] = ($status === 4 && $channel === 'huifu')
&& !$settleDiag['retry_blocked'];
// 变更历史:成功/失败后均可查看,不限于 status=4
$data['can_view_manual_history'] = ($channel === 'huifu')
&& ($hasOverride || $hasManualLog || $orderSplitSource === 'manual');
&& ($hasOverride || $hasManualLog || $orderSplitSource === 'manual' || isset($closedLogOrderIds[$orderId]));
$isClosed = ($status === SplitCloseService::STATUS_CLOSED) || isset($closedLogOrderIds[$orderId]);
$data['is_closed'] = $isClosed;
$data['close_reason'] = trim((string) ($data['close_reason'] ?? ''));
$data['closetime_text'] = !empty($data['closetime'])
? date('Y-m-d H:i:s', $data['closetime'])
: '';
$data['can_mark_closed'] = ($channel === 'huifu')
&& in_array($status, [2, 3, 4], true)
&& !$isClosed;
if ($settleDiag['retry_blocked'] && $status === 4 && $data['fail_reason_full'] === '') {
$data['fail_reason_full'] = $settleDiag['retry_block_reason'];
$data['fail_reason'] = mb_strlen($settleDiag['retry_block_reason']) > 72
? (mb_substr($settleDiag['retry_block_reason'], 0, 72) . '…')
: $settleDiag['retry_block_reason'];
}
if ($status === SplitCloseService::STATUS_CLOSED && $data['close_reason'] !== '') {
$data['fail_reason_full'] = '线下结案:' . $data['close_reason'];
$data['fail_reason'] = mb_strlen($data['close_reason']) > 72
? (mb_substr($data['close_reason'], 0, 72) . '…')
: $data['close_reason'];
}
unset($data['order'], $data['receivers']);
$rows[] = $data;
@@ -630,6 +667,37 @@ class Order extends Base
$this->success('操作成功', null, $result);
}
/**
* 标记已线下处理(停止重试,status=5)
*/
public function markClosed()
{
$splitOrderId = intval($this->request->post('split_order_id', 0));
$reason = trim((string) $this->request->post('reason', ''));
$row = $this->model->get($splitOrderId);
if (!$row) {
$this->error('分账记录不存在');
}
if (($row['channel'] ?? '') !== 'huifu') {
$this->error('仅汇付分账记录可标记结案');
}
try {
$result = (new SplitCloseService())->closeByOrderId(
intval($row['order_id']),
$reason,
intval($this->auth->id),
(string) ($this->auth->nickname ?? $this->auth->username ?? '')
);
} catch (\Throwable $e) {
$this->error($e->getMessage());
}
if (!empty($result['already'])) {
$this->success('该订单此前已标记结案');
}
$this->success('已标记线下结案,将不再自动重试');
}
public function retry($ids = null)
{
$id = $ids ?: $this->request->post('id');
@@ -637,6 +705,14 @@ class Order extends Base
if (!$row) {
$this->error('分账记录不存在,可能已被上次重试替换。请刷新列表后,对最新一条失败记录重试');
}
if (intval($row['status']) === SplitCloseService::STATUS_CLOSED
|| (new SplitCloseService())->isClosed((int) $row['order_id'])) {
$this->error('该订单已标记线下结案,不可重试');
}
$block = SplitWalletSettlement::getHuifuRetryBlockReason((int) $row['order_id']);
if ($block !== null) {
$this->error($block);
}
$orderId = (int) $row['order_id'];
$channel = $row['channel'] ?? 'wechat';
try {

查看文件

@@ -79,6 +79,7 @@
.status-tag.s2 { background: #f0f9eb; color: #67c23a; }
.status-tag.s3 { background: #fdf6ec; color: #e6a23c; }
.status-tag.s4 { background: #fef0f0; color: #f56c6c; }
.status-tag.s5 { background: #f4f4f5; color: #606266; }
.money-num {
display: block;
text-align: right;
@@ -279,6 +280,7 @@
<el-option label="成功" value="2"></el-option>
<el-option label="部分成功" value="3"></el-option>
<el-option label="失败" value="4"></el-option>
<el-option label="已结案" value="5"></el-option>
</el-select>
<el-select v-model="filters.channel" placeholder="通道" size="small" style="width:100px;" clearable>
<el-option label="汇付" value="huifu"></el-option>
@@ -322,8 +324,13 @@
<span class="money-num highlight">¥{{ formatMoney(s.row.split_amount) }}</span>
</template>
</el-table-column>
<el-table-column label="分账来源" class-name="col-source" width="130" align="center">
<el-table-column label="分账来源" class-name="col-source" width="148" align="center">
<template slot-scope="s">
<div v-if="s.row.settlement_source_text" style="font-size:11px;color:#606266;margin-bottom:4px;">
结算:{{ s.row.settlement_source_text }}
<span v-if="parseFloat(s.row.wallet_settlement_sum) > 0">(钱包 ¥{{ formatMoney(s.row.wallet_settlement_sum) }})</span>
</div>
<div v-if="s.row.retry_blocked && s.row.status == 4" style="font-size:11px;color:#f56c6c;margin-bottom:4px;">不可重试</div>
<div class="source-cell" v-if="s.row.split_source === 'manual' || s.row.has_manual_override || s.row.calc_source">
<span class="source-tag manual" v-if="s.row.split_source === 'manual' || s.row.has_manual_override">手动</span>
<span v-else-if="s.row.calc_source" :class="'source-tag ' + (s.row.calc_source === 'commission' ? 'commission' : 'rule')">
@@ -388,7 +395,8 @@
<template slot-scope="s">
<el-button type="text" size="small" v-if="s.row.can_edit_manual" @click="openManualDialog(s.row)">指定接收方</el-button>
<el-button type="text" size="small" v-if="s.row.can_view_manual_history" @click="openHistoryDrawer(s.row)">变更历史</el-button>
<el-button type="text" size="small" v-if="s.row.status == 4" @click="retry(s.row)">重试</el-button>
<el-button type="text" size="small" v-if="s.row.can_retry_split && !s.row.is_closed" @click="retry(s.row)">重试</el-button>
<el-button type="text" size="small" v-if="s.row.can_mark_closed" @click="markClosed(s.row)">标记结案</el-button>
<el-button type="text" size="small" @click="query(s.row.id)">同步</el-button>
</template>
</el-table-column>
@@ -405,7 +413,7 @@
@size-change="onSizeChange">
</el-pagination>
</div>
<p class="op-hint">失败汇付单可「指定接收方」改人改金额后重试;分账成功或失败后均可点「变更历史」查看每次改人记录。「同步」只更新状态,不会重复扣款。</p>
<p class="op-hint">失败汇付单可「指定接收方」改人改金额后重试;财务已线下处理的异常单请「标记结案」以停止自动重试。分账成功或失败后均可点「变更历史」查看记录。「同步」只更新状态,不会重复扣款。</p>
</div>
<!-- 指定接收方弹窗 -->

查看文件

@@ -295,6 +295,29 @@ define(['jquery', 'bootstrap', 'backend', 'form'], function ($, undefined, Backe
}
);
},
markClosed(row) {
var self = this;
Layer.prompt({
title: '标记已线下处理(停止重试)',
formType: 2,
value: '财务已线下对账/补款,停止汇付分账重试',
area: ['420px', '160px']
}, function (value, index) {
value = (value || '').trim();
if (!value) {
Layer.msg('请填写结案原因');
return;
}
Layer.close(index);
Fast.api.ajax({
url: 'shopro/split/order/markClosed',
type: 'POST',
data: { split_order_id: row.id, reason: value }
}, () => {
self.getList();
});
});
},
query(id) {
Fast.api.ajax({ url: 'shopro/split/order/query/ids/' + id, type: 'POST' }, (ret, res) => {
var hint = (res && res.msg) ? res.msg : '已同步通道状态并更新记录';

查看文件

@@ -1,57 +1,84 @@
{
"name": "shopro-plus",
"version": "1.0.0",
"lockfileVersion": 1,
"lockfileVersion": 3,
"requires": true,
"dependencies": {
"@babel/runtime": {
"packages": {
"": {
"name": "shopro-plus",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"jweixin-module": "^1.6.0",
"uni-read-pages": "^1.0.5",
"uni-simple-router": "^2.0.6",
"vconsole": "^3.7.0"
},
"devDependencies": {}
},
"node_modules/@babel/runtime": {
"version": "7.20.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.0.tgz",
"integrity": "sha512-NDYdls71fTXoU8TZHfbBWg7DiZfNzClcKui/+kyi6ppD2L1qnWW3VV6CjtaBXSUGGhiTWJ6ereOIkUvenif66Q==",
"requires": {
"dependencies": {
"regenerator-runtime": "^0.13.10"
},
"engines": {
"node": ">=6.9.0"
}
},
"copy-text-to-clipboard": {
"node_modules/copy-text-to-clipboard": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.0.1.tgz",
"integrity": "sha512-rvVsHrpFcL4F2P8ihsoLdFHmd404+CMg71S756oRSeQgqk51U3kicGdnvfkrxva0xXH92SjGS62B0XIJsbh+9Q=="
"integrity": "sha512-rvVsHrpFcL4F2P8ihsoLdFHmd404+CMg71S756oRSeQgqk51U3kicGdnvfkrxva0xXH92SjGS62B0XIJsbh+9Q==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"core-js": {
"node_modules/core-js": {
"version": "3.26.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.26.0.tgz",
"integrity": "sha512-+DkDrhoR4Y0PxDz6rurahuB+I45OsEUv8E1maPTB6OuHRohMMcznBq9TMpdpDMm/hUPob/mJJS3PqgbHpMTQgw=="
"integrity": "sha512-+DkDrhoR4Y0PxDz6rurahuB+I45OsEUv8E1maPTB6OuHRohMMcznBq9TMpdpDMm/hUPob/mJJS3PqgbHpMTQgw==",
"hasInstallScript": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"jweixin-module": {
"node_modules/jweixin-module": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/jweixin-module/-/jweixin-module-1.6.0.tgz",
"integrity": "sha512-dGk9cf+ipipHmtzYmKZs5B2toX+p4hLyllGLF6xuC8t+B05oYxd8fYoaRz0T30U2n3RUv8a4iwvjhA+OcYz52w=="
},
"mutation-observer": {
"node_modules/mutation-observer": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/mutation-observer/-/mutation-observer-1.0.3.tgz",
"integrity": "sha512-M/O/4rF2h776hV7qGMZUH3utZLO/jK7p8rnNgGkjKUw8zCGjRQPxB8z6+5l8+VjRUQ3dNYu4vjqXYLr+U8ZVNA=="
},
"regenerator-runtime": {
"node_modules/regenerator-runtime": {
"version": "0.13.10",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz",
"integrity": "sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw=="
},
"uni-read-pages": {
"node_modules/uni-read-pages": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/uni-read-pages/-/uni-read-pages-1.0.5.tgz",
"integrity": "sha512-GkrrZ0LX0vn9R5k6RKEi0Ez3Q3e2vUpjXQ8Z6/K/d28KudI9ajqgt8WEjQFlG5EPm1K6uTArN8LlqmZTEixDUA=="
"integrity": "sha512-GkrrZ0LX0vn9R5k6RKEi0Ez3Q3e2vUpjXQ8Z6/K/d28KudI9ajqgt8WEjQFlG5EPm1K6uTArN8LlqmZTEixDUA==",
"hasInstallScript": true
},
"uni-simple-router": {
"node_modules/uni-simple-router": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/uni-simple-router/-/uni-simple-router-2.0.7.tgz",
"integrity": "sha512-8FKv5dw7Eoonm0gkO8udprrxzin0fNUI0+AvIphFkFRH5ZmP5ZWJ2pvnWzb2NiiqQSECTSU5VSB7HhvOSwD5eA=="
},
"vconsole": {
"node_modules/vconsole": {
"version": "3.14.7",
"resolved": "https://registry.npmjs.org/vconsole/-/vconsole-3.14.7.tgz",
"integrity": "sha512-48byrra1sZk0zJfUM8HFN/ZKtMzvd7Sirt2h5Hfnf7txKOxL9JfoFKB2uLixmOF3jjxCpwnLLb1tsHlg4YzoyA==",
"requires": {
"dependencies": {
"@babel/runtime": "^7.17.2",
"copy-text-to-clipboard": "^3.0.1",
"core-js": "^3.11.0",

查看文件

@@ -8,6 +8,7 @@
v-if="userHeadData && userHeadData.style"
:scrollTop="scrollTop"
:detail="userHeadData"
:refreshKey="incomeRefreshKey"
@onShare="onShare"
></userinfo-card>
@@ -109,7 +110,8 @@ export default {
scrollTop: 0,
showShare: false,
enable: false,
userData: {}
userData: {},
incomeRefreshKey: 0
};
},
computed: {
@@ -150,6 +152,7 @@ export default {
this.init();
this.getUserData();
this.getAgent();
this.incomeRefreshKey += 1;
}
this.enable = true;
},

查看文件

@@ -52,21 +52,91 @@
</view>
</view>
<view class="asset-bar u-flex">
<view class="asset-item u-flex-1 u-flex-col u-col-center" @tap="jump('/pages/user/wallet/index')">
<text class="asset-value font-OPPOSANS">{{ userInfo.money || '0.00' }}</text>
<text class="asset-label">余额(元)</text>
<!-- 普通用户:余额 / 积分 / 优惠券 -->
<view class="asset-bar u-flex" v-if="!showIncomePanel">
<view
class="asset-item u-flex-1 u-flex-col u-col-center"
v-for="item in walletItems"
:key="item.key"
@tap="onAssetTap(item)"
>
<text class="asset-value font-OPPOSANS">{{ item.displayValue }}</text>
<text class="asset-label">{{ item.label }}</text>
</view>
<view class="asset-divider"></view>
<view class="asset-item u-flex-1 u-flex-col u-col-center" @tap="jump('/pages/user/wallet/score-balance')">
<text class="asset-value font-OPPOSANS">{{ userInfo.score || '0' }}</text>
<text class="asset-label">积分</text>
</view>
</view>
<!-- 分销商 / 区域代理:汇付分账收益面板 -->
<view class="split-income-card brand-card" v-if="showIncomePanel">
<view class="split-income-card__head u-flex u-row-between u-col-center">
<view>
<text class="split-income-card__title">分账收益</text>
<text class="split-income-card__sub">平台钱包可提现 · 汇付已进件走子户</text>
</view>
<view class="asset-divider"></view>
<view class="asset-item u-flex-1 u-flex-col u-col-center" @tap="jump('/pages/app/coupon/list')">
<text class="asset-value font-OPPOSANS">{{ userOtherData.coupons_num || '0' }}</text>
<text class="asset-label">优惠券</text>
<text class="split-income-card__role">{{ incomeRoleText }}</text>
</view>
<!-- 平台钱包余额(兜底入账部分) -->
<view class="split-wallet" @tap="onAssetTap(balanceItem)">
<view class="split-wallet__top u-flex u-row-between u-col-center">
<text class="split-wallet__label">平台钱包 · 可提现(元)</text>
<text class="split-wallet__state">余额</text>
</view>
<text class="split-wallet__amount font-OPPOSANS">¥{{ balanceItem.displayValue }}</text>
<view class="split-wallet__foot u-flex u-row-between u-col-center">
<text class="split-wallet__tip">含未进件/分账失败的兜底入账;已进件部分直达汇付子户</text>
<text class="u-iconfont uicon-arrow-right split-wallet__arrow"></text>
</view>
</view>
<!-- 已结算:累计收益(非钱包余额) -->
<view class="split-settled u-flex" v-if="showCommissionMetric || showRegionalMetric">
<view
class="split-settled__item u-flex-1"
v-if="showCommissionMetric"
@tap="onAssetTap(commissionItem)"
>
<text class="split-settled__label">分销收益</text>
<text class="split-settled__value font-OPPOSANS">¥{{ commissionItem.displayValue }}</text>
<text class="split-settled__hint">累计已结算(含汇付子户,非可提现余额)</text>
</view>
<view
class="split-settled__item u-flex-1"
v-if="showRegionalMetric"
:class="{ 'split-settled__item--solo': !showCommissionMetric }"
@tap="onAssetTap(regionalItem)"
>
<text class="split-settled__label">区县收益</text>
<text class="split-settled__value font-OPPOSANS">¥{{ regionalItem.displayValue }}</text>
<text class="split-settled__hint">累计已结算(含汇付子户)</text>
</view>
</view>
<!-- 非终态:分账待到账 -->
<view class="split-pending u-flex u-row-between u-col-center" @tap="onAssetTap(pendingItem)">
<view class="split-pending__main">
<view class="split-pending__title-row u-flex u-col-center">
<text class="split-pending__dot"></text>
<text class="split-pending__title">待入账收益</text>
</view>
<text class="split-pending__desc">订单已产生,等待结算或汇付分账确认</text>
</view>
<view class="split-pending__right u-flex u-col-center">
<text class="split-pending__amount font-OPPOSANS">¥{{ pendingItem.displayValue }}</text>
<text class="u-iconfont uicon-arrow-right split-pending__arrow"></text>
</view>
</view>
<view
class="split-onboard-nudge u-flex u-row-between u-col-center"
v-if="showOnboardNudge"
@tap="jump('/pages/user/wallet/onboard-bind')"
>
<view class="split-onboard-nudge__text">
<text class="split-onboard-nudge__title">有待入账收益,建议完成汇付进件</text>
<text class="split-onboard-nudge__sub">进件通过后新单收益直达汇付子户;未完成时走平台钱包兜底</text>
</view>
<text class="split-onboard-nudge__link">去进件 ›</text>
</view>
</view>
</view>
@@ -125,15 +195,28 @@
isFixed: false,
navBackground: {
background: 'none'
}
},
regionalIncome: '0.00',
regionalDelayMoney: '0.00',
hasRegionalAgent: false,
huifuOnboard: null
};
},
mounted() {},
mounted() {
if (this.isLogin) {
this.loadRegionalIncome();
this.loadHuifuOnboardHint();
}
},
props: {
detail: {},
scrollTop: {
type: Number,
default: 0
},
refreshKey: {
type: Number,
default: 0
}
},
watch: {
@@ -142,6 +225,23 @@
this.navBackground.background = val > 50
? `linear-gradient(180deg, ${theme.navy} 0%, ${theme.navyLight} 100%)`
: 'none';
},
isLogin(val) {
if (val) {
this.loadRegionalIncome();
this.loadHuifuOnboardHint();
} else {
this.regionalIncome = '0.00';
this.regionalDelayMoney = '0.00';
this.hasRegionalAgent = false;
this.huifuOnboard = null;
}
},
refreshKey() {
if (this.isLogin) {
this.loadRegionalIncome();
this.loadHuifuOnboardHint();
}
}
},
computed: {
@@ -202,10 +302,168 @@
tags.push(this.agentInfo.agent_level.name);
}
return tags;
},
isCommissionAgent() {
return !!(this.agentInfo && this.agentInfo.agent_level);
},
showIncomePanel() {
return this.isLogin && (this.isCommissionAgent || this.hasRegionalAgent);
},
incomeRoleText() {
if (this.isCommissionAgent && this.hasRegionalAgent) {
return '分销商 · 区域代理';
}
if (this.isCommissionAgent) {
return '分销商';
}
if (this.hasRegionalAgent) {
return '区域代理';
}
return '';
},
walletItems() {
return [{
key: 'money',
label: '余额(元)',
displayValue: this.formatMoney(this.userInfo.money),
path: '/pages/user/wallet/index'
}, {
key: 'score',
label: '积分',
displayValue: String(this.userInfo.score || '0'),
path: '/pages/user/wallet/score-balance'
}, {
key: 'coupon',
label: '优惠券',
displayValue: String(this.userOtherData.coupons_num || '0'),
path: '/pages/app/coupon/list'
}];
},
pendingIncome() {
const commissionPending = parseFloat(this.agentInfo?.delay_money || 0) || 0;
const regionalPending = parseFloat(this.regionalDelayMoney || 0) || 0;
return this.formatMoney(commissionPending + regionalPending);
},
showCommissionMetric() {
return this.isCommissionAgent;
},
showRegionalMetric() {
return this.hasRegionalAgent;
},
balanceItem() {
return {
key: 'balance',
displayValue: this.formatMoney(this.userInfo.money),
path: '/pages/user/wallet/index'
};
},
commissionItem() {
return {
key: 'commission',
displayValue: this.formatMoney(this.agentInfo?.total_income),
path: '/pages/app/commission/index'
};
},
regionalItem() {
return {
key: 'regional',
displayValue: this.formatMoney(this.regionalIncome),
path: '/pages/app/regional-agent/index'
};
},
pendingItem() {
return {
key: 'pending',
displayValue: this.pendingIncome,
path: this.pendingIncomePath
};
},
pendingIncomePath() {
const commissionPending = parseFloat(this.agentInfo?.delay_money || 0) || 0;
const regionalPending = parseFloat(this.regionalDelayMoney || 0) || 0;
if (regionalPending > 0 && commissionPending <= 0) {
return '/pages/app/regional-agent/rewards';
}
return '/pages/app/commission/commission-log';
},
showOnboardNudge() {
if (!this.showIncomePanel || !this.isCommissionAgent) {
return false;
}
const pending = parseFloat(this.pendingIncome) || 0;
if (pending <= 0) {
return false;
}
const h = this.huifuOnboard;
if (!h || !h.required) {
return false;
}
if (h.status === 'approved') {
return false;
}
return !!h.can_submit || !h.status;
}
},
methods: {
...mapActions(['getUserInfo', 'showAuthModal']),
formatMoney(value) {
const num = parseFloat(value);
if (Number.isNaN(num)) {
return '0.00';
}
return num.toFixed(2);
},
loadHuifuOnboardHint() {
if (!this.isCommissionAgent) {
this.huifuOnboard = null;
return;
}
this.$http('commission.auth').then(res => {
if (res.code === 1 && res.data) {
this.huifuOnboard = res.data.huifu_onboard || null;
}
}).catch(() => {});
},
loadRegionalIncome() {
this.$http('regionalAgent.info').then(res => {
if (res.code !== 1 || !res.data || res.data.status === 'close') {
this.regionalIncome = '0.00';
this.regionalDelayMoney = '0.00';
this.hasRegionalAgent = false;
return;
}
this.regionalDelayMoney = res.data.delay_money || '0.00';
const agents = Array.isArray(res.data.agents) ? res.data.agents : [];
this.hasRegionalAgent = agents.some(item => item.status === 'normal');
const total = agents.reduce((sum, item) => {
return sum + (parseFloat(item.total_income) || 0);
}, 0);
if (total > 0) {
this.regionalIncome = total.toFixed(2);
return;
}
this.loadRegionalSummary();
}).catch(() => {
this.regionalIncome = '0.00';
this.regionalDelayMoney = '0.00';
this.hasRegionalAgent = false;
});
},
loadRegionalSummary() {
this.$http('regionalAgent.stats').then(res => {
if (res.code === 1) {
const summary = (res.data && res.data.summary) || {};
this.regionalIncome = this.formatMoney(summary.total_income);
}
}).catch(() => {});
},
onAssetTap(item) {
if (!this.isLogin) {
this.showAuthModal('accountLogin');
return;
}
this.jump(item.path);
},
onProfileTap() {
if (this.isLogin) {
this.$Router.push('/pages/user/info');
@@ -288,14 +546,276 @@
.sh-userinfo-box {
position: relative;
padding-bottom: 0;
padding-bottom: 8rpx;
.head-wrap {
width: 100%;
padding: 0 $brand-page-gutter 28rpx;
padding: 0 $brand-page-gutter 20rpx;
box-sizing: border-box;
}
.asset-bar {
margin-top: 28rpx;
padding: 8rpx 0 4rpx;
}
.asset-item {
min-width: 0;
& + .asset-item {
border-left: 1rpx solid rgba(255, 255, 255, 0.28);
}
}
.asset-value {
font-size: 36rpx;
font-weight: 600;
color: #fff;
line-height: 1.2;
}
.asset-label {
margin-top: 8rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.88);
}
.split-income-card {
margin: 16rpx $brand-page-gutter 0;
padding: 28rpx 24rpx 24rpx;
background: #ffffff;
box-shadow: 0 12rpx 40rpx rgba(10, 36, 99, 0.12);
}
.split-income-card__head {
margin-bottom: 24rpx;
}
.split-income-card__title {
display: block;
font-size: 32rpx;
font-weight: 600;
color: #1D2129;
line-height: 1.4;
}
.split-income-card__sub {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
color: #86909C;
line-height: 1.4;
}
.split-income-card__role {
flex-shrink: 0;
padding: 6rpx 16rpx;
border-radius: 20rpx;
font-size: 22rpx;
font-weight: 500;
color: #1677FF;
background: #E8F3FF;
}
.split-wallet {
padding: 28rpx;
border-radius: 20rpx;
background: $brand-primary-gradient;
margin-bottom: 20rpx;
&:active {
opacity: 0.92;
}
}
.split-wallet__label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.88);
}
.split-wallet__state {
padding: 4rpx 14rpx;
border-radius: 16rpx;
font-size: 20rpx;
font-weight: 500;
color: #1677FF;
background: rgba(255, 255, 255, 0.92);
}
.split-wallet__amount {
display: block;
margin-top: 16rpx;
font-size: 56rpx;
font-weight: 700;
color: #ffffff;
line-height: 1.2;
}
.split-wallet__foot {
margin-top: 16rpx;
}
.split-wallet__tip {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.78);
line-height: 1.4;
flex: 1;
min-width: 0;
}
.split-wallet__arrow {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.72);
margin-left: 8rpx;
}
.split-settled {
margin-bottom: 20rpx;
border: 1rpx solid #E5E7EB;
border-radius: 16rpx;
overflow: hidden;
background: #F7F8FA;
}
.split-settled__item {
min-width: 0;
padding: 24rpx 20rpx;
text-align: center;
background: #ffffff;
& + .split-settled__item {
border-left: 1rpx solid #E5E7EB;
}
&--solo {
border-left: none;
}
&:active {
background: #F7F8FA;
}
}
.split-settled__label {
display: block;
font-size: 24rpx;
font-weight: 600;
color: #1D2129;
}
.split-settled__value {
display: block;
margin-top: 12rpx;
font-size: 36rpx;
font-weight: 700;
color: #1677FF;
line-height: 1.2;
}
.split-settled__hint {
display: block;
margin-top: 8rpx;
font-size: 20rpx;
color: #86909C;
}
.split-pending {
padding: 24rpx;
border-radius: 16rpx;
background: linear-gradient(135deg, #FFF7E6 0%, #FFFBF0 100%);
border: 1rpx solid rgba(212, 107, 8, 0.22);
&:active {
opacity: 0.92;
}
}
.split-pending__main {
flex: 1;
min-width: 0;
padding-right: 16rpx;
}
.split-pending__title-row {
margin-bottom: 8rpx;
}
.split-pending__dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #D46B08;
margin-right: 10rpx;
flex-shrink: 0;
}
.split-pending__title {
font-size: 28rpx;
font-weight: 600;
color: #1D2129;
}
.split-pending__desc {
display: block;
font-size: 22rpx;
color: #86909C;
line-height: 1.45;
}
.split-pending__right {
flex-shrink: 0;
}
.split-pending__amount {
font-size: 36rpx;
font-weight: 700;
color: #D46B08;
line-height: 1.2;
}
.split-pending__arrow {
font-size: 22rpx;
color: rgba(212, 107, 8, 0.55);
margin-left: 8rpx;
}
.split-onboard-nudge {
margin-top: 20rpx;
padding: 20rpx 24rpx;
border-radius: 12rpx;
background: rgba(116, 56, 213, 0.08);
border: 1rpx solid rgba(116, 56, 213, 0.18);
}
.split-onboard-nudge__text {
flex: 1;
min-width: 0;
padding-right: 16rpx;
}
.split-onboard-nudge__title {
display: block;
font-size: 26rpx;
font-weight: 600;
color: #1D2129;
line-height: 1.4;
}
.split-onboard-nudge__sub {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
color: #86909C;
line-height: 1.45;
}
.split-onboard-nudge__link {
flex-shrink: 0;
font-size: 26rpx;
font-weight: 600;
color: #7438D5;
}
.user-head {
width: 100%;
padding-top: 8rpx;
@@ -440,34 +960,6 @@
}
}
.asset-bar {
margin-top: 28rpx;
padding: 8rpx 0 4rpx;
}
.asset-item {
min-width: 0;
}
.asset-value {
font-size: 36rpx;
font-weight: 600;
color: #fff;
line-height: 1.2;
}
.asset-label {
margin-top: 8rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.72);
}
.asset-divider {
width: 1rpx;
height: 56rpx;
background: rgba(255, 255, 255, 0.22);
align-self: center;
}
}
// 绑定微信公众号
@@ -497,9 +989,6 @@
}
}
</style>
}
</style>
</style>

查看文件

@@ -7,8 +7,9 @@
<!-- 可提现 -->
<view class="wallet-num-box u-flex u-col-center u-row-between">
<view class="">
<view class="num-title">可提现金额(元)</view>
<view class="num-title">平台钱包 · 可提现(元)</view>
<view class="wallet-num">{{ userInfo.money || '0.00' }}</view>
<view class="num-tip">不含已进件推广员走汇付子户到账的部分,子户资金请在汇付侧结算/提现</view>
</view>
<button class="u-reset-button log-btn"
@tap="$Router.push({ path: '/pages/user/wallet/withdraw-log' })">提现记录</button>
@@ -358,7 +359,15 @@
font-size: 26rpx;
font-weight: 500;
color: #ffffff;
margin-bottom: 20rpx;
margin-bottom: 12rpx;
}
.num-tip {
font-size: 22rpx;
line-height: 1.45;
color: rgba(255, 255, 255, 0.82);
margin-bottom: 16rpx;
max-width: 520rpx;
}
.wallet-num {