feat(split): 添加分账手动指定接收方功能

- 实现SplitManualService服务类,支持手动覆盖分账明细
- 新增SplitManual和SplitManualLog数据模型
- 修改SplitCalculator优先使用手动覆盖数据进行分账计算
- 添加分账记录手动指定接收方的数据库表结构
- 集成手动分账到汇付分账流程中,支持失败重试机制
- 添加商品详情内容更新SQL脚本用于汇付审核
```
这个提交包含在:
可乐加冰
2026-10-22 22:38:35 +08:00
父节点 b5142ff780
当前提交 af08af8d47
共有 11 个文件被更改,包括 1301 次插入 和 11 次删除

查看文件

@@ -6,6 +6,7 @@ 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\SplitExecutor;
use addons\shopro\library\split\SplitManualService;
use addons\shopro\library\split\ProfitSharingService;
use app\admin\model\shopro\order\Order as ShopOrder;
use app\admin\model\shopro\user\User as ShopUser;
@@ -183,12 +184,18 @@ class Order extends Base
$data['fail_reason_full'] = $this->enrichFailReasonWithDiagnose($data['fail_reason_full'], $status, $diag);
$data['fail_reason'] = $this->summarizeFailReason($data['fail_reason_full'], $diag, $status);
$data['split_source'] = $diag['final_source'];
$data['split_source_text'] = $this->formatSplitSourceText($diag);
$data['calc_source'] = $diag['final_source'];
$data['calc_source_text'] = $this->formatSplitSourceText($diag);
$data['split_diagnose'] = $diag['issues'];
$data['commission_total'] = $diag['commission_total'];
$data['rule_total'] = $diag['rule_total'];
$orderSplitSource = $data['split_source'] ?? 'auto';
$data['split_source'] = $orderSplitSource;
$data['split_source_text'] = $orderSplitSource === 'manual' ? '手动指定' : $data['calc_source_text'];
$data['has_manual_override'] = (new SplitManualService())->hasOverride($orderId);
$data['can_edit_manual'] = ($status === 4 && $channel === 'huifu');
unset($data['order'], $data['receivers']);
$rows[] = $data;
}
@@ -468,6 +475,143 @@ class Order extends Base
return $map[$type] ?? ($type ?: '接收方');
}
/**
* 加载指定接收方弹窗数据
*/
public function manualReceivers()
{
$splitOrderId = intval($this->request->get('split_order_id', 0));
if ($splitOrderId <= 0) {
$this->error('参数错误');
}
try {
$context = (new SplitManualService())->getManualContext($splitOrderId);
} catch (\Throwable $e) {
$this->error($e->getMessage());
}
$this->success('操作成功', null, $context);
}
/**
* 保存手动覆盖(不重试)
*/
public function saveManual()
{
$splitOrderId = intval($this->request->post('split_order_id', 0));
$items = $this->request->post('items/a', []);
$remark = trim((string) $this->request->post('remark', ''));
$row = $this->model->get($splitOrderId);
if (!$row) {
$this->error('分账记录不存在');
}
try {
(new SplitManualService())->save(
intval($row['order_id']),
$items,
intval($this->auth->id),
(string) ($this->auth->nickname ?? $this->auth->username ?? ''),
$remark
);
} catch (\Throwable $e) {
$this->error($e->getMessage());
}
$this->success('保存成功');
}
/**
* 保存手动覆盖并重新分账
*/
public function saveManualAndRetry()
{
$splitOrderId = intval($this->request->post('split_order_id', 0));
$items = $this->request->post('items/a', []);
$remark = trim((string) $this->request->post('remark', ''));
$row = $this->model->get($splitOrderId);
if (!$row) {
$this->error('分账记录不存在');
}
$orderId = intval($row['order_id']);
$adminId = intval($this->auth->id);
$adminName = (string) ($this->auth->nickname ?? $this->auth->username ?? '');
$service = new SplitManualService();
try {
$service->save($orderId, $items, $adminId, $adminName, $remark);
$service->markPendingRetryLog($orderId, $adminId, $adminName, $remark);
} catch (\Throwable $e) {
$this->error($e->getMessage());
}
try {
if (class_exists('\\addons\\shopro_huifu\\library\\HuifuSplitService')) {
$result = (new \addons\shopro_huifu\library\HuifuSplitService())->executeByOrderId($orderId);
} else {
$result = (new SplitExecutor())->retry($splitOrderId);
}
} catch (\Throwable $e) {
$service->finishPendingRetryLog($orderId, false, $e->getMessage());
$this->error('重试失败:' . $e->getMessage());
}
if ($result) {
$this->success('保存并重试成功');
}
$hint = $this->formatSplitWxResultHint($orderId, 'huifu');
$this->error('保存成功但分账失败' . ($hint ?: ''));
}
/**
* 清除手动覆盖
*/
public function clearManual()
{
$splitOrderId = intval($this->request->post('split_order_id', 0));
$remark = trim((string) $this->request->post('remark', ''));
$row = $this->model->get($splitOrderId);
if (!$row) {
$this->error('分账记录不存在');
}
try {
(new SplitManualService())->clear(
intval($row['order_id']),
intval($this->auth->id),
(string) ($this->auth->nickname ?? $this->auth->username ?? ''),
$remark
);
} catch (\Throwable $e) {
$this->error($e->getMessage());
}
$this->success('已清除手动指定');
}
/**
* 变更历史
*/
public function manualHistory()
{
$orderId = intval($this->request->get('order_id', 0));
if ($orderId <= 0) {
$splitOrderId = intval($this->request->get('split_order_id', 0));
if ($splitOrderId > 0) {
$row = $this->model->get($splitOrderId);
$orderId = $row ? intval($row['order_id']) : 0;
}
}
if ($orderId <= 0) {
$this->error('参数错误');
}
$offset = intval($this->request->get('offset', 0));
$limit = intval($this->request->get('limit', 20));
$limit = max(1, min(100, $limit));
$result = (new SplitManualService())->getHistory($orderId, $offset, $limit);
$this->success('操作成功', null, $result);
}
public function retry($ids = null)
{
$id = $ids ?: $this->request->post('id');

查看文件

@@ -108,6 +108,18 @@
}
.source-tag.commission { background: #ecf5ff; color: #409eff; }
.source-tag.rule { background: #fdf6ec; color: #e6a23c; }
.source-tag.manual { background: #f0ebfa; color: #7438D5; }
.manual-badge {
display: inline-block;
margin-left: 4px;
padding: 0 5px;
font-size: 10px;
line-height: 16px;
border-radius: 3px;
background: #fef0f0;
color: #f56c6c;
vertical-align: middle;
}
.source-detail-btn {
flex-shrink: 0;
font-size: 11px;
@@ -187,6 +199,57 @@
.diag-pop li { margin-bottom: 6px; }
.diag-pop li:last-child { margin-bottom: 0; }
.op-hint { font-size: 12px; color: #909399; margin-top: 10px; }
.manual-dialog .section-title {
font-size: 13px;
font-weight: 600;
color: #303133;
margin: 0 0 8px;
}
.manual-dialog .section-block {
margin-bottom: 16px;
padding: 12px;
background: #fafafa;
border-radius: 6px;
}
.manual-dialog .orig-item {
font-size: 12px;
color: #606266;
line-height: 1.7;
padding: 4px 0;
border-bottom: 1px dashed #ebeef5;
}
.manual-dialog .orig-item:last-child { border-bottom: none; }
.manual-dialog .sum-row {
margin-top: 10px;
font-size: 13px;
text-align: right;
color: #303133;
}
.manual-dialog .sum-row strong { color: #e1212b; }
.history-drawer .history-item {
padding: 12px 0;
border-bottom: 1px solid #ebeef5;
}
.history-drawer .history-item:last-child { border-bottom: none; }
.history-drawer .history-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.history-drawer .history-action {
font-weight: 600;
color: #303133;
font-size: 13px;
}
.history-drawer .history-time { font-size: 12px; color: #909399; }
.history-drawer .history-meta { font-size: 12px; color: #606266; margin-bottom: 6px; }
.history-drawer .items-diff { font-size: 12px; line-height: 1.65; color: #606266; }
.history-drawer .items-diff .arrow { color: #909399; margin: 0 4px; }
.history-drawer .retry-result { margin-top: 6px; font-size: 12px; }
.history-drawer .retry-result.success { color: #67c23a; }
.history-drawer .retry-result.fail { color: #f56c6c; }
.history-drawer .retry-result.pending { color: #409eff; }
</style>
<div id="splitOrderIndex" v-cloak>
<div class="page-card">
@@ -249,14 +312,15 @@
<span class="money-num highlight">¥{{ formatMoney(s.row.split_amount) }}</span>
</template>
</el-table-column>
<el-table-column label="分账来源" class-name="col-source" width="118" align="center">
<el-table-column label="分账来源" class-name="col-source" width="130" align="center">
<template slot-scope="s">
<div class="source-cell" v-if="s.row.split_source">
<span :class="'source-tag ' + (s.row.split_source === 'commission' ? 'commission' : 'rule')">
{{ s.row.split_source === 'commission' ? '按佣金' : '规则兜底' }}
<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')">
{{ s.row.calc_source === 'commission' ? '按佣金' : '规则兜底' }}
</span>
<el-popover
v-if="s.row.split_source === 'rule' && s.row.split_diagnose && s.row.split_diagnose.length"
v-if="s.row.calc_source === 'rule' && s.row.split_diagnose && s.row.split_diagnose.length && s.row.split_source !== 'manual'"
placement="left"
width="300"
trigger="click">
@@ -310,8 +374,10 @@
<span v-else style="color:#909399;">—</span>
</template>
</el-table-column>
<el-table-column label="操作" width="128" fixed="right">
<el-table-column label="操作" width="200" fixed="right">
<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_edit_manual && (s.row.has_manual_override || s.row.split_source === 'manual')" @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" @click="query(s.row.id)">同步</el-button>
</template>
@@ -329,8 +395,98 @@
@size-change="onSizeChange">
</el-pagination>
</div>
<p class="op-hint">「重试」仅对失败记录向通道重新发起;「同步」只更新状态,不会重复扣款。</p>
<p class="op-hint">失败汇付单可「指定接收方」改人改金额后重试;「同步」只更新状态,不会重复扣款。</p>
</div>
<!-- 指定接收方弹窗 -->
<el-dialog title="指定接收方" :visible.sync="manualDialogVisible" width="720px" :close-on-click-modal="false" custom-class="manual-dialog">
<div v-loading="manualLoading">
<p style="font-size:13px;color:#606266;margin:0 0 12px;">
订单 <strong>{{ manualContext.order_sn }}</strong>
· 确认基数 ¥{{ formatMoney(manualContext.confirm_base) }}
· 上限 ¥{{ formatMoney(manualContext.max_split_amount) }}
</p>
<div class="section-block" v-if="manualContext.original_items && manualContext.original_items.length">
<p class="section-title">原自动计算明细</p>
<div class="orig-item" v-for="(it, idx) in manualContext.original_items" :key="'orig'+idx">
{{ formatManualItemLine(it) }}
</div>
</div>
<p class="section-title">手动指定明细</p>
<el-table :data="manualForm.items" border size="small" style="width:100%;margin-bottom:10px;">
<el-table-column label="接收方" min-width="220">
<template slot-scope="s">
<el-select v-model="s.row.receiver_id" filterable placeholder="选择汇付接收方" size="small" style="width:100%;" @change="onReceiverChange(s.row)">
<el-option v-for="opt in manualContext.receiver_options" :key="opt.id" :label="opt.label" :value="opt.id"></el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="角色" width="100">
<template slot-scope="s">
<el-select v-model="s.row.role_type" size="small" style="width:100%;">
<el-option label="推广员" value="promoter"></el-option>
<el-option label="区域公司" value="regional_agent"></el-option>
<el-option label="平台" value="platform"></el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="金额" width="120">
<template slot-scope="s">
<el-input-number v-model="s.row.amount" :min="0.01" :precision="2" :step="0.01" size="small" controls-position="right" style="width:100%;"></el-input-number>
</template>
</el-table-column>
<el-table-column label="说明" width="120">
<template slot-scope="s">
<el-input v-model="s.row.description" size="small" placeholder="备注"></el-input>
</template>
</el-table-column>
<el-table-column label="" width="56" align="center">
<template slot-scope="s">
<el-button type="text" size="small" style="color:#f56c6c;" @click="removeManualRow(s.$index)">删</el-button>
</template>
</el-table-column>
</el-table>
<el-button size="small" @click="addManualRow">+ 添加一行</el-button>
<div class="sum-row">合计 <strong>¥{{ formatMoney(manualItemsTotal) }}</strong></div>
<el-input v-model="manualForm.remark" type="textarea" :rows="2" placeholder="操作备注(可选)" style="margin-top:12px;"></el-input>
</div>
<span slot="footer">
<el-button size="small" @click="manualDialogVisible = false">取消</el-button>
<el-button size="small" v-if="manualContext.has_manual" @click="clearManualOverride">清除手动指定</el-button>
<el-button size="small" type="primary" plain :loading="manualSaving" @click="saveManual(false)">仅保存</el-button>
<el-button size="small" type="primary" :loading="manualSaving" @click="saveManual(true)">保存并重试</el-button>
</span>
</el-dialog>
<!-- 变更历史抽屉 -->
<el-drawer title="变更历史" :visible.sync="historyDrawerVisible" size="480px" custom-class="history-drawer">
<div v-loading="historyLoading" style="padding:0 20px 20px;">
<div v-if="!historyList.length && !historyLoading" style="color:#909399;font-size:13px;text-align:center;padding:40px 0;">暂无变更记录</div>
<div class="history-item" v-for="h in historyList" :key="h.id">
<div class="history-head">
<span class="history-action">{{ h.action_text }}</span>
<span class="history-time">{{ h.createtime_text }}</span>
</div>
<div class="history-meta" v-if="h.admin_name">操作人:{{ h.admin_name }}</div>
<div class="history-meta" v-if="h.remark">备注:{{ h.remark }}</div>
<div class="items-diff">
<div v-if="h.before_items && h.before_items.length">
<div v-for="(it, i) in h.before_items" :key="'b'+i">{{ formatManualItemLine(it) }}</div>
</div>
<span v-else style="color:#c0c4cc;">(空)</span>
<span class="arrow">→</span>
<div v-if="h.after_items && h.after_items.length">
<div v-for="(it, i) in h.after_items" :key="'a'+i">{{ formatManualItemLine(it) }}</div>
</div>
<span v-else style="color:#c0c4cc;">(空)</span>
</div>
<div v-if="h.retry_result" :class="'retry-result ' + h.retry_result">
重试结果:{{ h.retry_result_text }}
<span v-if="h.retry_message"> — {{ h.retry_message }}</span>
</div>
</div>
</div>
</el-drawer>
</div>
<script src="__CDN__/assets/addons/shopro/libs/vue.js"></script>
<script src="__CDN__/assets/addons/shopro/libs/element/element.js"></script>