diff --git a/addons/shopro/library/split/SplitCloseService.php b/addons/shopro/library/split/SplitCloseService.php new file mode 100644 index 0000000..59e6091 --- /dev/null +++ b/addons/shopro/library/split/SplitCloseService.php @@ -0,0 +1,202 @@ +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; + } +} diff --git a/addons/shopro/library/split/SplitExecutor.php b/addons/shopro/library/split/SplitExecutor.php index 9f0d5e9..28514a9 100644 --- a/addons/shopro/library/split/SplitExecutor.php +++ b/addons/shopro/library/split/SplitExecutor.php @@ -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); } diff --git a/addons/shopro/library/split/SplitManualService.php b/addons/shopro/library/split/SplitManualService.php index e523a86..60ab957 100644 --- a/addons/shopro/library/split/SplitManualService.php +++ b/addons/shopro/library/split/SplitManualService.php @@ -572,6 +572,7 @@ class SplitManualService 'update' => '修改', 'clear' => '清除', 'retry' => '保存并重试', + 'close' => '线下结案', ]; return $map[$action] ?? $action; } diff --git a/addons/shopro/library/split/SplitWalletSettlement.php b/addons/shopro/library/split/SplitWalletSettlement.php index ab24bd8..cf63a91 100644 --- a/addons/shopro/library/split/SplitWalletSettlement.php +++ b/addons/shopro/library/split/SplitWalletSettlement.php @@ -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; + } } diff --git a/addons/shopro/tests/split_commission_verify.php b/addons/shopro/tests/split_commission_verify.php index bc68534..8f46c0f 100644 --- a/addons/shopro/tests/split_commission_verify.php +++ b/addons/shopro/tests/split_commission_verify.php @@ -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"; diff --git a/addons/shopro/upgrade_split_close.sql b/addons/shopro/upgrade_split_close.sql new file mode 100644 index 0000000..b0f3091 --- /dev/null +++ b/addons/shopro/upgrade_split_close.sql @@ -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 '操作类型'; diff --git a/addons/shopro/upgrade_split_close_menu.sql b/addons/shopro/upgrade_split_close_menu.sql new file mode 100644 index 0000000..0a07929 --- /dev/null +++ b/addons/shopro/upgrade_split_close_menu.sql @@ -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'); diff --git a/addons/shopro_huifu/command/SplitRetry.php b/addons/shopro_huifu/command/SplitRetry.php index a83ca29..448f986 100644 --- a/addons/shopro_huifu/command/SplitRetry.php +++ b/addons/shopro_huifu/command/SplitRetry.php @@ -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) { diff --git a/addons/shopro_huifu/library/HuifuSplitService.php b/addons/shopro_huifu/library/HuifuSplitService.php index f3a8caa..6fb0fba 100644 --- a/addons/shopro_huifu/library/HuifuSplitService.php +++ b/addons/shopro_huifu/library/HuifuSplitService.php @@ -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']); } /** diff --git a/application/admin/controller/shopro/split/Order.php b/application/admin/controller/shopro/split/Order.php index e338702..199f1ca 100644 --- a/application/admin/controller/shopro/split/Order.php +++ b/application/admin/controller/shopro/split/Order.php @@ -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 { diff --git a/application/admin/view/shopro/split/order/index.html b/application/admin/view/shopro/split/order/index.html index 9baa461..eac497c 100644 --- a/application/admin/view/shopro/split/order/index.html +++ b/application/admin/view/shopro/split/order/index.html @@ -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 @@ + @@ -322,8 +324,13 @@ ¥{{ formatMoney(s.row.split_amount) }} - +