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