```
feat(split): 新增分账订单线下结案功能 - 添加 SplitCloseService 类用于处理订单结案逻辑 - 实现订单结案状态检查和标记功能 - 在汇付分账重试逻辑中加入结案状态验证,防止已结案订单继续重试 - 更新分账手动服务,添加结案操作类型 - 扩展钱包结算类,增加结案相关诊断方法 - 修改测试脚本,支持批量标记订单为已结案状态 - 添加数据库升级脚本,增加结案相关字段和菜单权限 ```
这个提交包含在:
@@ -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']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
在新工单中引用
屏蔽一个用户