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

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

查看文件

@@ -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 {

查看文件

@@ -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 @@
<el-option label="成功" value="2"></el-option>
<el-option label="部分成功" value="3"></el-option>
<el-option label="失败" value="4"></el-option>
<el-option label="已结案" value="5"></el-option>
</el-select>
<el-select v-model="filters.channel" placeholder="通道" size="small" style="width:100px;" clearable>
<el-option label="汇付" value="huifu"></el-option>
@@ -322,8 +324,13 @@
<span class="money-num highlight">¥{{ formatMoney(s.row.split_amount) }}</span>
</template>
</el-table-column>
<el-table-column label="分账来源" class-name="col-source" width="130" align="center">
<el-table-column label="分账来源" class-name="col-source" width="148" align="center">
<template slot-scope="s">
<div v-if="s.row.settlement_source_text" style="font-size:11px;color:#606266;margin-bottom:4px;">
结算:{{ s.row.settlement_source_text }}
<span v-if="parseFloat(s.row.wallet_settlement_sum) > 0">(钱包 ¥{{ formatMoney(s.row.wallet_settlement_sum) }})</span>
</div>
<div v-if="s.row.retry_blocked && s.row.status == 4" style="font-size:11px;color:#f56c6c;margin-bottom:4px;">不可重试</div>
<div class="source-cell" v-if="s.row.split_source === 'manual' || s.row.has_manual_override || s.row.calc_source">
<span class="source-tag manual" v-if="s.row.split_source === 'manual' || s.row.has_manual_override">手动</span>
<span v-else-if="s.row.calc_source" :class="'source-tag ' + (s.row.calc_source === 'commission' ? 'commission' : 'rule')">
@@ -388,7 +395,8 @@
<template slot-scope="s">
<el-button type="text" size="small" v-if="s.row.can_edit_manual" @click="openManualDialog(s.row)">指定接收方</el-button>
<el-button type="text" size="small" v-if="s.row.can_view_manual_history" @click="openHistoryDrawer(s.row)">变更历史</el-button>
<el-button type="text" size="small" v-if="s.row.status == 4" @click="retry(s.row)">重试</el-button>
<el-button type="text" size="small" v-if="s.row.can_retry_split && !s.row.is_closed" @click="retry(s.row)">重试</el-button>
<el-button type="text" size="small" v-if="s.row.can_mark_closed" @click="markClosed(s.row)">标记结案</el-button>
<el-button type="text" size="small" @click="query(s.row.id)">同步</el-button>
</template>
</el-table-column>
@@ -405,7 +413,7 @@
@size-change="onSizeChange">
</el-pagination>
</div>
<p class="op-hint">失败汇付单可「指定接收方」改人改金额后重试;分账成功或失败后均可点「变更历史」查看每次改人记录。「同步」只更新状态,不会重复扣款。</p>
<p class="op-hint">失败汇付单可「指定接收方」改人改金额后重试;财务已线下处理的异常单请「标记结案」以停止自动重试。分账成功或失败后均可点「变更历史」查看记录。「同步」只更新状态,不会重复扣款。</p>
</div>
<!-- 指定接收方弹窗 -->