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

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

查看文件

@@ -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";