```
feat(split): 添加分账手动指定接收方功能 - 实现SplitManualService服务类,支持手动覆盖分账明细 - 新增SplitManual和SplitManualLog数据模型 - 修改SplitCalculator优先使用手动覆盖数据进行分账计算 - 添加分账记录手动指定接收方的数据库表结构 - 集成手动分账到汇付分账流程中,支持失败重试机制 - 添加商品详情内容更新SQL脚本用于汇付审核 ```
这个提交包含在:
@@ -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>
|
||||
|
||||
在新工单中引用
屏蔽一个用户