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']);
}
/**